1767 lines
49 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>