新增生产-前缫看台记录,并导入车头工/挡车工看台记录历史数据

功能:
- 后端新增 KtjlCtgs / KtjlDcgs 两张表、KtjlService 与 api/prod/ktjl 接口
  (按日期+车间+班次查询、整组先删后建保存、整组删除、按看台排班带出默认记录、工种下拉)
- 挡车数按 F9 口径自动重算(与他人接壤的边界车位各扣 0.5),机组由车位反查机组车台
- 前端新增 /prod/qianxian/ktjl 看台记录页:挡车工/车头工双页签、删除、录入(新增)、录入(指定日期)
- 录入弹窗校验对齐 F9:个人时长不超标准、挡车数/看台时长匹配、车头工每机组≥2 人且时长为标准时长整数倍
- 生产-前缫菜单新增「看台记录」,权限 prod:ktjl:list

数据导入:
- 新增内嵌资源 biz_prod_qs_ktjl_ctg.csv / biz_prod_qs_ktjl_dcg.csv(约 5 万行,采用紧凑 CSV)
- 剔除软删除行、日期异常行、部门无法映射与职工不存在的行;日期由 Excel 序列号转换
- 车头工 12693 行、挡车工 38048 行入库,日期范围 2025-12-16 ~ 2026-09-11
- 表内已有数据则整体跳过,分批 1000 行插入

