feat: 完整功能迭代 — 移动端+PC端全面优化

新增:
- 今日纪要 (6分类+彩色标签+时间选择器)
- 客户经理PC端工作台 (我的数据,5模块CRUD)
- 用户管理页 (支局长修改角色)
- 客户备注/收支费用(金额+单位)/联系人管理
- 拜访人姓名+电话字段
- 客户导入导出/旧周报导入模板下载
- 序号列+分页(25/50/100)

修复/优化:
- Hash路由→HTML5 History, Casdoor回调正常
- 时区统一为Asia/Shanghai (today_cst)
- 数据库懒加载→selectinload预加载
- 日期解析兼容ISO datetime字符串
- 文件上传定位修复 (position:relative)
- 表单button type='button'防止误提交
- 分管领导权限(只读客户档案,不可编辑)
- 侧边栏折叠+SVG图标
- 拜访方式/紧急度统一chip风格
- 时间范围统一为el-time-picker(is-range)
- 全局CSS设计变量+box-sizing修复滚动条

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-23 11:40:03 +08:00
parent a1886074dd
commit e7af0e15ab
37 changed files with 4561 additions and 643 deletions
+169 -67
View File
@@ -13,8 +13,10 @@ const miniBusiness = ref<any[]>([])
const dailyNotes = ref<any[]>([])
const keyVisits = ref<any[]>([])
const customers = ref<any[]>([])
const allUsers = ref<any[]>([])
const plannedVisitors = ref<string[]>([])
const dialogTimeRange = ref<any>(null)
// Dialog
const dialogVisible = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const dialogType = ref('')
@@ -22,10 +24,23 @@ const form = ref<any>({})
const categories = ['行政事务', '合同整理', '发票处理', '内部会议', '培训学习', '其他']
const statusPick = ref<Record<string, string>>({})
const planStatuses = ['计划中', '已完成', '已取消']
const miniStatuses = ['跟进中', '已签约', '已流失']
const keyStatuses = ['未开始', '进行中', '已完成']
onMounted(async () => {
await Promise.all([loadAll(), loadCustomers()])
await Promise.all([loadAll(), loadCustomers(), loadUsers()])
})
async function loadUsers() {
try {
const res = await api.get('/users/')
allUsers.value = res.data || []
} catch (_) {}
}
function typeLabel(t: string) {
const labels: Record<string, string> = { visit: '拜访记录', note: '今日纪要', plan: '工作计划', mini: '小微商机', key: '要客拜访' }
return labels[t] || ''
@@ -57,23 +72,31 @@ async function loadAll() {
function openCreate(type: string) {
dialogMode.value = 'create'; dialogType.value = type
if (type === 'visit') form.value = { customer_id: '', visit_date: todayStr(), visit_method: '上门', time_range: '', communication_content: '', customer_demand: '' }
dialogTimeRange.value = null
if (type === 'visit') form.value = { customer_id: '', visit_date: todayStr(), visit_method: '上门', time_range: '', visitor_name: '', visitor_phone: '', communication_content: '', customer_demand: '' }
else if (type === 'note') form.value = { note_date: todayStr(), category: '其他', content: '', time_range: '' }
else if (type === 'plan') form.value = { customer_id: '', plan_content: '', plan_date: todayStr(), status: '计划中' }
else if (type === 'mini') form.value = { customer_id: '', product_type: '', amount: '', follow_up_detail: '', status: '跟进中', expected_revenue_date: '' }
else if (type === 'key') form.value = { customer_id: '', urgency_level: '一般', description: '', progress_status: '未开始', planned_date: '', planned_visitor: '', visit_target: '' }
else if (type === 'key') { form.value = { customer_id: '', urgency_level: '一般', description: '', progress_status: '未开始', planned_date: '', planned_visitor: '', visit_target: '' }; plannedVisitors.value = [] }
dialogVisible.value = true
}
function openEdit(type: string, item: any) {
dialogMode.value = 'edit'; dialogType.value = type
form.value = { ...item }
dialogTimeRange.value = null
if ((type === 'visit' || type === 'note') && item.time_range && item.time_range.includes('-')) {
const parts = item.time_range.split('-')
dialogTimeRange.value = [parts[0], parts[1]]
}
if (type === 'key') {
plannedVisitors.value = item.planned_visitor ? item.planned_visitor.split('、') : []
}
dialogVisible.value = true
}
async function handleSave() {
const t = dialogType.value
const d = form.value
const t = dialogType.value; const d = form.value
try {
if (dialogMode.value === 'create') {
switch (t) {
@@ -113,6 +136,36 @@ async function handleDelete(type: string, id: string) {
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
function onVisitorsChange(val: string[]) {
form.value.planned_visitor = val.join('、')
}
function onDialogTimeChange(val: [string, string] | null) {
form.value.time_range = val ? val.join('-') : ''
}
// Quick status change
async function quickStatusChange(type: string, row: any, newStatus: string) {
try {
await ElMessageBox.confirm(`确定将状态改为「${newStatus}」?`, '确认', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' })
const payload: any = {}
if (type === 'plan') payload.status = newStatus
else if (type === 'mini') payload.status = newStatus
else if (type === 'key') payload.progress_status = newStatus
let url = ''
if (type === 'plan') url = `/work-plans/${row.id}`
else if (type === 'mini') url = `/mini-business/${row.id}`
else if (type === 'key') url = `/key-visits/${row.id}`
await api.put(url, payload)
ElMessage.success('状态已更新')
await loadAll()
} catch (e: any) {
if (e !== 'cancel') ElMessage.error('更新失败: ' + (e.response?.data?.detail || e.message))
// Reset statusPick back to original on cancel/error
delete statusPick.value[row.id]
}
}
const visitsByDate = computed(() => {
const g: Record<string, any[]> = {}
for (const v of visits.value) { const d = v.visit_date; if (!g[d]) g[d] = []; g[d].push(v) }
@@ -127,24 +180,32 @@ const notesByDate = computed(() => {
<template>
<div class="workspace" v-loading="loading">
<h3>我的工作数据</h3>
<div class="page-head">
<h2 class="page-title">我的工作数据</h2>
<div class="page-rule"></div>
</div>
<el-card>
<el-tabs v-model="activeTab">
<!-- 拜访记录 -->
<!-- 拜访记录 -->
<el-tab-pane label="拜访记录" name="visits">
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('visit')">+ 新建拜访</el-button></div>
<div v-for="[date, items] in visitsByDate" :key="date" class="date-group">
<h4>📅 {{ date }}</h4>
<h4 class="date-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px; color: var(--gold)">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
{{ date }}
</h4>
<el-table :data="items" stripe size="small">
<el-table-column prop="customer_name" label="客户" width="130">
<template #default="{ row }"><el-link type="primary" :underline="false">{{ row.customer_name }}</el-link></template>
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('visit', row)">{{ row.customer_name }}</el-link></template>
</el-table-column>
<el-table-column prop="visit_method" label="方式" width="60" />
<el-table-column prop="time_range" label="时间" width="100" />
<el-table-column prop="communication_content" label="沟通内容" min-width="200" show-overflow-tooltip />
<el-table-column label="操作" width="120">
<el-table-column label="操作" width="70">
<template #default="{ row }">
<el-button text size="small" @click="openEdit('visit', row)">编辑</el-button>
<el-button text size="small" type="danger" @click="handleDelete('visit', row.id)">删除</el-button>
</template>
</el-table-column>
@@ -153,18 +214,24 @@ const notesByDate = computed(() => {
<div v-if="!visits.length" class="empty">暂无数据</div>
</el-tab-pane>
<!-- 今日纪要 -->
<!-- 今日纪要 -->
<el-tab-pane label="今日纪要" name="daily_notes">
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('note')">+ 新建纪要</el-button></div>
<div v-for="[date, items] in notesByDate" :key="date" class="date-group">
<h4>📅 {{ date }}</h4>
<h4 class="date-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px; color: var(--gold)">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
{{ date }}
</h4>
<el-table :data="items" stripe size="small">
<el-table-column prop="category" label="分类" width="100"><template #default="{ row }"><el-tag size="small">{{ row.category }}</el-tag></template></el-table-column>
<el-table-column prop="content" label="工作内容" min-width="250" show-overflow-tooltip />
<el-table-column prop="content" label="工作内容" min-width="250" show-overflow-tooltip>
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('note', row)">{{ row.content }}</el-link></template>
</el-table-column>
<el-table-column prop="time_range" label="时间" width="100" />
<el-table-column label="操作" width="120">
<el-table-column label="操作" width="70">
<template #default="{ row }">
<el-button text size="small" @click="openEdit('note', row)">编辑</el-button>
<el-button text size="small" type="danger" @click="handleDelete('note', row.id)">删除</el-button>
</template>
</el-table-column>
@@ -173,19 +240,31 @@ const notesByDate = computed(() => {
<div v-if="!dailyNotes.length" class="empty">暂无数据</div>
</el-tab-pane>
<!-- 工作计划 -->
<!-- 工作计划 -->
<el-tab-pane label="工作计划" name="work_plans">
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('plan')">+ 新建计划</el-button></div>
<el-table :data="workPlans" stripe size="small">
<el-table-column prop="customer_name" label="客户" width="130">
<template #default="{ row }"><el-link type="primary" :underline="false">{{ row.customer_name }}</el-link></template>
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('plan', row)">{{ row.customer_name }}</el-link></template>
</el-table-column>
<el-table-column prop="plan_content" label="工作计划" min-width="250" show-overflow-tooltip />
<el-table-column prop="plan_content" label="工作计划" min-width="200" show-overflow-tooltip />
<el-table-column prop="plan_date" label="计划时间" width="110" />
<el-table-column prop="status" label="状态" width="90"><template #default="{ row }"><el-tag size="small">{{ row.status }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="120">
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-select
v-model="statusPick[row.id]"
size="small"
style="width:100%"
:placeholder="row.status"
@change="(v: string) => quickStatusChange('plan', row, v)"
@visible-change="(v: boolean) => { if (v) statusPick[row.id] = row.status }"
>
<el-option v-for="s in planStatuses" :key="s" :label="s" :value="s" />
</el-select>
</template>
</el-table-column>
<el-table-column label="操作" width="70">
<template #default="{ row }">
<el-button text size="small" @click="openEdit('plan', row)">编辑</el-button>
<el-button text size="small" type="danger" @click="handleDelete('plan', row.id)">删除</el-button>
</template>
</el-table-column>
@@ -193,21 +272,33 @@ const notesByDate = computed(() => {
<div v-if="!workPlans.length" class="empty">暂无数据</div>
</el-tab-pane>
<!-- 小微商机 -->
<!-- ═══ 小微商机 ═══ -->
<el-tab-pane label="小微商机" name="mini_biz">
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('mini')">+ 新建商机</el-button></div>
<el-table :data="miniBusiness" stripe size="small">
<el-table-column prop="customer_name" label="客户" width="130">
<template #default="{ row }"><el-link type="primary" :underline="false">{{ row.customer_name }}</el-link></template>
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('mini', row)">{{ row.customer_name }}</el-link></template>
</el-table-column>
<el-table-column prop="product_type" label="产品类型" width="120" />
<el-table-column prop="amount" label="金额" width="90" />
<el-table-column prop="follow_up_detail" label="跟进内容" min-width="200" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="90"><template #default="{ row }"><el-tag size="small">{{ row.status }}</el-tag></template></el-table-column>
<el-table-column prop="expected_revenue_date" label="预计列收" width="110" />
<el-table-column label="操作" width="120">
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-select
v-model="statusPick[row.id]"
size="small"
style="width:100%"
:placeholder="row.status"
@change="(v: string) => quickStatusChange('mini', row, v)"
@visible-change="(v: boolean) => { if (v) statusPick[row.id] = row.status }"
>
<el-option v-for="s in miniStatuses" :key="s" :label="s" :value="s" />
</el-select>
</template>
</el-table-column>
<el-table-column prop="expected_revenue_date" label="预计列收" width="110" />
<el-table-column label="操作" width="70">
<template #default="{ row }">
<el-button text size="small" @click="openEdit('mini', row)">编辑</el-button>
<el-button text size="small" type="danger" @click="handleDelete('mini', row.id)">删除</el-button>
</template>
</el-table-column>
@@ -215,22 +306,34 @@ const notesByDate = computed(() => {
<div v-if="!miniBusiness.length" class="empty">暂无数据</div>
</el-tab-pane>
<!-- 要客拜访 -->
<!-- ═══ 要客拜访 ═══ -->
<el-tab-pane label="要客拜访" name="key_visits">
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('key')">+ 新建要客拜访</el-button></div>
<el-table :data="keyVisits" stripe size="small">
<el-table-column prop="customer_name" label="客户" width="130">
<template #default="{ row }"><el-link type="primary" :underline="false">{{ row.customer_name }}</el-link></template>
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('key', row)">{{ row.customer_name }}</el-link></template>
</el-table-column>
<el-table-column prop="urgency_level" label="重要度" width="80"><template #default="{ row }"><el-tag size="small" :type="row.urgency_level==='紧急'?'danger':row.urgency_level==='重要'?'warning':''">{{ row.urgency_level }}</el-tag></template></el-table-column>
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
<el-table-column prop="progress_status" label="进展" width="80" />
<el-table-column label="进展" width="120">
<template #default="{ row }">
<el-select
v-model="statusPick[row.id]"
size="small"
style="width:100%"
:placeholder="row.progress_status"
@change="(v: string) => quickStatusChange('key', row, v)"
@visible-change="(v: boolean) => { if (v) statusPick[row.id] = row.progress_status }"
>
<el-option v-for="s in keyStatuses" :key="s" :label="s" :value="s" />
</el-select>
</template>
</el-table-column>
<el-table-column prop="planned_date" label="计划时间" width="110" />
<el-table-column prop="planned_visitor" label="拜访人" width="80" />
<el-table-column prop="visit_target" label="拜访对象" width="100" />
<el-table-column label="操作" width="120">
<el-table-column label="操作" width="70">
<template #default="{ row }">
<el-button text size="small" @click="openEdit('key', row)">编辑</el-button>
<el-button text size="small" type="danger" @click="handleDelete('key', row.id)">删除</el-button>
</template>
</el-table-column>
@@ -241,65 +344,62 @@ const notesByDate = computed(() => {
</el-card>
<!-- Create/Edit Dialog -->
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + typeLabel(dialogType)" width="520px">
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' ' + typeLabel(dialogType)" width="520px">
<el-form label-position="top" v-if="form">
<!-- Customer selector for types that need it -->
<el-form-item v-if="['visit','plan','mini','key'].includes(dialogType)" label="客户单位">
<el-select v-model="form.customer_id" filterable remote :remote-method="(q: string) => loadCustomers(q)" placeholder="搜索选择客户" style="width:100%">
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<!-- Date -->
<el-form-item v-if="dialogType === 'visit'" label="日期">
<el-date-picker v-model="form.visit_date" type="date" style="width:100%" />
</el-form-item>
<el-form-item v-if="dialogType === 'note'" label="日期">
<el-date-picker v-model="form.note_date" type="date" style="width:100%" />
</el-form-item>
<el-form-item v-if="dialogType === 'plan'" label="计划拜访时间">
<el-date-picker v-model="form.plan_date" type="date" style="width:100%" />
</el-form-item>
<!-- Visit specific -->
<el-form-item v-if="dialogType === 'visit'" label="日期"><el-date-picker v-model="form.visit_date" type="date" style="width:100%" /></el-form-item>
<el-form-item v-if="dialogType === 'note'" label="日期"><el-date-picker v-model="form.note_date" type="date" style="width:100%" /></el-form-item>
<el-form-item v-if="dialogType === 'plan'" label="计划拜访时间"><el-date-picker v-model="form.plan_date" type="date" style="width:100%" /></el-form-item>
<template v-if="dialogType === 'visit'">
<el-form-item label="拜访方式">
<el-radio-group v-model="form.visit_method">
<el-radio-button value="上门">上门</el-radio-button><el-radio-button value="电话">电话</el-radio-button><el-radio-button value="微信">微信</el-radio-button><el-radio-button value="出差">出差</el-radio-button>
</el-radio-group>
<div class="method-grid">
<el-button v-for="m in ['上门','电话','微信','出差']" :key="m" size="small" :type="form.visit_method===m?'primary':''" @click="form.visit_method=m">{{ m }}</el-button>
</div>
</el-form-item>
<el-form-item label="时间范围"><el-input v-model="form.time_range" /></el-form-item>
<el-form-item label="时间范围">
<el-time-picker v-model="dialogTimeRange" is-range format="HH:mm" value-format="HH:mm" range-separator="" start-placeholder="开始" end-placeholder="结束" style="width:100%" @change="onDialogTimeChange" />
</el-form-item>
<el-form-item label="拜访人姓名"><el-input v-model="form.visitor_name" placeholder="实际拜访人姓名" /></el-form-item>
<el-form-item label="拜访人电话"><el-input v-model="form.visitor_phone" placeholder="联系电话可选" /></el-form-item>
<el-form-item label="沟通内容"><el-input v-model="form.communication_content" type="textarea" :rows="3" /></el-form-item>
<el-form-item label="客户需求"><el-input v-model="form.customer_demand" type="textarea" :rows="2" /></el-form-item>
</template>
<!-- Note specific -->
<template v-if="dialogType === 'note'">
<el-form-item label="分类">
<div style="display:flex;flex-wrap:wrap;gap:6px">
<el-button v-for="c in categories" :key="c" :type="form.category===c?'primary':''" size="small" @click="form.category=c">{{ c }}</el-button>
</div>
<el-form-item label="分类"><div style="display:flex;flex-wrap:wrap;gap:6px"><el-button v-for="c in categories" :key="c" :type="form.category===c?'primary':''" size="small" @click="form.category=c">{{ c }}</el-button></div></el-form-item>
<el-form-item label="时间范围">
<el-time-picker v-model="dialogTimeRange" is-range format="HH:mm" value-format="HH:mm" range-separator="" start-placeholder="开始" end-placeholder="结束" style="width:100%" @change="onDialogTimeChange" />
</el-form-item>
<el-form-item label="时间范围"><el-input v-model="form.time_range" /></el-form-item>
<el-form-item label="工作内容"><el-input v-model="form.content" type="textarea" :rows="4" /></el-form-item>
</template>
<!-- Plan specific -->
<template v-if="dialogType === 'plan'">
<el-form-item label="工作计划"><el-input v-model="form.plan_content" type="textarea" :rows="4" /></el-form-item>
<el-form-item label="状态"><el-select v-model="form.status"><el-option label="计划中" value="计划中" /><el-option label="已完成" value="已完成" /><el-option label="已取消" value="已取消" /></el-select></el-form-item>
</template>
<!-- Mini biz specific -->
<template v-if="dialogType === 'mini'">
<el-form-item label="产品类型"><el-input v-model="form.product_type" /></el-form-item>
<el-form-item label="金额"><el-input v-model="form.amount" /></el-form-item>
<el-form-item label="跟进内容"><el-input v-model="form.follow_up_detail" type="textarea" :rows="4" /></el-form-item>
<el-form-item label="状态"><el-select v-model="form.status"><el-option label="跟进中" value="跟进中" /><el-option label="已签约" value="已签约" /><el-option label="已流失" value="已流失" /></el-select></el-form-item>
<el-form-item label="预计列收时间"><el-input v-model="form.expected_revenue_date" /></el-form-item>
<el-form-item label="预计列收时间"><el-date-picker v-model="form.expected_revenue_date" type="month" placeholder="选择月份" style="width:100%" value-format="YYYY-MM" /></el-form-item>
</template>
<!-- Key visit specific -->
<template v-if="dialogType === 'key'">
<el-form-item label="紧急重要度"><el-radio-group v-model="form.urgency_level"><el-radio-button value="一般">一般</el-radio-button><el-radio-button value="重要">重要</el-radio-button><el-radio-button value="紧急">紧急</el-radio-button></el-radio-group></el-form-item>
<el-form-item label="紧急重要度">
<div class="method-grid">
<el-button v-for="u in ['一般','重要','紧急']" :key="u" size="small" :type="form.urgency_level===u ? (u==='紧急'?'danger':u==='重要'?'warning':'') : ''" @click="form.urgency_level=u">{{ u }}</el-button>
</div>
</el-form-item>
<el-form-item label="内容描述"><el-input v-model="form.description" type="textarea" :rows="4" /></el-form-item>
<el-form-item label="进展状态"><el-select v-model="form.progress_status"><el-option label="未开始" value="未开始" /><el-option label="进行中" value="进行中" /><el-option label="已完成" value="已完成" /></el-select></el-form-item>
<el-form-item label="计划拜访时间"><el-input v-model="form.planned_date" /></el-form-item>
<el-form-item label="计划拜访人"><el-input v-model="form.planned_visitor" /></el-form-item>
<el-form-item label="计划拜访时间"><el-date-picker v-model="form.planned_date" type="date" placeholder="选择日期" style="width:100%" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="计划拜访人可多选">
<el-select v-model="plannedVisitors" multiple filterable allow-create default-first-option placeholder="选择或输入姓名可多选" style="width:100%" @change="onVisitorsChange">
<el-option v-for="u in allUsers" :key="u.id" :label="u.name + (u.role==='director'?' (支局长)':u.role==='leader'?' (分管领导)':'')" :value="u.name" />
</el-select>
</el-form-item>
<el-form-item label="拜访对象"><el-input v-model="form.visit_target" /></el-form-item>
</template>
</el-form>
@@ -312,8 +412,10 @@ const notesByDate = computed(() => {
</template>
<style scoped>
.workspace h3 { margin: 0 0 16px; }
.page-head { margin-bottom: 20px; }
.page-title { margin: 0; font-family: 'ZCOOL XiaoWei', STSong, serif; font-size: 22px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; }
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
.date-group { margin-bottom: 20px; }
.date-group h4 { margin: 12px 0 8px; font-size: 14px; color: #303133; }
.empty { text-align: center; color: #c0c4cc; padding: 40px 0; }
.date-title { display: flex; align-items: center; margin: 12px 0 8px; font-family: 'ZCOOL XiaoWei', STSong, serif; font-size: 14px; color: var(--ink); letter-spacing: 0.04em; }
.empty { text-align: center; color: var(--c-text-muted); padding: 40px 0; font-family: 'Noto Serif SC', STSong, serif; }
</style>