1767 lines
49 KiB
Vue
1767 lines
49 KiB
Vue
<script setup lang="ts">
|
||
import {
|
||
AlertTriangle,
|
||
Check,
|
||
ChevronDown,
|
||
ChevronUp,
|
||
ClipboardCheck,
|
||
Edit3,
|
||
FileSpreadsheet,
|
||
FileText,
|
||
ImagePlus,
|
||
LoaderCircle,
|
||
Mic,
|
||
Plus,
|
||
RefreshCw,
|
||
Save,
|
||
SlidersHorizontal,
|
||
Undo2,
|
||
Upload,
|
||
Users,
|
||
X,
|
||
} from "@lucide/vue";
|
||
import { computed, onMounted, reactive, ref } from "vue";
|
||
import { useRouter } from "vue-router";
|
||
|
||
import { downloadOcrFile } from "@/api/ocr";
|
||
import { fetchModelConfig, updateModelConfig } from "@/api/modelConfig";
|
||
import { useReviewStore } from "@/stores/review.store";
|
||
import { useAuthStore } from "@/stores/auth.store";
|
||
import OcrRecognitionDialog from "@/components/ocr/OcrRecognitionDialog.vue";
|
||
import { featureFlags } from "@/config/features";
|
||
import type { OcrStandardJudgment } from "@/types/ocr";
|
||
import type { RiskItem, RiskItemUpdate } from "@/types/riskItem";
|
||
import type { ModelWeightConfig, WeightDimension } from "@/types/modelConfig";
|
||
|
||
const router = useRouter();
|
||
const store = useReviewStore();
|
||
const auth = useAuthStore();
|
||
const sourceFilter = ref("all");
|
||
const statusFilter = ref("待审核");
|
||
const expandedId = ref<number | null>(null);
|
||
const editingId = ref<number | null>(null);
|
||
const reviewingId = ref<number | null>(null);
|
||
const withdrawingId = ref<number | null>(null);
|
||
const ocrOpen = ref(false);
|
||
const attachmentError = ref("");
|
||
const modelConfig = ref<ModelWeightConfig | null>(null);
|
||
const modelConfigError = ref("");
|
||
const savingWeights = ref(false);
|
||
const weightDraft = reactive<Record<WeightDimension, number>>({
|
||
供应商表现: 25,
|
||
物料品类: 20,
|
||
产品品类: 15,
|
||
加工厂质量: 15,
|
||
标准符合性: 10,
|
||
节点阶段: 10,
|
||
缺陷等级: 5,
|
||
});
|
||
const frequencyCoefficient = ref(0.3);
|
||
const frequencyCap = ref(1.5);
|
||
const notes = reactive<Record<number, string>>({});
|
||
const editForm = reactive<Record<number, RiskItemUpdate>>({});
|
||
|
||
const sources = ["all", "表单", "语音", "OCR", "Excel导入"];
|
||
const statuses = ["待审核", "待预审", "all"];
|
||
const pendingCount = computed(
|
||
() => store.items.filter((item) => item.status === "待审核").length,
|
||
);
|
||
const preReviewCount = computed(
|
||
() => store.items.filter((item) => item.status === "待预审").length,
|
||
);
|
||
const sourceCount = computed(
|
||
() => new Set(store.items.map((item) => item.sourceType)).size,
|
||
);
|
||
const weightTotal = computed(() =>
|
||
Object.values(weightDraft).reduce(
|
||
(sum, value) => sum + Number(value || 0),
|
||
0,
|
||
),
|
||
);
|
||
const permissionRows = [
|
||
{ role: "管理员", assess: true, submit: true, review: true, weights: true },
|
||
{
|
||
role: "产品经理",
|
||
assess: true,
|
||
submit: true,
|
||
review: true,
|
||
weights: false,
|
||
},
|
||
{ role: "审核员", assess: true, submit: false, review: true, weights: false },
|
||
{
|
||
role: "user",
|
||
assess: true,
|
||
submit: false,
|
||
review: false,
|
||
weights: false,
|
||
},
|
||
];
|
||
const permissionKeys = ["assess", "submit", "review", "weights"] as const;
|
||
const weightRows: Array<{ dimension: WeightDimension; description: string }> = [
|
||
{ dimension: "供应商表现", description: "含频次风险系数 α 修正" },
|
||
{ dimension: "物料品类", description: "按物料历史异常率" },
|
||
{ dimension: "产品品类", description: "拆解到具体产品品类" },
|
||
{ dimension: "加工厂质量", description: "巡检 / 批次合格率" },
|
||
{ dimension: "节点阶段", description: "开发阶段越靠后风险越高" },
|
||
{ dimension: "缺陷等级", description: "A 级缺陷加权" },
|
||
{ dimension: "标准符合性", description: "按判定标准库量化达标率计分" },
|
||
];
|
||
|
||
function load() {
|
||
return store.loadPending({
|
||
sourceType: sourceFilter.value,
|
||
status: statusFilter.value,
|
||
});
|
||
}
|
||
async function loadModelWeights() {
|
||
if (!auth.hasPermission("model_weight.manage")) return;
|
||
modelConfigError.value = "";
|
||
try {
|
||
const response = await fetchModelConfig();
|
||
if (!response.data.data) throw new Error("模型配置响应为空");
|
||
modelConfig.value = response.data.data;
|
||
Object.assign(weightDraft, response.data.data.weights);
|
||
frequencyCoefficient.value = response.data.data.frequencyCoefficient;
|
||
frequencyCap.value = response.data.data.frequencyCap;
|
||
} catch {
|
||
modelConfigError.value = "模型权重加载失败";
|
||
}
|
||
}
|
||
async function saveModelWeights() {
|
||
if (Math.abs(weightTotal.value - 100) > 0.01 || savingWeights.value) return;
|
||
savingWeights.value = true;
|
||
modelConfigError.value = "";
|
||
try {
|
||
const response = await updateModelConfig({
|
||
weights: { ...weightDraft },
|
||
frequencyCoefficient: frequencyCoefficient.value,
|
||
frequencyCap: frequencyCap.value,
|
||
});
|
||
if (!response.data.data) throw new Error("模型配置响应为空");
|
||
modelConfig.value = response.data.data;
|
||
} catch {
|
||
modelConfigError.value = "权重保存失败,请检查各维度合计是否为100%";
|
||
} finally {
|
||
savingWeights.value = false;
|
||
}
|
||
}
|
||
function sourceClass(source: string) {
|
||
return source === "OCR"
|
||
? "source-ocr"
|
||
: source === "语音"
|
||
? "source-voice"
|
||
: source === "Excel导入"
|
||
? "source-excel"
|
||
: "source-form";
|
||
}
|
||
function sourceIcon(source: string) {
|
||
return source === "OCR"
|
||
? ImagePlus
|
||
: source === "语音"
|
||
? Mic
|
||
: source === "Excel导入"
|
||
? FileSpreadsheet
|
||
: Edit3;
|
||
}
|
||
function levelClass(level: string | null | undefined) {
|
||
return level ? `level-${level.toLowerCase()}` : "level-empty";
|
||
}
|
||
function isOwnSubmission(item: RiskItem) {
|
||
return Boolean(auth.user?.empNo && item.submittedBy === auth.user.empNo);
|
||
}
|
||
function toggleItem(id: number) {
|
||
expandedId.value = expandedId.value === id ? null : id;
|
||
if (editingId.value === id) editingId.value = null;
|
||
}
|
||
function beginEdit(item: RiskItem) {
|
||
expandedId.value = item.id;
|
||
editingId.value = item.id;
|
||
editForm[item.id] = {
|
||
productName: item.productName,
|
||
productCategory: item.productCategory,
|
||
materialType: item.materialType,
|
||
occurredOn: item.occurredOn,
|
||
supplier: item.supplier,
|
||
factory: item.factory,
|
||
stageNew: item.stageNew,
|
||
description: item.description,
|
||
defectLevel: item.defectLevel,
|
||
solution: item.solution,
|
||
};
|
||
}
|
||
function ocrFiles(item: RiskItem) {
|
||
const files = item.ocrPayload?.files;
|
||
if (!Array.isArray(files)) return [];
|
||
return files.filter(
|
||
(file): file is { fileId: string; fileName: string } =>
|
||
typeof file === "object" &&
|
||
file !== null &&
|
||
typeof file.fileId === "string" &&
|
||
typeof file.fileName === "string",
|
||
);
|
||
}
|
||
function ocrJudgments(item: RiskItem): OcrStandardJudgment[] {
|
||
const judgments = item.ocrPayload?.confirmedJudgments;
|
||
return Array.isArray(judgments) ? (judgments as OcrStandardJudgment[]) : [];
|
||
}
|
||
async function openAttachment(fileId: string) {
|
||
attachmentError.value = "";
|
||
try {
|
||
const response = await downloadOcrFile(fileId);
|
||
const url = URL.createObjectURL(response.data);
|
||
window.open(url, "_blank", "noopener,noreferrer");
|
||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||
} catch {
|
||
attachmentError.value = "附件打开失败或已被清理";
|
||
}
|
||
}
|
||
function editValue(id: number, key: keyof RiskItemUpdate) {
|
||
return editForm[id]?.[key] ?? "";
|
||
}
|
||
function setEditValue(id: number, key: keyof RiskItemUpdate, value: string) {
|
||
editForm[id] = { ...(editForm[id] ?? {}), [key]: value };
|
||
}
|
||
async function saveEdit(item: RiskItem) {
|
||
const changes = editForm[item.id];
|
||
if (!changes?.description?.trim() || changes.description.trim().length < 5)
|
||
return;
|
||
try {
|
||
await store.update(item.id, changes);
|
||
editingId.value = null;
|
||
} catch {
|
||
// The store error is displayed above the queue.
|
||
}
|
||
}
|
||
async function review(item: RiskItem, action: "通过" | "驳回") {
|
||
const note = notes[item.id]?.trim() ?? "";
|
||
if (note.length < 2 || reviewingId.value !== null) return;
|
||
if (!window.confirm(`确认${action}该风险项吗?审核动作会写入审计记录。`))
|
||
return;
|
||
reviewingId.value = item.id;
|
||
try {
|
||
await store.review(item.id, action, note);
|
||
expandedId.value = null;
|
||
} catch {
|
||
// The exact API error is shown above the queue.
|
||
} finally {
|
||
reviewingId.value = null;
|
||
}
|
||
}
|
||
async function withdraw(item: RiskItem) {
|
||
if (withdrawingId.value !== null) return;
|
||
if (!window.confirm("确认撤回该风险项吗?撤回后将不再显示在审核队列中。"))
|
||
return;
|
||
withdrawingId.value = item.id;
|
||
try {
|
||
await store.withdraw(item.id);
|
||
if (expandedId.value === item.id) expandedId.value = null;
|
||
} catch {
|
||
// The exact API error is shown above the queue.
|
||
} finally {
|
||
withdrawingId.value = null;
|
||
}
|
||
}
|
||
function formatDate(value: string | null) {
|
||
if (!value) return "未记录时间";
|
||
return new Intl.DateTimeFormat("zh-CN", {
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
}).format(new Date(value));
|
||
}
|
||
onMounted(() => {
|
||
void load();
|
||
void loadModelWeights();
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div class="admin-page">
|
||
<header class="admin-header">
|
||
<div>
|
||
<p class="eyebrow">RISK ITEM CONSOLE</p>
|
||
<h1>风险项管理工作台</h1>
|
||
<p class="admin-description">风险项录入、审核与数据质量管理</p>
|
||
</div>
|
||
<div class="admin-header-actions">
|
||
<button
|
||
class="button button--secondary"
|
||
type="button"
|
||
:disabled="store.loading"
|
||
@click="load"
|
||
>
|
||
<RefreshCw
|
||
:size="15"
|
||
:class="{ spinning: store.loading }"
|
||
/>刷新</button
|
||
><button
|
||
v-if="auth.hasPermission('risk_item.submit')"
|
||
class="button button--primary"
|
||
type="button"
|
||
@click="router.push({ name: 'risk-item-entry' })"
|
||
>
|
||
<Plus :size="15" />录入风险项
|
||
</button>
|
||
</div>
|
||
</header>
|
||
<section class="admin-metrics">
|
||
<article class="metric-card metric-card--pending">
|
||
<span class="metric-icon"><ClipboardCheck :size="17" /></span>
|
||
<div>
|
||
<small>待审核</small><strong>{{ pendingCount }}</strong>
|
||
</div>
|
||
<span>需要人工把关</span>
|
||
</article>
|
||
<article class="metric-card metric-card--pre">
|
||
<span class="metric-icon"><AlertTriangle :size="17" /></span>
|
||
<div>
|
||
<small>待预审</small><strong>{{ preReviewCount }}</strong>
|
||
</div>
|
||
<span>等待系统校验</span>
|
||
</article>
|
||
<article class="metric-card">
|
||
<span class="metric-icon"><FileText :size="17" /></span>
|
||
<div>
|
||
<small>当前队列</small><strong>{{ store.items.length }}</strong>
|
||
</div>
|
||
<span>{{ sourceCount }} 种来源</span>
|
||
</article>
|
||
</section>
|
||
<div v-if="store.error" class="queue-error" role="alert">
|
||
<AlertTriangle :size="16" />{{ store.error
|
||
}}<button type="button" @click="load">重试</button>
|
||
</div>
|
||
<div class="admin-layout">
|
||
<section class="queue-panel">
|
||
<header class="queue-header">
|
||
<div>
|
||
<h2>审核队列</h2>
|
||
<span>按提交时间倒序 · {{ store.items.length }} 条</span>
|
||
</div>
|
||
<div class="queue-filters">
|
||
<select
|
||
:value="sourceFilter"
|
||
aria-label="来源筛选"
|
||
@change="
|
||
sourceFilter = ($event.target as HTMLSelectElement).value;
|
||
load();
|
||
"
|
||
>
|
||
<option v-for="value in sources" :key="value" :value="value">
|
||
{{ value === "all" ? "全部来源" : value }}
|
||
</option></select
|
||
><select
|
||
:value="statusFilter"
|
||
aria-label="状态筛选"
|
||
@change="
|
||
statusFilter = ($event.target as HTMLSelectElement).value;
|
||
load();
|
||
"
|
||
>
|
||
<option v-for="value in statuses" :key="value" :value="value">
|
||
{{ value === "all" ? "全部状态" : value }}
|
||
</option>
|
||
</select>
|
||
</div>
|
||
</header>
|
||
<div v-if="store.loading" class="queue-state">
|
||
<LoaderCircle :size="20" class="spinning" />正在加载审核队列…
|
||
</div>
|
||
<div
|
||
v-else-if="!store.items.length"
|
||
class="queue-state queue-state--empty"
|
||
>
|
||
<ClipboardCheck :size="28" /><strong>当前没有符合条件的风险项</strong
|
||
><span>新提交的风险项通过预审后会出现在这里。</span
|
||
><button
|
||
v-if="auth.hasPermission('risk_item.submit')"
|
||
class="button button--secondary"
|
||
type="button"
|
||
@click="router.push({ name: 'risk-item-entry' })"
|
||
>
|
||
<Plus :size="14" />录入风险项
|
||
</button>
|
||
</div>
|
||
<div v-else class="review-list">
|
||
<article
|
||
v-for="item in store.items"
|
||
:key="item.id"
|
||
class="review-item"
|
||
:class="{ expanded: expandedId === item.id }"
|
||
>
|
||
<div class="review-summary">
|
||
<button
|
||
class="review-summary__content"
|
||
type="button"
|
||
@click="toggleItem(item.id)"
|
||
>
|
||
<span class="source-badge" :class="sourceClass(item.sourceType)"
|
||
><component :is="sourceIcon(item.sourceType)" :size="13" />{{
|
||
item.sourceType
|
||
}}</span
|
||
><span class="review-title"
|
||
><strong>{{ item.productName || "未填写产品名称" }}</strong
|
||
><small
|
||
>{{ item.productCategory || "未分类" }} ·
|
||
{{ formatDate(item.submittedAt || item.createdAt) }}</small
|
||
></span
|
||
>
|
||
</button>
|
||
<button
|
||
v-if="item.status === '待审核' || item.status === '待预审'"
|
||
class="review-withdraw"
|
||
type="button"
|
||
:disabled="withdrawingId !== null"
|
||
title="撤回风险项"
|
||
@click="withdraw(item)"
|
||
>
|
||
<Undo2 :size="12" />{{
|
||
withdrawingId === item.id ? "撤回中" : "撤回"
|
||
}}
|
||
</button>
|
||
<span class="review-meta"
|
||
><span
|
||
class="level-tag"
|
||
:class="levelClass(item.defectLevel)"
|
||
>{{
|
||
item.defectLevel ? `${item.defectLevel}级` : "未定级"
|
||
}}</span
|
||
><span
|
||
class="item-status"
|
||
:class="item.status === '待审核' ? 'is-pending' : 'is-pre'"
|
||
>{{ item.status }}</span
|
||
></span
|
||
>
|
||
<button
|
||
class="review-toggle"
|
||
type="button"
|
||
:aria-label="expandedId === item.id ? '收起风险项' : '展开风险项'"
|
||
@click="toggleItem(item.id)"
|
||
>
|
||
<ChevronUp v-if="expandedId === item.id" :size="17" />
|
||
<ChevronDown v-else :size="17" />
|
||
</button>
|
||
</div>
|
||
<div v-if="expandedId === item.id" class="review-detail">
|
||
<template v-if="editingId === item.id"
|
||
><div class="edit-grid">
|
||
<label
|
||
>产品名称<input
|
||
:value="editValue(item.id, 'productName')"
|
||
@input="
|
||
setEditValue(
|
||
item.id,
|
||
'productName',
|
||
($event.target as HTMLInputElement).value,
|
||
)
|
||
" /></label
|
||
><label
|
||
>产品品类<input
|
||
:value="editValue(item.id, 'productCategory')"
|
||
@input="
|
||
setEditValue(
|
||
item.id,
|
||
'productCategory',
|
||
($event.target as HTMLInputElement).value,
|
||
)
|
||
" /></label
|
||
><label
|
||
>物料<input
|
||
:value="editValue(item.id, 'materialType')"
|
||
@input="
|
||
setEditValue(
|
||
item.id,
|
||
'materialType',
|
||
($event.target as HTMLInputElement).value,
|
||
)
|
||
" /></label
|
||
><label
|
||
>发生日期<input
|
||
type="date"
|
||
:value="editValue(item.id, 'occurredOn')"
|
||
@input="
|
||
setEditValue(
|
||
item.id,
|
||
'occurredOn',
|
||
($event.target as HTMLInputElement).value,
|
||
)
|
||
" /></label
|
||
><label
|
||
>供应商<input
|
||
:value="editValue(item.id, 'supplier')"
|
||
@input="
|
||
setEditValue(
|
||
item.id,
|
||
'supplier',
|
||
($event.target as HTMLInputElement).value,
|
||
)
|
||
" /></label
|
||
><label
|
||
>加工厂<input
|
||
:value="editValue(item.id, 'factory')"
|
||
@input="
|
||
setEditValue(
|
||
item.id,
|
||
'factory',
|
||
($event.target as HTMLInputElement).value,
|
||
)
|
||
" /></label
|
||
><label
|
||
>流程阶段<select
|
||
:value="editValue(item.id, 'stageNew')"
|
||
@change="
|
||
setEditValue(
|
||
item.id,
|
||
'stageNew',
|
||
($event.target as HTMLSelectElement).value,
|
||
)
|
||
"
|
||
>
|
||
<option>第一阶段(需求发现)</option>
|
||
<option>第二阶段(产品立项)</option>
|
||
<option>第三阶段(产品开发)</option>
|
||
<option>第四阶段(量产上市)</option>
|
||
<option>第五阶段(产品生命管理)</option>
|
||
</select></label
|
||
><label
|
||
>缺陷等级<select
|
||
:value="editValue(item.id, 'defectLevel')"
|
||
@change="
|
||
setEditValue(
|
||
item.id,
|
||
'defectLevel',
|
||
($event.target as HTMLSelectElement).value,
|
||
)
|
||
"
|
||
>
|
||
<option value="">未确定</option>
|
||
<option>A</option>
|
||
<option>B</option>
|
||
<option>C</option>
|
||
</select></label
|
||
>
|
||
</div>
|
||
<label class="full-field"
|
||
>异常描述<textarea
|
||
rows="4"
|
||
:value="editValue(item.id, 'description')"
|
||
@input="
|
||
setEditValue(
|
||
item.id,
|
||
'description',
|
||
($event.target as HTMLTextAreaElement).value,
|
||
)
|
||
"
|
||
></textarea></label
|
||
><label class="full-field"
|
||
>解决方案<textarea
|
||
rows="3"
|
||
:value="editValue(item.id, 'solution')"
|
||
@input="
|
||
setEditValue(
|
||
item.id,
|
||
'solution',
|
||
($event.target as HTMLTextAreaElement).value,
|
||
)
|
||
"
|
||
></textarea>
|
||
</label>
|
||
<div class="detail-actions">
|
||
<button type="button" @click="editingId = null">取消</button
|
||
><button
|
||
class="approve-button"
|
||
type="button"
|
||
@click="saveEdit(item)"
|
||
>
|
||
<Check :size="14" />保存修改
|
||
</button>
|
||
</div></template
|
||
>
|
||
<template v-else
|
||
><dl class="detail-fields">
|
||
<div>
|
||
<dt>产品品类</dt>
|
||
<dd>{{ item.productCategory || "未填写" }}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>物料</dt>
|
||
<dd>{{ item.materialType || "未填写" }}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>发生日期</dt>
|
||
<dd>{{ item.occurredOn || "未填写" }}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>供应商</dt>
|
||
<dd>{{ item.supplier || "未填写" }}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>加工厂</dt>
|
||
<dd>{{ item.factory || "未填写" }}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>发现阶段</dt>
|
||
<dd>{{ item.stageNew || "未填写" }}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>提交人</dt>
|
||
<dd>{{ item.submittedBy || "未填写" }}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>提交时间</dt>
|
||
<dd>
|
||
{{ formatDate(item.submittedAt || item.createdAt) }}
|
||
</dd>
|
||
</div>
|
||
</dl>
|
||
<div class="description-block">
|
||
<strong>异常描述</strong>
|
||
<p>{{ item.description || "未填写" }}</p>
|
||
</div>
|
||
<div v-if="item.solution" class="description-block">
|
||
<strong>解决方案</strong>
|
||
<p>{{ item.solution }}</p>
|
||
</div>
|
||
<div v-if="ocrFiles(item).length" class="attachment-list">
|
||
<button
|
||
v-for="file in ocrFiles(item)"
|
||
:key="file.fileId"
|
||
type="button"
|
||
title="通过鉴权打开原始附件"
|
||
@click="openAttachment(file.fileId)"
|
||
>
|
||
<FileText :size="14" />{{ file.fileName }}
|
||
</button>
|
||
</div>
|
||
<p v-if="attachmentError" class="form-error">
|
||
{{ attachmentError }}
|
||
</p>
|
||
<div v-if="ocrJudgments(item).length" class="ocr-judgments">
|
||
<strong>OCR 标准判定</strong>
|
||
<div
|
||
v-for="judgment in ocrJudgments(item)"
|
||
:key="`${judgment.measurementItem}-${judgment.standardRuleId}`"
|
||
>
|
||
<span
|
||
>{{ judgment.measurementItem }}:{{
|
||
judgment.measuredValue
|
||
}}</span
|
||
>
|
||
<span>{{ judgment.standardValue || "未匹配标准" }}</span>
|
||
<b
|
||
:class="
|
||
judgment.result === '不合格'
|
||
? 'is-fail'
|
||
: judgment.result === '合格'
|
||
? 'is-pass'
|
||
: 'is-review'
|
||
"
|
||
>{{ judgment.result }}</b
|
||
>
|
||
<small v-if="judgment.standardRuleId"
|
||
>标准 #{{ judgment.standardRuleId }}
|
||
{{ judgment.basis || "" }}</small
|
||
>
|
||
</div>
|
||
</div>
|
||
<div class="review-controls">
|
||
<p v-if="isOwnSubmission(item)" class="self-review-warning">
|
||
<AlertTriangle
|
||
:size="14"
|
||
/>该风险项由当前账号提交。根据审核防自审规则,请由其他审核员或管理者处理。
|
||
</p>
|
||
<textarea
|
||
v-model="notes[item.id]"
|
||
rows="2"
|
||
maxlength="200"
|
||
placeholder="填写审核意见(驳回时必填,通过时建议填写)"
|
||
></textarea>
|
||
<div class="detail-actions">
|
||
<button
|
||
class="edit-button"
|
||
type="button"
|
||
:disabled="
|
||
item.status === '已通过' || item.status === '已驳回'
|
||
"
|
||
@click="beginEdit(item)"
|
||
>
|
||
<Edit3 :size="14" />编辑</button
|
||
><button
|
||
class="reject-button"
|
||
type="button"
|
||
:disabled="
|
||
item.status !== '待审核' ||
|
||
isOwnSubmission(item) ||
|
||
reviewingId !== null ||
|
||
(notes[item.id]?.trim().length ?? 0) < 2
|
||
"
|
||
@click="review(item, '驳回')"
|
||
>
|
||
<X :size="14" />驳回</button
|
||
><button
|
||
class="approve-button"
|
||
type="button"
|
||
:disabled="
|
||
item.status !== '待审核' ||
|
||
isOwnSubmission(item) ||
|
||
reviewingId !== null ||
|
||
(notes[item.id]?.trim().length ?? 0) < 2
|
||
"
|
||
@click="review(item, '通过')"
|
||
>
|
||
<Check :size="14" />通过入库
|
||
</button>
|
||
</div>
|
||
</div></template
|
||
>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
</section>
|
||
<aside class="admin-tools">
|
||
<article
|
||
v-if="auth.hasPermission('risk_item.submit')"
|
||
class="tool-card"
|
||
>
|
||
<header>
|
||
<span class="tool-icon tool-icon--blue"
|
||
><ClipboardCheck :size="16"
|
||
/></span>
|
||
<div>
|
||
<h3>风险项表单录入</h3>
|
||
<p>填写风险项并提交审核</p>
|
||
</div>
|
||
</header>
|
||
<button
|
||
class="tool-link"
|
||
type="button"
|
||
@click="router.push({ name: 'risk-item-entry' })"
|
||
>
|
||
<Plus :size="14" />新建风险项
|
||
</button>
|
||
</article>
|
||
<article
|
||
v-if="auth.hasPermission('risk_item.submit')"
|
||
class="tool-card"
|
||
>
|
||
<header>
|
||
<span class="tool-icon tool-icon--cyan"
|
||
><ImagePlus :size="16"
|
||
/></span>
|
||
<div>
|
||
<h3>风险项 OCR 录入</h3>
|
||
<p>识别报告并生成待审核风险项</p>
|
||
</div>
|
||
</header>
|
||
<button class="tool-link" type="button" @click="ocrOpen = true">
|
||
<Upload :size="14" />识别并录入风险项
|
||
</button>
|
||
</article>
|
||
<article v-if="featureFlags.voiceEntry" class="tool-card">
|
||
<header>
|
||
<span class="tool-icon tool-icon--purple"><Mic :size="16" /></span>
|
||
<div>
|
||
<h3>风险项语音录入</h3>
|
||
<p>口述异常并生成待审核风险项</p>
|
||
</div>
|
||
</header>
|
||
<button class="voice-entry" type="button">
|
||
<Mic :size="17" /><span>按住说话</span></button
|
||
><small class="tool-hint">普通话 · 最长 60 秒</small>
|
||
</article>
|
||
<article
|
||
v-if="auth.hasPermission('risk_item.submit')"
|
||
class="tool-card"
|
||
>
|
||
<header>
|
||
<span class="tool-icon tool-icon--green"
|
||
><FileSpreadsheet :size="16"
|
||
/></span>
|
||
<div>
|
||
<h3>风险项 Excel 批量导入</h3>
|
||
<p>按模板批量生成待审核风险项</p>
|
||
</div>
|
||
</header>
|
||
<button class="tool-link" type="button">
|
||
<Upload :size="14" />批量导入风险项</button
|
||
><small class="tool-hint">单次最多 1000 条,自动去重校验</small>
|
||
</article>
|
||
<article class="tool-card config-card">
|
||
<header>
|
||
<span class="tool-icon tool-icon--gray"
|
||
><ClipboardCheck :size="16"
|
||
/></span>
|
||
<div>
|
||
<h3>审核规则</h3>
|
||
<p>预审与审核动作均留痕</p>
|
||
</div>
|
||
</header>
|
||
<div class="config-row">
|
||
<span>审核防自审</span><strong>已启用</strong>
|
||
</div>
|
||
<div class="config-row">
|
||
<span>审核意见</span><strong>通过 / 驳回均支持</strong>
|
||
</div>
|
||
</article>
|
||
</aside>
|
||
</div>
|
||
<div
|
||
v-if="
|
||
featureFlags.adminConfigurationPanels &&
|
||
auth.hasPermission('model_weight.manage')
|
||
"
|
||
class="administration-config"
|
||
>
|
||
<section class="admin-config-panel">
|
||
<header class="admin-config-heading">
|
||
<span><Users :size="17" /></span>
|
||
<div>
|
||
<h2>用户权限(含审核角色)</h2>
|
||
<p>四角色权限矩阵</p>
|
||
</div>
|
||
</header>
|
||
<div class="config-table-wrap">
|
||
<table class="config-table">
|
||
<thead>
|
||
<tr>
|
||
<th>角色</th>
|
||
<th>评估/查询</th>
|
||
<th>录入/上传</th>
|
||
<th>审核</th>
|
||
<th>权重配置</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="row in permissionRows" :key="row.role">
|
||
<td>{{ row.role }}</td>
|
||
<td v-for="key in permissionKeys" :key="key">
|
||
<Check v-if="row[key]" class="permission-yes" :size="15" />
|
||
<span v-else class="permission-no">—</span>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="admin-config-panel">
|
||
<header class="admin-config-heading">
|
||
<span><SlidersHorizontal :size="17" /></span>
|
||
<div>
|
||
<h2>模型权重配置</h2>
|
||
<p>七维模型,保存后对新评估实时生效</p>
|
||
</div>
|
||
<strong>七维 · 合计 {{ weightTotal }}%</strong>
|
||
</header>
|
||
<div class="config-table-wrap">
|
||
<table class="config-table weight-table">
|
||
<thead>
|
||
<tr>
|
||
<th>维度</th>
|
||
<th>权重</th>
|
||
<th>说明</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="row in weightRows" :key="row.dimension">
|
||
<td>{{ row.dimension }}</td>
|
||
<td>
|
||
<label
|
||
><input
|
||
v-model.number="weightDraft[row.dimension]"
|
||
type="number"
|
||
min="1"
|
||
max="100"
|
||
step="0.5"
|
||
/><span>%</span></label
|
||
>
|
||
</td>
|
||
<td>{{ row.description }}</td>
|
||
</tr>
|
||
<tr class="frequency-row">
|
||
<td>频次风险系数</td>
|
||
<td colspan="2">
|
||
<label
|
||
>α = 1.0 + (N / 100) ×
|
||
<input
|
||
v-model.number="frequencyCoefficient"
|
||
type="number"
|
||
min="0"
|
||
max="1"
|
||
step="0.05"
|
||
/></label>
|
||
<label
|
||
>上限
|
||
<input
|
||
v-model.number="frequencyCap"
|
||
type="number"
|
||
min="1"
|
||
max="3"
|
||
step="0.1"
|
||
/></label>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<footer class="config-actions">
|
||
<p v-if="modelConfigError" class="form-error">
|
||
{{ modelConfigError }}
|
||
</p>
|
||
<span v-else-if="modelConfig?.updatedBy"
|
||
>最近更新:{{ modelConfig.updatedBy }}</span
|
||
>
|
||
<span v-else>权重合计必须为 100%</span>
|
||
<button
|
||
class="button button--primary"
|
||
type="button"
|
||
:disabled="savingWeights || Math.abs(weightTotal - 100) > 0.01"
|
||
@click="saveModelWeights"
|
||
>
|
||
<LoaderCircle
|
||
v-if="savingWeights"
|
||
class="spinning"
|
||
:size="14"
|
||
/><Save v-else :size="14" />{{
|
||
savingWeights ? "保存中…" : "保存配置"
|
||
}}
|
||
</button>
|
||
</footer>
|
||
</section>
|
||
</div>
|
||
<OcrRecognitionDialog
|
||
:open="ocrOpen"
|
||
@close="ocrOpen = false"
|
||
@submitted="load"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.admin-page {
|
||
width: min(1320px, 100%);
|
||
margin: 0 auto;
|
||
padding: 28px 34px 48px;
|
||
overflow-y: auto;
|
||
}
|
||
.admin-header,
|
||
.admin-header-actions,
|
||
.queue-header,
|
||
.queue-header > div,
|
||
.review-summary,
|
||
.review-meta,
|
||
.detail-actions,
|
||
.review-controls,
|
||
.tool-card header,
|
||
.config-row {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
.admin-header,
|
||
.queue-header {
|
||
justify-content: space-between;
|
||
gap: 20px;
|
||
}
|
||
.admin-header {
|
||
margin-bottom: 22px;
|
||
}
|
||
.admin-header h1 {
|
||
margin: 4px 0 3px;
|
||
font-size: 24px;
|
||
font-weight: 750;
|
||
}
|
||
.admin-description {
|
||
margin: 0;
|
||
color: var(--color-text-muted);
|
||
font-size: 11px;
|
||
}
|
||
.admin-header-actions {
|
||
gap: 8px;
|
||
}
|
||
.admin-metrics {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 10px;
|
||
margin-bottom: 18px;
|
||
}
|
||
.metric-card {
|
||
display: flex;
|
||
min-width: 0;
|
||
align-items: center;
|
||
gap: 10px;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 7px;
|
||
padding: 14px;
|
||
background: var(--color-surface);
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
.metric-icon,
|
||
.tool-icon {
|
||
display: grid;
|
||
place-items: center;
|
||
border-radius: 6px;
|
||
}
|
||
.metric-icon {
|
||
width: 34px;
|
||
height: 34px;
|
||
flex: 0 0 34px;
|
||
background: var(--color-bg-warm);
|
||
color: var(--color-text-muted);
|
||
}
|
||
.metric-card--pending .metric-icon {
|
||
background: var(--color-warning-soft);
|
||
color: var(--color-warning);
|
||
}
|
||
.metric-card--pre .metric-icon {
|
||
background: var(--color-danger-soft);
|
||
color: var(--color-danger);
|
||
}
|
||
.metric-card small {
|
||
display: block;
|
||
color: var(--color-text-muted);
|
||
font-size: 10px;
|
||
}
|
||
.metric-card strong {
|
||
display: block;
|
||
font-size: 21px;
|
||
line-height: 1.1;
|
||
}
|
||
.metric-card > span:last-child {
|
||
margin-left: auto;
|
||
color: var(--color-text-faint);
|
||
font-size: 10px;
|
||
}
|
||
.queue-error {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
margin-bottom: 14px;
|
||
border: 1px solid var(--color-danger-border);
|
||
border-radius: 6px;
|
||
padding: 10px 12px;
|
||
background: var(--color-danger-soft);
|
||
color: var(--color-danger);
|
||
font-size: 11px;
|
||
}
|
||
.queue-error button {
|
||
margin-left: auto;
|
||
border: 1px solid var(--color-danger-border);
|
||
border-radius: 4px;
|
||
padding: 4px 8px;
|
||
background: white;
|
||
color: var(--color-danger);
|
||
font-size: 10px;
|
||
}
|
||
.admin-layout {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) 300px;
|
||
gap: 16px;
|
||
align-items: start;
|
||
}
|
||
.queue-panel {
|
||
min-width: 0;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 7px;
|
||
background: var(--color-surface);
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
.queue-header {
|
||
border-bottom: 1px solid var(--color-border-light);
|
||
padding: 15px 17px;
|
||
}
|
||
.queue-header > div {
|
||
gap: 10px;
|
||
}
|
||
.queue-header h2 {
|
||
margin: 0;
|
||
font-size: 14px;
|
||
}
|
||
.queue-header span {
|
||
color: var(--color-text-faint);
|
||
font-size: 10px;
|
||
}
|
||
.queue-filters {
|
||
display: flex;
|
||
gap: 6px;
|
||
}
|
||
.queue-filters select {
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 4px;
|
||
padding: 6px 8px;
|
||
background: white;
|
||
color: var(--color-text-secondary);
|
||
font-size: 10px;
|
||
}
|
||
.review-list {
|
||
padding: 0 12px;
|
||
}
|
||
.review-item {
|
||
border-bottom: 1px solid var(--color-border-light);
|
||
}
|
||
.review-item:last-child {
|
||
border-bottom: 0;
|
||
}
|
||
.review-summary {
|
||
width: 100%;
|
||
gap: 11px;
|
||
padding: 8px 5px;
|
||
background: transparent;
|
||
color: var(--color-text);
|
||
}
|
||
.review-summary:hover,
|
||
.review-item.expanded .review-summary {
|
||
background: var(--color-bg-warm);
|
||
}
|
||
.review-summary__content {
|
||
display: flex;
|
||
min-width: 0;
|
||
flex: 1;
|
||
align-items: center;
|
||
gap: 11px;
|
||
border: 0;
|
||
padding: 6px 0;
|
||
background: transparent;
|
||
color: var(--color-text);
|
||
font: inherit;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
}
|
||
.review-withdraw {
|
||
display: inline-flex;
|
||
flex: 0 0 auto;
|
||
min-height: 26px;
|
||
align-items: center;
|
||
gap: 4px;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 5px;
|
||
padding: 0 8px;
|
||
background: var(--color-surface);
|
||
color: var(--color-text-muted);
|
||
font: inherit;
|
||
font-size: 10px;
|
||
cursor: pointer;
|
||
}
|
||
.review-withdraw:hover:not(:disabled) {
|
||
border-color: #fecaca;
|
||
background: #fef2f2;
|
||
color: var(--color-danger);
|
||
}
|
||
.review-withdraw:disabled {
|
||
cursor: wait;
|
||
opacity: 0.55;
|
||
}
|
||
.review-toggle {
|
||
display: grid;
|
||
width: 28px;
|
||
height: 28px;
|
||
flex: 0 0 28px;
|
||
place-items: center;
|
||
border: 0;
|
||
border-radius: 5px;
|
||
padding: 0;
|
||
background: transparent;
|
||
color: var(--color-text-secondary);
|
||
cursor: pointer;
|
||
}
|
||
.review-toggle:hover {
|
||
background: var(--color-surface);
|
||
}
|
||
.source-badge {
|
||
display: inline-flex;
|
||
min-width: 52px;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 4px;
|
||
border-radius: 4px;
|
||
padding: 5px 6px;
|
||
font-size: 9px;
|
||
font-weight: 650;
|
||
}
|
||
.source-form {
|
||
background: var(--color-accent-subtle);
|
||
color: var(--color-accent);
|
||
}
|
||
.source-voice {
|
||
background: var(--color-purple-soft);
|
||
color: var(--color-purple);
|
||
}
|
||
.source-ocr {
|
||
background: #ecfeff;
|
||
color: #0891b2;
|
||
}
|
||
.source-excel {
|
||
background: var(--color-success-soft);
|
||
color: var(--color-success);
|
||
}
|
||
.review-title {
|
||
display: grid;
|
||
min-width: 0;
|
||
flex: 1;
|
||
gap: 2px;
|
||
}
|
||
.review-title strong {
|
||
overflow: hidden;
|
||
font-size: 12px;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.review-title small {
|
||
color: var(--color-text-faint);
|
||
font-size: 10px;
|
||
}
|
||
.review-meta {
|
||
flex: 0 0 auto;
|
||
gap: 6px;
|
||
}
|
||
.level-tag,
|
||
.item-status {
|
||
border-radius: 999px;
|
||
padding: 3px 7px;
|
||
font-size: 9px;
|
||
white-space: nowrap;
|
||
}
|
||
.level-a {
|
||
background: var(--color-danger-soft);
|
||
color: var(--color-danger);
|
||
}
|
||
.level-b {
|
||
background: var(--color-warning-soft);
|
||
color: var(--color-warning);
|
||
}
|
||
.level-c,
|
||
.level-empty {
|
||
background: var(--color-bg-warm);
|
||
color: var(--color-text-muted);
|
||
}
|
||
.item-status.is-pending {
|
||
background: var(--color-warning-soft);
|
||
color: var(--color-warning);
|
||
}
|
||
.item-status.is-pre {
|
||
background: var(--color-danger-soft);
|
||
color: var(--color-danger);
|
||
}
|
||
.review-detail {
|
||
border-top: 1px solid var(--color-border-light);
|
||
padding: 16px 8px 18px 68px;
|
||
}
|
||
.detail-fields {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 12px;
|
||
margin: 0 0 14px;
|
||
}
|
||
.detail-fields div {
|
||
min-width: 0;
|
||
}
|
||
.detail-fields dt {
|
||
color: var(--color-text-faint);
|
||
font-size: 10px;
|
||
}
|
||
.detail-fields dd {
|
||
overflow-wrap: anywhere;
|
||
margin: 2px 0 0;
|
||
color: var(--color-text-secondary);
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
}
|
||
.description-block {
|
||
border-left: 3px solid var(--color-border-strong);
|
||
margin-top: 10px;
|
||
padding-left: 9px;
|
||
}
|
||
.description-block strong {
|
||
font-size: 10px;
|
||
}
|
||
.description-block p {
|
||
margin: 2px 0 0;
|
||
color: var(--color-text-muted);
|
||
font-size: 11px;
|
||
line-height: 1.6;
|
||
white-space: pre-wrap;
|
||
}
|
||
.attachment-list {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
margin-top: 12px;
|
||
}
|
||
.attachment-list button {
|
||
display: inline-flex;
|
||
min-width: 0;
|
||
align-items: center;
|
||
gap: 5px;
|
||
border: 1px solid var(--color-border-strong);
|
||
border-radius: 4px;
|
||
padding: 5px 8px;
|
||
background: white;
|
||
color: var(--color-accent);
|
||
font-size: 10px;
|
||
cursor: pointer;
|
||
}
|
||
.ocr-judgments {
|
||
display: grid;
|
||
gap: 7px;
|
||
margin-top: 14px;
|
||
border: 1px solid var(--color-border-light);
|
||
border-radius: 5px;
|
||
padding: 10px;
|
||
}
|
||
.ocr-judgments > strong {
|
||
font-size: 11px;
|
||
}
|
||
.ocr-judgments > div {
|
||
display: grid;
|
||
grid-template-columns: minmax(100px, 0.8fr) minmax(180px, 1.5fr) auto;
|
||
gap: 6px 10px;
|
||
align-items: start;
|
||
border-top: 1px solid var(--color-border-light);
|
||
padding-top: 7px;
|
||
color: var(--color-text-secondary);
|
||
font-size: 10px;
|
||
}
|
||
.ocr-judgments b {
|
||
border-radius: 3px;
|
||
padding: 2px 5px;
|
||
font-size: 10px;
|
||
}
|
||
.ocr-judgments b.is-pass {
|
||
background: var(--color-success-soft);
|
||
color: var(--color-success);
|
||
}
|
||
.ocr-judgments b.is-fail {
|
||
background: var(--color-danger-soft);
|
||
color: var(--color-danger);
|
||
}
|
||
.ocr-judgments b.is-review {
|
||
background: var(--color-warning-soft);
|
||
color: var(--color-warning);
|
||
}
|
||
.ocr-judgments small {
|
||
grid-column: 1 / -1;
|
||
color: var(--color-text-faint);
|
||
}
|
||
.review-controls {
|
||
align-items: flex-end;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
margin-top: 17px;
|
||
}
|
||
.self-review-warning {
|
||
display: flex;
|
||
width: 100%;
|
||
align-items: flex-start;
|
||
gap: 6px;
|
||
margin: 0;
|
||
border-left: 3px solid var(--color-warning);
|
||
padding: 7px 9px;
|
||
background: var(--color-warning-soft);
|
||
color: var(--color-warning);
|
||
font-size: 11px;
|
||
line-height: 1.5;
|
||
}
|
||
.review-controls textarea {
|
||
min-width: 0;
|
||
flex: 1;
|
||
border: 1px solid var(--color-border-strong);
|
||
border-radius: 5px;
|
||
padding: 8px;
|
||
font-size: 11px;
|
||
resize: vertical;
|
||
}
|
||
.detail-actions {
|
||
flex: 0 0 auto;
|
||
gap: 6px;
|
||
}
|
||
.detail-actions button {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
border: 1px solid var(--color-border-strong);
|
||
border-radius: 4px;
|
||
padding: 7px 9px;
|
||
background: white;
|
||
color: var(--color-text-secondary);
|
||
font-size: 10px;
|
||
}
|
||
.detail-actions button:disabled {
|
||
cursor: not-allowed;
|
||
opacity: 0.45;
|
||
}
|
||
.approve-button {
|
||
border-color: var(--color-success) !important;
|
||
background: var(--color-success) !important;
|
||
color: white !important;
|
||
}
|
||
.approve-button:hover:not(:disabled) {
|
||
background: #12843d !important;
|
||
}
|
||
.reject-button:hover:not(:disabled) {
|
||
border-color: var(--color-danger) !important;
|
||
color: var(--color-danger) !important;
|
||
}
|
||
.edit-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 11px;
|
||
}
|
||
.edit-grid label,
|
||
.full-field {
|
||
display: grid;
|
||
gap: 5px;
|
||
color: var(--color-text-muted);
|
||
font-size: 10px;
|
||
font-weight: 650;
|
||
}
|
||
.edit-grid input,
|
||
.edit-grid select,
|
||
.full-field textarea {
|
||
width: 100%;
|
||
border: 1px solid var(--color-border-strong);
|
||
border-radius: 4px;
|
||
padding: 7px 8px;
|
||
background: white;
|
||
color: var(--color-text);
|
||
font-size: 11px;
|
||
}
|
||
.full-field {
|
||
margin-top: 11px;
|
||
}
|
||
.full-field textarea {
|
||
resize: vertical;
|
||
}
|
||
.edit-button:hover:not(:disabled) {
|
||
border-color: var(--color-accent) !important;
|
||
color: var(--color-accent) !important;
|
||
}
|
||
.queue-state {
|
||
display: flex;
|
||
min-height: 260px;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
color: var(--color-text-muted);
|
||
font-size: 12px;
|
||
}
|
||
.queue-state--empty svg {
|
||
color: var(--color-success);
|
||
}
|
||
.queue-state--empty strong {
|
||
color: var(--color-text);
|
||
}
|
||
.queue-state .button {
|
||
margin-top: 7px;
|
||
}
|
||
.admin-tools {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
.tool-card {
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 7px;
|
||
padding: 15px;
|
||
background: var(--color-surface);
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
.tool-card header {
|
||
align-items: flex-start;
|
||
gap: 9px;
|
||
}
|
||
.tool-icon {
|
||
width: 32px;
|
||
height: 32px;
|
||
flex: 0 0 32px;
|
||
}
|
||
.tool-icon--blue {
|
||
background: var(--color-accent-subtle);
|
||
color: var(--color-accent);
|
||
}
|
||
.tool-icon--cyan {
|
||
background: #ecfeff;
|
||
color: #0891b2;
|
||
}
|
||
.tool-icon--purple {
|
||
background: var(--color-purple-soft);
|
||
color: var(--color-purple);
|
||
}
|
||
.tool-icon--green {
|
||
background: var(--color-success-soft);
|
||
color: var(--color-success);
|
||
}
|
||
.tool-icon--gray {
|
||
background: var(--color-bg-warm);
|
||
color: var(--color-text-muted);
|
||
}
|
||
.tool-card h3 {
|
||
margin: 0;
|
||
font-size: 12px;
|
||
}
|
||
.tool-card p {
|
||
margin: 2px 0 0;
|
||
color: var(--color-text-faint);
|
||
font-size: 10px;
|
||
}
|
||
.tool-link {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
margin-top: 13px;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 4px;
|
||
padding: 7px 9px;
|
||
background: white;
|
||
color: var(--color-text-secondary);
|
||
font-size: 10px;
|
||
}
|
||
.tool-link:hover {
|
||
border-color: var(--color-accent);
|
||
color: var(--color-accent);
|
||
}
|
||
.upload-zone {
|
||
display: flex;
|
||
position: relative;
|
||
min-height: 66px;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 2px;
|
||
border: 1px dashed var(--color-border-strong);
|
||
border-radius: 5px;
|
||
margin-top: 13px;
|
||
color: var(--color-accent);
|
||
font-size: 10px;
|
||
cursor: pointer;
|
||
}
|
||
.upload-zone:hover {
|
||
border-color: var(--color-accent);
|
||
background: var(--color-accent-subtle);
|
||
}
|
||
.upload-zone small,
|
||
.tool-hint {
|
||
color: var(--color-text-faint);
|
||
font-size: 9px;
|
||
}
|
||
.upload-zone input {
|
||
position: absolute;
|
||
inset: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
opacity: 0;
|
||
cursor: pointer;
|
||
}
|
||
.voice-entry {
|
||
display: flex;
|
||
width: 100%;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
margin-top: 13px;
|
||
border: 1px solid var(--color-purple);
|
||
border-radius: 5px;
|
||
padding: 8px;
|
||
background: var(--color-purple-soft);
|
||
color: var(--color-purple);
|
||
font-size: 10px;
|
||
}
|
||
.tool-hint {
|
||
display: block;
|
||
margin-top: 7px;
|
||
text-align: center;
|
||
}
|
||
.config-row {
|
||
justify-content: space-between;
|
||
border-top: 1px solid var(--color-border-light);
|
||
margin-top: 10px;
|
||
padding-top: 9px;
|
||
color: var(--color-text-muted);
|
||
font-size: 10px;
|
||
}
|
||
.config-row strong {
|
||
color: var(--color-success);
|
||
font-size: 10px;
|
||
}
|
||
.administration-config {
|
||
display: grid;
|
||
gap: 14px;
|
||
margin-top: 18px;
|
||
}
|
||
.admin-config-panel {
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 8px;
|
||
background: var(--color-surface);
|
||
padding: 18px 20px;
|
||
}
|
||
.admin-config-heading {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 9px;
|
||
margin-bottom: 13px;
|
||
}
|
||
.admin-config-heading > span {
|
||
display: grid;
|
||
width: 30px;
|
||
height: 30px;
|
||
place-items: center;
|
||
border-radius: 5px;
|
||
background: var(--color-bg);
|
||
color: var(--color-accent);
|
||
}
|
||
.admin-config-heading h2,
|
||
.admin-config-heading p {
|
||
margin: 0;
|
||
}
|
||
.admin-config-heading h2 {
|
||
font-size: 14px;
|
||
}
|
||
.admin-config-heading p {
|
||
margin-top: 2px;
|
||
color: var(--color-text-muted);
|
||
font-size: 10px;
|
||
}
|
||
.admin-config-heading > strong {
|
||
margin-left: auto;
|
||
color: var(--color-accent);
|
||
font-size: 10px;
|
||
}
|
||
.config-table-wrap {
|
||
overflow-x: auto;
|
||
}
|
||
.config-table {
|
||
width: 100%;
|
||
min-width: 600px;
|
||
border-collapse: collapse;
|
||
font-size: 11px;
|
||
}
|
||
.config-table th,
|
||
.config-table td {
|
||
border-bottom: 1px solid var(--color-border-light);
|
||
padding: 9px 10px;
|
||
text-align: left;
|
||
}
|
||
.config-table th {
|
||
background: var(--color-bg);
|
||
color: var(--color-text-muted);
|
||
font-weight: 650;
|
||
}
|
||
.permission-yes {
|
||
color: var(--color-success);
|
||
}
|
||
.permission-no {
|
||
color: var(--color-danger);
|
||
}
|
||
.weight-table input {
|
||
width: 58px;
|
||
border: 1px solid var(--color-border-strong);
|
||
border-radius: 4px;
|
||
padding: 5px;
|
||
font: inherit;
|
||
font-size: 11px;
|
||
}
|
||
.weight-table label {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 11px;
|
||
}
|
||
.weight-table label + label {
|
||
margin-left: 14px;
|
||
}
|
||
.frequency-row td:first-child {
|
||
color: #7c3aed;
|
||
font-weight: 650;
|
||
}
|
||
.config-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
gap: 12px;
|
||
padding-top: 14px;
|
||
color: var(--color-text-muted);
|
||
font-size: 10px;
|
||
}
|
||
.config-actions .form-error {
|
||
margin: 0 auto 0 0;
|
||
}
|
||
.config-actions .button {
|
||
min-height: 34px;
|
||
}
|
||
.spinning {
|
||
animation: spin 0.8s linear infinite;
|
||
}
|
||
@keyframes spin {
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
@media (max-width: 980px) {
|
||
.admin-layout {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.admin-tools {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
.config-card {
|
||
grid-column: 1/-1;
|
||
}
|
||
}
|
||
@media (max-width: 680px) {
|
||
.admin-page {
|
||
padding: 20px 12px 36px;
|
||
}
|
||
.admin-header {
|
||
align-items: flex-start;
|
||
flex-direction: column;
|
||
}
|
||
.admin-header-actions {
|
||
width: 100%;
|
||
}
|
||
.admin-header-actions .button {
|
||
flex: 1;
|
||
}
|
||
.admin-metrics {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.admin-layout {
|
||
display: block;
|
||
}
|
||
.admin-tools {
|
||
grid-template-columns: 1fr;
|
||
margin-top: 12px;
|
||
}
|
||
.queue-header {
|
||
align-items: flex-start;
|
||
flex-direction: column;
|
||
}
|
||
.queue-filters {
|
||
width: 100%;
|
||
}
|
||
.queue-filters select {
|
||
flex: 1;
|
||
}
|
||
.review-meta {
|
||
align-items: flex-end;
|
||
flex-direction: column;
|
||
gap: 3px;
|
||
}
|
||
.review-detail {
|
||
padding-left: 8px;
|
||
}
|
||
.detail-fields {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
.review-controls {
|
||
align-items: stretch;
|
||
flex-direction: column;
|
||
}
|
||
.detail-actions {
|
||
justify-content: flex-end;
|
||
width: 100%;
|
||
}
|
||
.edit-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
</style>
|