其他:同步工作进度-13 文档与开发数据库快照
This commit is contained in:
yanpeng200212
2026-10-22 11:16:29 +08:00
parent 2e4c045818
commit 69c4052a64
15 changed files with 52228 additions and 2 deletions
+1
View File
@@ -66,6 +66,7 @@ export const componentMap = {
'prod/xdjy/index': () => import('../views/prod/xdjy/index.vue'),
'prod/hbjy/index': () => import('../views/prod/hbjy/index.vue'),
'prod/ktpb/index': () => import('../views/prod/ktpb/index.vue'),
'prod/ktjl/index': () => import('../views/prod/ktjl/index.vue'),
'notification/messages/index': () => import('../views/notification/messages/index.vue'),
'notification/subscribe/index': () => import('../views/notification/subscribe/index.vue'),
'notification/type/index': () => import('../views/notification/type/index.vue'),
+347
View File
@@ -0,0 +1,347 @@
<script setup>
/**
* 看台记录录入弹窗(对应 F9 Page_Ktjl_Dcg_Edit / Page_Ktjl_Ctg_Edit)
* - 挡车工:可编辑机组/起止车位/挡车数/时长,挡车数按接壤车位自动计算
* - 车头工:可编辑机组/时长
* - 校验口径与 F9 一致:个人时长不超标准、挡车数/看台时长匹配、车头工每机组≥2 人且时长为标准时长整数倍
*/
import { ref, reactive, computed, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import request from '../../../utils/request';
import EmployeeSelect from '../../../components/select/EmployeeSelect.vue';
const props = defineProps({
modelValue: Boolean,
type: { type: String, default: 'dcg' }, // dcg | ctg
date: { type: String, default: '' },
depId: { type: Number, default: 0 },
depName: { type: String, default: '' },
banci: { type: Number, default: 1 },
banciName: { type: String, default: '甲' },
/** 预置行(录入(新增) 的排班带出 / 指定日期复制),库中无记录时使用 */
presetRows: { type: Array, default: () => [] },
});
const emit = defineEmits(['update:modelValue', 'saved']);
const visible = ref(false);
const loading = ref(false);
const saving = ref(false);
const rows = ref([]);
const hoursStandard = ref(9);
const jizuOptions = ref([]);
const empPicker = reactive({ visible: false });
const selected = ref([]);
const title = computed(() => (props.type === 'dcg' ? '挡车工看台记录' : '车头工看台记录'));
watch(() => props.modelValue, async (v) => {
visible.value = v;
if (v) await load();
});
watch(visible, (v) => emit('update:modelValue', v));
async function load() {
loading.value = true;
try {
const res = await request.get(`/prod/ktjl/${props.type}`, {
params: { date: props.date, depId: props.depId, banci: props.banci },
});
const list = (res.data || []).length ? res.data : props.presetRows || [];
rows.value = list.map((x) => ({ ...x, selected: false }));
const jz = await request.get('/prod/ktpb/jizu-options', { params: { depId: props.depId } });
jizuOptions.value = jz.data || [];
hoursStandard.value = rows.value.length ? Math.max(...rows.value.map((x) => Number(x.hours) || 0)) : 9;
if (props.type === 'dcg') recalcDcg();
} finally {
loading.value = false;
}
}
/** 由车位推导机组(与后端 CalJizu 一致) */
function jizuOf(start, end) {
const hit = jizuOptions.value.find((j) => j.chetaiStart <= end && j.chetaiEnd >= start);
return hit ? hit.jizu : 0;
}
/** 挡车数:车位数 - 与他人在边界车位各分 0.5(与后端 CalDangcheNum 一致) */
function recalcDcg() {
const list = rows.value;
list.forEach((it) => {
const s = Number(it.chetaiStart) || 0;
const e = Number(it.chetaiEnd) || 0;
if (!s || e < s) {
it.dangcheNum = 0;
return;
}
const startShared = list.some((x) => x !== it && x.employeeId !== it.employeeId && Number(x.chetaiEnd) === s);
const endShared = list.some((x) => x !== it && x.employeeId !== it.employeeId && Number(x.chetaiStart) === e);
it.dangcheNum = e + 1 - s - (startShared ? 0.5 : 0) - (endShared ? 0.5 : 0);
it.jizu = jizuOf(s, e);
});
}
function onChetaiChange() {
recalcDcg();
}
/** 添加职工(多选) */
async function openEmpPicker() {
empPicker.visible = true;
}
async function onEmployeesPicked(list) {
const exist = new Set(rows.value.map((x) => String(x.employeeId)));
const picked = Array.isArray(list) ? list : [list];
picked.forEach((e) => {
if (exist.has(String(e.id))) return;
rows.value.push({
id: 0,
employeeId: e.id,
employeeName: e.name,
employeeDepId: e.depId,
employeeDepName: e.depName,
worktypeId: e.worktypeId,
worktypeName: e.worktypeName,
jizu: props.type === 'dcg' ? 0 : 1,
chetaiStart: 0,
chetaiEnd: 0,
dangcheNum: 0,
hours: Number(hoursStandard.value) || 9,
noChanliang: false,
selected: false,
});
});
if (props.type === 'dcg') recalcDcg();
}
function removeSelected() {
const picked = rows.value.filter((x) => x.selected);
if (!picked.length) return ElMessage.warning('请选择要删除的记录');
rows.value = rows.value.filter((x) => !x.selected);
if (props.type === 'dcg') recalcDcg();
}
async function batchHours() {
const picked = rows.value.filter((x) => x.selected);
if (!picked.length) return ElMessage.warning('请选择要修改的记录');
try {
const { value } = await ElMessageBox.prompt('批量设置时长(小时)', '设置时长', {
inputValue: String(hoursStandard.value),
inputPattern: /^\d+(\.\d+)?$/,
inputErrorMessage: '请输入数字',
});
picked.forEach((x) => {
x.hours = Number(value);
});
} catch {
/* 取消 */
}
}
/** 校验(对齐 F9 ValidCustom),返回提示列表 */
function validate() {
const warns = [];
const std = Number(hoursStandard.value) || 0;
if (rows.value.some((x) => !x.employeeId)) {
return { fatal: '不能有空数据(存在未选择职工的记录)', warns: [] };
}
if (props.type === 'dcg') {
const over = [];
const byEmp = {};
rows.value.forEach((x) => {
const key = String(x.employeeId);
byEmp[key] = byEmp[key] || { name: x.employeeName, hours: 0 };
byEmp[key].hours += Number(x.hours) || 0;
});
Object.values(byEmp).forEach((v) => {
if (std > 0 && v.hours > std) over.push(v.name);
});
if (over.length) return { fatal: `${over.join('、')} 超过标准时长(${std} 小时)`, warns: [] };
rows.value.forEach((x) => {
const s = Number(x.chetaiStart);
const e = Number(x.chetaiEnd);
const shared = rows.value.some((y) => y.employeeId !== x.employeeId && (Number(y.chetaiEnd) === s || Number(y.chetaiStart) === e));
if (shared && Number(x.dangcheNum) * 10 % 10 === 0) {
warns.push(`${x.jizu}组${e}台,多人看台挡车数不匹配`);
}
if (Number(x.dangcheNum) === 0) warns.push(`${x.jizu}组${e}台,挡车数不能为 0`);
});
// 看台时长校验
const jizus = [...new Set(rows.value.map((x) => x.jizu))];
jizus.forEach((jz) => {
const items = rows.value.filter((x) => x.jizu === jz);
if (!items.length) return;
const min = Math.min(...items.map((x) => Number(x.chetaiStart)));
const max = Math.max(...items.map((x) => Number(x.chetaiEnd)));
for (let i = min; i <= max; i++) {
const cover = items.filter((x) => Number(x.chetaiStart) <= i && Number(x.chetaiEnd) >= i);
if (!cover.length) continue;
const hasStart = cover.some((x) => Number(x.chetaiStart) === i);
const hasEnd = cover.some((x) => Number(x.chetaiEnd) === i);
if (hasStart && hasEnd) {
const up = cover.filter((x) => Number(x.chetaiStart) === i).reduce((a, b) => a + Number(b.hours), 0);
const down = cover.filter((x) => Number(x.chetaiEnd) === i).reduce((a, b) => a + Number(b.hours), 0);
if (up === down) {
if (std > 0 && up !== std) warns.push(`${jz}组${i}台,看台时长有误`);
} else {
warns.push(`${jz}组${i}台,看台时长不一致`);
}
} else {
const sum = cover.reduce((a, b) => a + Number(b.hours), 0);
if (std > 0 && sum !== std) warns.push(`${jz}组${i}台,看台时长有误`);
}
}
});
} else {
const jizus = [...new Set(rows.value.map((x) => x.jizu))];
jizus.forEach((jz) => {
const items = rows.value.filter((x) => x.jizu === jz);
if (items.length < 2) warns.push(`${jz}组不足两人`);
if (items.some((x) => Number(x.hours) === 0)) warns.push(`${jz}组时长有误`);
const sum = items.reduce((a, b) => a + Number(b.hours), 0);
if (std > 0 && (sum < std * 2 || sum % std !== 0)) warns.push(`${jz}组时长有误`);
});
}
return { fatal: '', warns };
}
async function save() {
const { fatal, warns } = validate();
if (fatal) return ElMessage.warning(fatal);
if (warns.length) {
try {
await ElMessageBox.confirm(`${[...new Set(warns)].join('\n')}\n是否继续保存?`, '验证提示', {
type: 'warning',
confirmButtonText: '继续保存',
cancelButtonText: '返回修改',
});
} catch {
return;
}
}
saving.value = true;
try {
const res = await request.post(`/prod/ktjl/${props.type}/batch`, {
date: props.date,
depId: props.depId,
banci: props.banci,
items: rows.value.map((x) => ({
employeeId: x.employeeId,
employeeDepId: x.employeeDepId,
worktypeId: x.worktypeId,
worktypeName: x.worktypeName,
jizu: Number(x.jizu) || 0,
chetaiStart: Number(x.chetaiStart) || 0,
chetaiEnd: Number(x.chetaiEnd) || 0,
dangcheNum: Number(x.dangcheNum) || 0,
hours: Number(x.hours) || 0,
noChanliang: !!x.noChanliang,
})),
});
ElMessage.success(res.message || '保存成功');
visible.value = false;
emit('saved');
} finally {
saving.value = false;
}
}
</script>
<template>
<el-dialog v-model="visible" :title="title" width="1080px" destroy-on-close append-to-body>
<div class="dlg-head">
<el-tag type="info">{{ date }}</el-tag>
<el-tag type="info">{{ depName }}</el-tag>
<el-tag type="info">{{ banciName }}班</el-tag>
<span class="lbl">标准时长</span>
<el-input-number v-model="hoursStandard" :min="1" :max="24" :step="0.5" :controls="false" style="width: 80px" />
<span class="tip">共 {{ rows.length }} 条</span>
</div>
<div class="dlg-toolbar">
<el-button type="primary" size="small" @click="openEmpPicker">添加职工</el-button>
<el-button size="small" @click="removeSelected">删除选中</el-button>
<el-button size="small" @click="batchHours">批量修改时长</el-button>
<span class="hint">
{{ type === 'dcg'
? '挡车数 = 车位数 −(与他人接壤的边界车位各 0.5);机组按车位自动归属'
: '每个机组至少 2 人,机组时长合计需为标准时长的整数倍' }}
</span>
</div>
<el-table v-loading="loading" :data="rows" border size="small" height="420" @selection-change="selected = $event">
<el-table-column type="selection" width="42" />
<el-table-column type="index" label="序号" width="52" />
<el-table-column prop="employeeDepName" label="部门" min-width="110" show-overflow-tooltip />
<el-table-column prop="worktypeName" label="工种" width="90" />
<el-table-column prop="employeeName" label="姓名" width="120" show-overflow-tooltip />
<el-table-column label="班次" width="70" align="center">
<template #default>{{ banciName }}</template>
</el-table-column>
<el-table-column label="机组" width="80" align="center">
<template #default="{ row }">
<el-input-number v-model="row.jizu" :min="0" :max="99" :controls="false" size="small" style="width: 60px" />
</template>
</el-table-column>
<template v-if="type === 'dcg'">
<el-table-column label="开始车位" width="96" align="center">
<template #default="{ row }">
<el-input-number
v-model="row.chetaiStart"
:min="1"
:max="999"
:controls="false"
size="small"
style="width: 76px"
@change="onChetaiChange"
/>
</template>
</el-table-column>
<el-table-column label="结束车位" width="96" align="center">
<template #default="{ row }">
<el-input-number
v-model="row.chetaiEnd"
:min="1"
:max="999"
:controls="false"
size="small"
style="width: 76px"
@change="onChetaiChange"
/>
</template>
</el-table-column>
<el-table-column prop="dangcheNum" label="挡车数" width="80" align="right" />
</template>
<el-table-column label="时长" width="90" align="center">
<template #default="{ row }">
<el-input-number v-model="row.hours" :min="0" :max="24" :step="0.5" :controls="false" size="small" style="width: 70px" />
</template>
</el-table-column>
<el-table-column label="不计产量" width="90" align="center">
<template #default="{ row }">
<el-switch v-model="row.noChanliang" size="small" />
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
<EmployeeSelect v-model="empPicker.visible" multiple :dep-id="depId" @confirm="onEmployeesPicked" />
</el-dialog>
</template>
<style scoped>
.dlg-head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
.dlg-head .lbl { color: var(--el-text-color-regular); font-size: 13px; margin-left: 6px; }
.dlg-head .tip { margin-left: auto; color: var(--el-text-color-secondary); font-size: 13px; }
.dlg-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.dlg-toolbar .hint { color: var(--el-text-color-secondary); font-size: 12px; }
</style>
+260
View File
@@ -0,0 +1,260 @@
<script setup>
/**
* 看台记录(生产-前缫)
* 对应 WinForms 端 WinClient\F9.Business.Prod\QS\KTJL\Page_Ktjl
* - 两个页签:挡车工 / 车头工
* - 过滤:日期 + 车间 + 班次(关键字)
* - 按钮:删除(按日期+车间+班次整批)、录入(新增)(按看台排班带出)、录入(指定日期)(复制某日记录)
*/
import { ref, reactive, computed, onMounted } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import request from '../../../utils/request';
import KtjlEditDialog from './KtjlEditDialog.vue';
const TABS = [
{ name: 'dcg', label: '挡车工' },
{ name: 'ctg', label: '车头工' },
];
const BANCI = [
{ value: 1, label: '甲班' },
{ value: 2, label: '乙班' },
];
function today() {
const d = new Date();
const m = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${d.getFullYear()}-${m}-${dd}`;
}
const tab = ref('dcg');
const deps = ref([]);
const filter = reactive({ date: today(), depId: 0, banci: 1, keyword: '' });
const state = reactive({
dcg: { rows: [], loading: false },
ctg: { rows: [], loading: false },
});
const cur = computed(() => state[tab.value]);
const banciName = computed(() => (filter.banci === 2 ? '乙' : '甲'));
const depName = computed(() => deps.value.find((d) => d.id === filter.depId)?.name || '');
const dlg = reactive({ visible: false, type: 'dcg', presetRows: [] });
const copyDlg = reactive({ visible: false, date: '' });
async function loadDeps() {
const res = await request.get('/prod/ktpb/qianxian-deps');
deps.value = res.data || [];
if (deps.value.length && !filter.depId) filter.depId = deps.value[0].id;
}
async function load() {
if (!filter.depId) return;
const s = cur.value;
s.loading = true;
try {
const res = await request.get(`/prod/ktjl/${tab.value}`, {
params: { date: filter.date, depId: filter.depId, banci: filter.banci, like: filter.keyword },
});
s.rows = res.data || [];
} finally {
s.loading = false;
}
}
onMounted(async () => {
await loadDeps();
await load();
});
async function onTabChange() {
await load();
}
/** 录入(新增):已有记录则继续编辑,否则按看台排班带出默认记录 */
async function openNew() {
if (!filter.depId) return ElMessage.warning('请先选择车间');
const res = await request.get(`/prod/ktjl/${tab.value}`, {
params: { date: filter.date, depId: filter.depId, banci: filter.banci },
});
const exist = res.data || [];
if (exist.length) {
dlg.presetRows = [];
} else {
const def = await request.get(`/prod/ktjl/${tab.value}/defaults`, {
params: { date: filter.date, depId: filter.depId, banci: filter.banci },
});
dlg.presetRows = def.data || [];
if (!dlg.presetRows.length) {
ElMessage.warning('该车间该班次暂无看台排班,请先维护「看台排班」或手工添加职工');
}
}
dlg.type = tab.value;
dlg.visible = true;
}
/** 录入(指定日期):把某一天的记录复制到当前日期再编辑 */
async function openCopy() {
if (!filter.depId) return ElMessage.warning('请先选择车间');
copyDlg.date = '';
copyDlg.visible = true;
}
async function doCopy() {
if (!copyDlg.date) return ElMessage.warning('请选择来源日期');
const res = await request.get(`/prod/ktjl/${tab.value}`, {
params: { date: copyDlg.date, depId: filter.depId, banci: filter.banci },
});
const list = (res.data || []).map((x) => ({ ...x, id: 0 }));
if (!list.length) return ElMessage.warning('来源日期没有看台记录');
copyDlg.visible = false;
dlg.presetRows = list;
dlg.type = tab.value;
dlg.visible = true;
}
async function removeGroup() {
if (!filter.depId) return ElMessage.warning('请先选择车间');
if (!cur.value.rows.length) return ElMessage.warning('当前没有可删除的记录');
try {
await ElMessageBox.confirm(
`确认删除 ${filter.date},${depName.value},${banciName.value}班 的全部${tab.value === 'dcg' ? '挡车工' : '车头工'}看台记录?`,
'提示',
{ type: 'warning' }
);
} catch {
return;
}
const res = await request.delete(`/prod/ktjl/${tab.value}`, {
params: { date: filter.date, depId: filter.depId, banci: filter.banci },
});
ElMessage.success(res.message || '删除成功');
await load();
}
async function onSaved() {
await load();
}
</script>
<template>
<div class="ktjl-page">
<el-card shadow="never">
<el-tabs v-model="tab" @tab-change="onTabChange">
<el-tab-pane v-for="t in TABS" :key="t.name" :label="t.label" :name="t.name" />
</el-tabs>
<div class="toolbar">
<span class="lbl">日期</span>
<el-date-picker
v-model="filter.date"
type="date"
value-format="YYYY-MM-DD"
:clearable="false"
style="width: 150px"
@change="load"
/>
<span class="lbl">车间</span>
<el-select v-model="filter.depId" placeholder="请选择车间" style="width: 160px" @change="load">
<el-option v-for="d in deps" :key="d.id" :label="d.name" :value="d.id" />
</el-select>
<span class="lbl">班次</span>
<el-select v-model="filter.banci" style="width: 110px" @change="load">
<el-option v-for="b in BANCI" :key="b.value" :label="b.label" :value="b.value" />
</el-select>
<el-input
v-model="filter.keyword"
placeholder="姓名 / 工种 / 机组"
clearable
style="width: 180px"
@keyup.enter="load"
@clear="load"
/>
<el-button type="primary" @click="load">查询</el-button>
<div class="right">
<el-button type="danger" plain @click="removeGroup">删除</el-button>
<el-button type="primary" @click="openNew">录入(新增)</el-button>
<el-button @click="openCopy">录入(指定日期)</el-button>
</div>
</div>
<!-- 挡车工 -->
<el-table v-if="tab === 'dcg'" v-loading="state.dcg.loading" :data="state.dcg.rows" border size="small">
<el-table-column type="index" label="序号" width="55" />
<el-table-column prop="depName" label="部门" min-width="110" show-overflow-tooltip />
<el-table-column label="班次" width="70" align="center">
<template #default="{ row }">{{ row.banciName }}班</template>
</el-table-column>
<el-table-column prop="employeeName" label="姓名" width="120" show-overflow-tooltip />
<el-table-column prop="worktypeName" label="工种" width="90" />
<el-table-column prop="employeeDepName" label="职工部门" min-width="110" show-overflow-tooltip />
<el-table-column prop="jizu" label="机组" width="70" align="center" />
<el-table-column prop="chetaiStart" label="开始车位" width="90" align="center" />
<el-table-column prop="chetaiEnd" label="结束车位" width="90" align="center" />
<el-table-column prop="dangcheNum" label="挡车数" width="80" align="right" />
<el-table-column prop="hours" label="时长" width="70" align="right" />
<el-table-column label="不计产量" width="90" align="center">
<template #default="{ row }">{{ row.noChanliang ? '√' : '' }}</template>
</el-table-column>
<template #empty>
<span class="empty">当日无记录,可点击「录入(新增)」按看台排班带出</span>
</template>
</el-table>
<!-- 车头工 -->
<el-table v-else v-loading="state.ctg.loading" :data="state.ctg.rows" border size="small">
<el-table-column type="index" label="序号" width="55" />
<el-table-column prop="depName" label="部门" min-width="110" show-overflow-tooltip />
<el-table-column label="班次" width="70" align="center">
<template #default="{ row }">{{ row.banciName }}班</template>
</el-table-column>
<el-table-column prop="employeeName" label="姓名" width="120" show-overflow-tooltip />
<el-table-column prop="worktypeName" label="工种" width="90" />
<el-table-column prop="employeeDepName" label="职工部门" min-width="110" show-overflow-tooltip />
<el-table-column prop="jizu" label="机组" width="70" align="center" />
<el-table-column prop="hours" label="时长" width="70" align="right" />
<el-table-column label="不计产量" width="90" align="center">
<template #default="{ row }">{{ row.noChanliang ? '√' : '' }}</template>
</el-table-column>
<template #empty>
<span class="empty">当日无记录,可点击「录入(新增)」按看台排班带出</span>
</template>
</el-table>
</el-card>
<KtjlEditDialog
v-model="dlg.visible"
:type="dlg.type"
:date="filter.date"
:dep-id="filter.depId"
:dep-name="depName"
:banci="filter.banci"
:banci-name="banciName"
:preset-rows="dlg.presetRows"
@saved="onSaved"
/>
<el-dialog v-model="copyDlg.visible" title="选择来源日期" width="360px" append-to-body>
<el-date-picker
v-model="copyDlg.date"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择要复制的日期"
style="width: 100%"
/>
<div class="copy-tip">将把该日期「{{ depName }} · {{ banciName }}班」的{{ tab === 'dcg' ? '挡车工' : '车头工' }}看台记录复制到 {{ filter.date }} 再编辑保存。</div>
<template #footer>
<el-button @click="copyDlg.visible = false">取消</el-button>
<el-button type="primary" @click="doCopy">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.ktjl-page :deep(.el-card__body) { padding-top: 6px; }
.toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
.toolbar .lbl { color: var(--el-text-color-regular); font-size: 13px; }
.toolbar .right { margin-left: auto; }
.empty { color: var(--el-text-color-secondary); font-size: 13px; }
.copy-tip { margin-top: 10px; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.6; }
</style>