管理端:设计系统 v2 收敛 + 修复漏斗卡恒为 0 的响应式缺陷

## 一、设计系统收敛

- theme/index.css 重写为 token 底座:品牌三级、语义五组、文字四级、
  边框/背景/圆角/阴影/动效/热区/排版刻度
- 修复 Element Plus 派生色阶残留:--el-color-*-light-N 是 SCSS 编译期静态生成,
  只覆盖基色不会带动它们 —— 数据统计页实测 133 处元素仍在用 EP 默认调色板
  (其中主色蓝 8 处)。已按 mix(#fff,base,N*10%) / mix(#000,base,20%) 补全全部档位
- 新增 src/theme/tokens.ts:模板里 :style 需拼 8 位 hex(color + '15'),用不了 var(),
  故以 TS 常量镜像色值;守卫会双向校验它与 index.css 同名取值一致
- 24 个 .vue 收敛:硬编码色值 97 处/67 种 → 0;越界字号 88 处(最低 9px)→ 0;
  非法字重 650×6 → 0;触摸目标 17 处 <44px → 全部 ≥44px
- 对比度:text-tertiary 3.54:1、warning 2.33:1、coral 作文字 3.14:1 → 全部 ≥4.5:1
- 新增守卫 ui-tests/admin-design-tokens.mjs(含 WCAG 对比度实算)

## 二、修复既有功能性缺陷:数据统计页漏斗卡与安全卡恒显示 0

- 现象:同一张卡片里,上方漏斗数字全是 0,下方汇总表却是 12846/9217/8462/3812,
  界面自相矛盾
- 根因:funnelStages / matchStages / safetyCards 是普通数组(const x = [...]),
  loadFunnel() 直接改元素属性,模板读的却是同一个数组 —— 非响应式写入不触发重渲染。
  同页凡走 ref 的值(汇总表、V11 区块)全部正常,规律清晰
- 修法(按根因):三个集合改为 computed() 从既有 ref 派生,卡片与汇总表因此同源,
  结构上不可能再出现「同一份数据的两处展示对不上」;另把 loadFunnel() 内遮蔽
  颜色助手的局部变量 const token 改名 authToken
- 该缺陷无 TS/lint/构建报错,前 18 个守卫全绿也抓不到 —— 只有真跑起来看渲染结果才暴露
- 新增守卫 ui-tests/admin-reactivity-contracts.mjs:
  ①非响应式状态(普通声明 + 被当状态写 + 出现在模板里;未被写过的静态常量清单合法)
  ②数据统计页同源契约 ③颜色助手遮蔽

## 验证

- UI 契约门禁 20/20 PASS(MIN_GUARDS 18→20)
- 负向注入 3 例全部 FAIL(exit 1),还原后复跑 exit 0(证明守卫有判别力)
- vue-tsc --noEmit exit 0
- vite build 成功(17.74s)
- 渲染态实测(Edge 无头 + CDP 读渲染后 DOM,同一 mock、同一路由,只差代码):
  修复前漏斗卡/安全卡全 0、比率全 '-' → 修复后 12846/9217/8462/3812/
  48210/51236/7420/4380/1926/1284,安全卡 17/214/23/9

交付物:artifacts/admin-design-v2-preview.html
This commit is contained in:
WorkBuddy
2026-09-23 01:06:57 +08:00
parent 269adf1fed
commit 631e3000d3
20 changed files with 1765 additions and 381 deletions
@@ -183,13 +183,13 @@ onMounted(loadActivities)
}
.form-hint {
margin-top: 8px;
color: #909399;
font-size: 12px;
color: var(--color-text-placeholder);
font-size: var(--font-size-xs);
}
.opt-type {
float: right;
color: #909399;
font-size: 12px;
color: var(--color-text-placeholder);
font-size: var(--font-size-xs);
}
.result-card {
margin-top: 12px;
@@ -434,8 +434,8 @@ onMounted(() => {
}
.form-hint {
margin-top: 4px;
color: #8a90a2;
font-size: 12px;
color: var(--color-text-placeholder);
font-size: var(--font-size-xs);
line-height: 1.5;
}
.partner-row {
@@ -213,8 +213,9 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { get } from '@/api/client'
import { token } from '@/theme/tokens'
const loading = ref(false)
const dateRange = ref<[string, string]>([
@@ -227,87 +228,108 @@ const safetyStats = ref<any>({})
const v11 = ref<any>({})
const dailySummary = ref<any[]>([])
const funnelStages = [
{ key: 'registered', label: '累计注册', value: 0, rate: null as string | null, color: '#409EFF' },
{ key: 'verified', label: '已实名', value: 0, rate: null as string | null, color: '#67C23A' },
{ key: 'approved', label: '审核通过', value: 0, rate: null as string | null, color: '#E59A32' },
{ key: 'active', label: '7日活跃', value: 0, rate: null as string | null, color: '#E96B6B' }
]
// 漏斗卡与安全卡一律**派生自上面的 ref**,不再各自持有一份可变副本。
//
// 历史缺陷(2026-09-23 渲染态实测发现):这三组曾是 `const x = [...]` 普通数组,
// loadFunnel() 直接对元素赋值(`x[0].value = ...`),模板读的却是同一个数组 ——
// 非响应式写入不触发重渲染,于是卡片恒显示 0,而同一张卡里的汇总表(走 ref)
// 显示 12846/9217/8462/3812,界面自相矛盾。
// 改为 computed 派生后两者同源,结构上不可能再对不上。
const funnelStages = computed(() => [
{
key: 'registered', label: '累计注册',
value: funnelStats.value.totalRegistered || 0,
rate: null as string | null, color: token('--color-info')
},
{
key: 'verified', label: '已实名',
value: funnelStats.value.verifiedCount || 0,
rate: funnelStats.value.verifyRate || '-', color: token('--color-success')
},
{
key: 'approved', label: '审核通过',
value: funnelStats.value.profileApproved || 0,
rate: funnelStats.value.approvalRate || '-', color: token('--color-warning')
},
{
key: 'active', label: '7日活跃',
value: funnelStats.value.activeUsers || 0,
rate: funnelStats.value.activeRate || '-', color: token('--color-primary')
}
])
const matchStages = [
{ key: 'recommended', label: '推荐', value: 0, rate: '-', color: '#409EFF' },
{ key: 'liked', label: '喜欢', value: 0, rate: '-', color: '#67C23A' },
{ key: 'matches', label: 'Match', value: 0, rate: '-', color: '#E59A32' },
{ key: 'chats', label: '聊天', value: 0, rate: '-', color: '#909399' },
{ key: 'knowing', label: '了解中', value: 0, rate: '-', color: '#E96B6B' },
{ key: 'success', label: '成功牵手', value: 0, rate: '-', color: '#F56C6C' }
]
const matchStages = computed(() => [
{
key: 'recommended', label: '推荐',
value: matchFunnelStats.value.totalRecommended || 0,
rate: '-', color: token('--color-info')
},
{
key: 'liked', label: '喜欢',
value: matchFunnelStats.value.totalLiked || 0,
rate: matchFunnelStats.value.likeRate || '-', color: token('--color-success')
},
{
key: 'matches', label: 'Match',
value: matchFunnelStats.value.totalMatches || 0,
rate: matchFunnelStats.value.matchRate || '-', color: token('--color-warning')
},
{
key: 'chats', label: '聊天',
value: matchFunnelStats.value.chatsStarted || 0,
rate: matchFunnelStats.value.chatRate || '-', color: token('--color-neutral')
},
{
key: 'knowing', label: '了解中',
value: matchFunnelStats.value.gettingToKnow || 0,
rate: '-', color: token('--color-primary')
},
{
key: 'success', label: '成功牵手',
value: matchFunnelStats.value.successPairs || 0,
rate: matchFunnelStats.value.successRate || '-', color: token('--color-danger')
}
])
const safetyCards = [
{ key: 'pending', label: '待处理举报', value: 0, color: '#E96B6B' },
{ key: 'resolved', label: '已处理举报', value: 0, color: '#67C23A' },
{ key: 'highRisk', label: '高风险用户', value: 0, color: '#E59A32' },
{ key: 'banned', label: '封禁用户', value: 0, color: '#F56C6C' }
]
const safetyCards = computed(() => [
{ key: 'pending', label: '待处理举报', value: safetyStats.value.pendingReports || 0, color: token('--color-primary') },
{ key: 'resolved', label: '已处理举报', value: safetyStats.value.resolvedReports || 0, color: token('--color-success') },
{ key: 'highRisk', label: '高风险用户', value: safetyStats.value.highRiskUsers || 0, color: token('--color-warning') },
{ key: 'banned', label: '封禁用户', value: safetyStats.value.bannedUsers || 0, color: token('--color-danger') }
])
async function loadFunnel() {
try {
const token = localStorage.getItem('accessToken')
// 注意:这里曾是 `const token = ...`,与顶部从 @/theme/tokens 导入的**颜色助手**
// `token` 同名(函数内遮蔽)。现已改名 authToken,避免后续在函数内误用颜色助手。
const authToken = localStorage.getItem('accessToken')
if (dateRange.value[0] && dateRange.value[1]) {
const [userRes, matchRes, safetyRes, v11Res]: any[] = await Promise.all([
get('/operator/analytics/user-funnel', {
since: dateRange.value[0], until: dateRange.value[1]
}, { Authorization: `Bearer ${token}` }),
}, { Authorization: `Bearer ${authToken}` }),
get('/operator/analytics/match-funnel', {
since: dateRange.value[0], until: dateRange.value[1]
}, { Authorization: `Bearer ${token}` }),
get('/operator/analytics/safety', {}, { Authorization: `Bearer ${token}` }),
}, { Authorization: `Bearer ${authToken}` }),
get('/operator/analytics/safety', {}, { Authorization: `Bearer ${authToken}` }),
// Phase 20:原先只接了 4/15 个端点,V1.1 公益服务的专项统计
// (服务用户数/人次、活动互选 vs 成功、Banner、专项项目)
// 后端已实现却无人调用 —— 本行起补齐。
get('/operator/analytics/v11-dashboard', {}, { Authorization: `Bearer ${token}` })
get('/operator/analytics/v11-dashboard', {}, { Authorization: `Bearer ${authToken}` })
])
const userData = userRes.data || {}
const matchData = matchRes.data || {}
const safetyData = safetyRes.data || {}
const v11Data = v11Res.data || {}
funnelStats.value = userData
matchFunnelStats.value = matchData
safetyStats.value = safetyData
v11.value = v11Data
// 更新漏斗显示
funnelStages[0].value = userData.totalRegistered || 0
funnelStages[1].value = userData.verifiedCount || 0
funnelStages[1].rate = userData.verifyRate || '-'
funnelStages[2].value = userData.profileApproved || 0
funnelStages[2].rate = userData.approvalRate || '-'
funnelStages[3].value = userData.activeUsers || 0
funnelStages[3].rate = userData.activeRate || '-'
matchStages[0].value = matchData.totalRecommended || 0
matchStages[1].value = matchData.totalLiked || 0
matchStages[1].rate = matchData.likeRate || '-'
matchStages[2].value = matchData.totalMatches || 0
matchStages[2].rate = matchData.matchRate || '-'
matchStages[3].value = matchData.chatsStarted || 0
matchStages[3].rate = matchData.chatRate || '-'
matchStages[4].value = matchData.gettingToKnow || 0
matchStages[5].value = matchData.successPairs || 0
matchStages[5].rate = matchData.successRate || '-'
safetyCards[0].value = safetyData.pendingReports || 0
safetyCards[1].value = safetyData.resolvedReports || 0
safetyCards[2].value = safetyData.highRiskUsers || 0
safetyCards[3].value = safetyData.bannedUsers || 0
// 只落 ref,卡片由上面三个 computed 自动派生 —— 不再手工同步第二份副本。
funnelStats.value = userRes.data || {}
matchFunnelStats.value = matchRes.data || {}
safetyStats.value = safetyRes.data || {}
v11.value = v11Res.data || {}
}
} catch (e) { console.error(e) }
}
async function loadDailySummary() {
try {
const token = localStorage.getItem('accessToken')
const res: any = await get('/operator/analytics/daily-summary', { days: 30 }, { Authorization: `Bearer ${token}` })
const authToken = localStorage.getItem('accessToken')
const res: any = await get('/operator/analytics/daily-summary', { days: 30 }, { Authorization: `Bearer ${authToken}` })
dailySummary.value = res.data || []
} catch (e) { console.error(e) }
}
@@ -329,21 +351,21 @@ onMounted(loadData)
padding: 20px;
text-align: center;
}
.funnel-label { font-size: 14px; color: #666; margin-bottom: 8px; }
.funnel-value { font-size: 28px; font-weight: bold; color: #333; }
.funnel-rate { font-size: 12px; color: #999; margin-top: 4px; }
.funnel-label { font-size: var(--font-size-base); color: var(--color-text-tertiary); margin-bottom: 8px; }
.funnel-value { font-size: var(--font-size-4xl); font-weight: var(--font-weight-bold); color: var(--color-text-primary); }
.funnel-rate { font-size: var(--font-size-xs); color: var(--color-text-placeholder); margin-top: 4px; }
.stat-card { text-align: center; }
.stat-value { font-size: 32px; font-weight: bold; }
.stat-label { font-size: 13px; color: #666; margin-top: 6px; }
.stat-value { font-size: var(--font-size-5xl); font-weight: var(--font-weight-bold); }
.stat-label { font-size: var(--font-size-sm); color: var(--color-text-tertiary); margin-top: 6px; }
.mini-stat {
border: 1px solid #ebeef5;
border: 1px solid var(--color-border-light);
border-radius: 8px;
padding: 14px;
text-align: center;
background: #fafafa;
background: var(--color-bg-secondary);
}
.mini-value { font-size: 22px; font-weight: bold; color: #303133; }
.mini-label { font-size: 12px; color: #909399; margin-top: 6px; }
.metric-note { font-size: 12px; color: #909399; margin-top: 12px; line-height: 1.6; }
.unavailable { color: #c0c4cc; font-style: italic; }
.mini-value { font-size: var(--font-size-3xl); font-weight: var(--font-weight-bold); color: var(--color-text-primary); }
.mini-label { font-size: var(--font-size-xs); color: var(--color-text-placeholder); margin-top: 6px; }
.metric-note { font-size: var(--font-size-xs); color: var(--color-text-placeholder); margin-top: 12px; line-height: 1.6; }
.unavailable { color: var(--color-text-disabled); font-style: italic; }
</style>
@@ -60,7 +60,7 @@
</el-descriptions>
<template v-if="detail.status==='pending'||detail.status==='reviewing'||detail.status==='escalated'">
<h4 style="margin:20px 0 12px;color:#E96B6B">审核决策</h4>
<h4 style="margin:20px 0 12px;color:var(--admin-coral-strong)">审核决策</h4>
<el-form label-width="100px">
<el-form-item label="处理结果">
<el-select v-model="decision" style="width:100%">
@@ -86,6 +86,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { get, post } from '@/api/client'
import { token as tokenColor } from '@/theme/tokens'
const loading = ref(false)
const processing = ref(false)
@@ -122,11 +123,11 @@ async function loadStats() {
const res: any = await get('/operator/appeals/stats', {}, { Authorization: `Bearer ${token}` })
const data = res.data || {}
stats.value = [
{ key: 'pending', label: '待审核', value: data.pendingCount || 0, color: '#E96B6B' },
{ key: 'reviewing', label: '审核中', value: data.reviewingCount || 0, color: '#E59A32' },
{ key: 'approved', label: '已通过', value: data.approvedCount || 0, color: '#67C23A' },
{ key: 'rejected', label: '已驳回', value: data.rejectedCount || 0, color: '#909399' },
{ key: 'escalated', label: '已升级', value: data.escalatedCount || 0, color: '#F56C6C' }
{ key: 'pending', label: '待审核', value: data.pendingCount || 0, color: tokenColor('--color-primary') },
{ key: 'reviewing', label: '审核中', value: data.reviewingCount || 0, color: tokenColor('--color-warning') },
{ key: 'approved', label: '已通过', value: data.approvedCount || 0, color: tokenColor('--color-success') },
{ key: 'rejected', label: '已驳回', value: data.rejectedCount || 0, color: tokenColor('--color-neutral') },
{ key: 'escalated', label: '已升级', value: data.escalatedCount || 0, color: tokenColor('--color-danger') }
]
} catch {}
}
@@ -168,8 +169,8 @@ onMounted(loadData)
<style scoped>
.page-container { padding: 24px; }
.stat-card { text-align: center; }
.stat-value { font-size: 32px; font-weight: bold; color: #409EFF; }
.stat-label { font-size: 13px; color: #666; margin-top: 6px; }
.stat-value { font-size: var(--font-size-5xl); font-weight: var(--font-weight-bold); color: var(--admin-coral-strong); }
.stat-label { font-size: var(--font-size-sm); color: var(--color-text-tertiary); margin-top: 6px; }
.card-header { display: flex; justify-content: space-between; align-items: center; }
.text-muted { color: #BBB; font-size: 13px; }
.text-muted { color: var(--color-text-disabled); font-size: var(--font-size-sm); }
</style>
@@ -302,21 +302,21 @@ onMounted(loadBanners)
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; }
.field-hint { width: 100%; font-size: 12px; color: var(--el-text-color-secondary); line-height: 1.5; }
.preview-stage { padding: 20px; background: #f5f6f8; border-radius: 12px; }
.preview-stage { padding: 20px; background: var(--color-bg-subtle); border-radius: 12px; }
.preview-card {
padding: 20px 18px;
border-radius: 16px;
background: linear-gradient(135deg, #ffe9df, #fff7f2);
background: var(--gradient-brand-soft); /* 还原消费端 Banner 预览底色 */
box-shadow: 0 6px 16px rgba(200, 120, 100, 0.16);
}
.preview-tag {
display: inline-block; padding: 2px 10px; border-radius: 999px;
background: rgba(255, 255, 255, 0.85); color: #c8553d; font-size: 12px;
background: var(--color-bg-overlay); color: var(--admin-coral-strong); font-size: var(--font-size-xs);
}
.preview-title { margin-top: 10px; font-size: 17px; font-weight: 700; color: #2c2c34; }
.preview-subtitle { margin-top: 6px; font-size: 12px; line-height: 1.6; color: #6b6b78; }
.preview-title { margin-top: 10px; font-size: var(--font-size-xl); font-weight: var(--font-weight-bold); color: var(--color-text-primary); }
.preview-subtitle { margin-top: 6px; font-size: var(--font-size-xs); line-height: 1.6; color: var(--color-text-tertiary); }
.preview-button {
display: inline-block; margin-top: 14px; padding: 5px 16px; border-radius: 999px;
background: linear-gradient(135deg, #ff7d6e, #ff5c82); color: #fff; font-size: 12px; font-weight: 600;
background: var(--gradient-brand-vivid); color: var(--color-text-inverse); font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); min-height: var(--tap-control); /* 还原消费端 Banner 按钮外观预览 */
}
</style>
@@ -253,5 +253,5 @@ onMounted(() => { loadCategories(); loadArticles() })
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; }
.field-hint { width: 100%; font-size: 12px; color: var(--el-text-color-secondary); line-height: 1.5; }
.field-hint { width: 100%; font-size: var(--font-size-xs); color: var(--el-text-color-secondary); line-height: 1.5; }
</style>
+1 -1
View File
@@ -284,5 +284,5 @@ onMounted(loadPage)
</script>
<style scoped>
.page-container{padding:28px}.page-kicker{color:var(--admin-primary);font-size:12px;font-weight:650;letter-spacing:1px}.stat-tags{display:flex;gap:8px}.review-panel{padding:20px 24px}.muted{color:var(--color-text-tertiary);font-size:12px}.drawer-loading{min-height:240px}.drawer-heading{margin:24px 0 12px;font-size:16px}.full-width{width:100%}.review-actions{display:flex;flex-wrap:wrap;gap:8px}@media(max-width:760px){.page-container{padding:18px}}
.page-container{padding:28px}.page-kicker{color:var(--admin-primary);font-size:var(--font-size-xs);font-weight:var(--font-weight-semibold);letter-spacing:1px}.stat-tags{display:flex;gap:8px}.review-panel{padding:20px 24px}.muted{color:var(--color-text-tertiary);font-size:var(--font-size-xs)}.drawer-loading{min-height:240px}.drawer-heading{margin:24px 0 12px;font-size:var(--font-size-lg)}.full-width{width:100%}.review-actions{display:flex;flex-wrap:wrap;gap:8px}@media(max-width:760px){.page-container{padding:18px}}
</style>
@@ -60,7 +60,7 @@
<el-descriptions-item label="补充说明" :span="2">{{ detail.report?.description || '无' }}</el-descriptions-item>
</el-descriptions>
<h4 style="margin:20px 0 12px;color:#E96B6B">被举报人风险画像</h4>
<h4 style="margin:20px 0 12px;color:var(--admin-coral-strong)">被举报人风险画像</h4>
<el-descriptions :column="2" border v-if="detail.riskProfile">
<el-descriptions-item label="用户">{{ detail.riskProfile.displayName }}</el-descriptions-item>
<el-descriptions-item label="风险分数">{{ detail.riskProfile.totalRiskScore }}</el-descriptions-item>
@@ -72,7 +72,7 @@
<el-descriptions-item label="历史举报数">{{ detail.riskProfile.totalReportsCount }}</el-descriptions-item>
</el-descriptions>
<h4 style="margin:20px 0 12px;color:#E96B6B">处置操作</h4>
<h4 style="margin:20px 0 12px;color:var(--admin-coral-strong)">处置操作</h4>
<el-form label-width="100px">
<el-form-item label="处理结果">
<el-select v-model="processResult" style="width:100%">
@@ -100,6 +100,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { get, post } from '@/api/client'
import { token as tokenColor } from '@/theme/tokens'
const loading = ref(false)
const processing = ref(false)
@@ -137,10 +138,10 @@ async function loadStats() {
const res: any = await get('/operator/risk/reports/stats', {}, { Authorization: `Bearer ${token}` })
const data = res.data || {}
stats.value = [
{ key: 'pending', label: '待处理', value: data.pendingCount || 0, color: '#E96B6B' },
{ key: 'processing', label: '处理中', value: data.processingCount || 0, color: '#E59A32' },
{ key: 'resolved', label: '已处理', value: data.resolvedCount || 0, color: '#67C23A' },
{ key: 'dismissed', label: '已驳回', value: data.dismissedCount || 0, color: '#909399' }
{ key: 'pending', label: '待处理', value: data.pendingCount || 0, color: tokenColor('--color-primary') },
{ key: 'processing', label: '处理中', value: data.processingCount || 0, color: tokenColor('--color-warning') },
{ key: 'resolved', label: '已处理', value: data.resolvedCount || 0, color: tokenColor('--color-success') },
{ key: 'dismissed', label: '已驳回', value: data.dismissedCount || 0, color: tokenColor('--color-neutral') }
]
} catch {}
}
@@ -188,7 +189,7 @@ onMounted(loadData)
<style scoped>
.page-container { padding: 24px; }
.stat-card { text-align: center; }
.stat-value { font-size: 36px; font-weight: bold; }
.stat-label { font-size: 14px; color: #666; margin-top: 8px; }
.stat-value { font-size: var(--font-size-5xl); font-weight: var(--font-weight-bold); }
.stat-label { font-size: var(--font-size-base); color: var(--color-text-tertiary); margin-top: 8px; }
.card-header { display: flex; justify-content: space-between; align-items: center; }
</style>
@@ -348,6 +348,6 @@ onMounted(loadStations)
.form-hint {
margin-left: 10px;
color: var(--el-text-color-secondary);
font-size: 12px;
font-size: var(--font-size-xs);
}
</style>
@@ -20,7 +20,7 @@
<div class="identity-summary">
<el-avatar :size="58">{{ (user.displayName || '用')[0] }}</el-avatar>
<div><h2>{{ user.displayName || '未设置显示名称' }}</h2><span>用户编号:{{ user.id }}</span></div>
<el-progress class="completion" type="dashboard" :percentage="user.profileCompletion || 0" :width="72" :stroke-width="7" color="#EE666B" />
<el-progress class="completion" type="dashboard" :percentage="user.profileCompletion || 0" :width="72" :stroke-width="7" color="var(--admin-coral-solid)" />
</div>
<el-tabs v-model="activeTab" class="detail-tabs">
<el-tab-pane label="基础信息" name="basic">
@@ -129,5 +129,5 @@ function formatTime(time?: string) { return time ? new Date(time).toLocaleString
onMounted(loadDetail)
</script>
<style scoped>
.page-container{padding:28px}.back-button{margin-bottom:8px}.heading-tags{display:flex;gap:8px}.detail-card{padding:24px}.identity-summary{display:flex;align-items:center;gap:14px;padding-bottom:20px;border-bottom:1px solid var(--color-border-light)}.identity-summary .el-avatar{color:#fff;background:var(--admin-primary)}.identity-summary h2{font-size:20px}.identity-summary span{display:block;margin-top:6px;color:var(--color-text-tertiary);font-size:12px}.completion{margin-left:auto}.detail-tabs{margin-top:18px}.masked{margin-right:8px;color:var(--color-text-tertiary)}.risk-summary{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.risk-summary article{padding:18px;background:var(--admin-soft-pink);border-radius:12px}.risk-summary span{color:var(--color-text-secondary)}.risk-summary strong{display:block;margin-top:8px;font-size:26px}.danger-zone{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-top:20px;padding:18px;border:1px solid #F3CCCC;border-radius:12px}.danger-zone h3{display:flex;align-items:center;gap:7px;color:var(--color-danger)}.danger-zone p{margin-top:6px;color:var(--color-text-secondary)}.status-form{margin-top:18px}.full-width{width:100%}@media(max-width:760px){.page-container{padding:18px}.risk-summary{grid-template-columns:1fr}.danger-zone{align-items:flex-start;flex-direction:column}}
.page-container{padding:28px}.back-button{margin-bottom:8px}.heading-tags{display:flex;gap:8px}.detail-card{padding:24px}.identity-summary{display:flex;align-items:center;gap:14px;padding-bottom:20px;border-bottom:1px solid var(--color-border-light)}.identity-summary .el-avatar{color:var(--color-text-inverse);background:var(--admin-coral-solid)}.identity-summary h2{font-size:var(--font-size-2xl)}.identity-summary span{display:block;margin-top:6px;color:var(--color-text-tertiary);font-size:var(--font-size-xs)}.completion{margin-left:auto}.detail-tabs{margin-top:18px}.masked{margin-right:8px;color:var(--color-text-tertiary)}.risk-summary{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.risk-summary article{padding:18px;background:var(--admin-soft-pink);border-radius:12px}.risk-summary span{color:var(--color-text-secondary)}.risk-summary strong{display:block;margin-top:8px;font-size:var(--font-size-4xl)}.danger-zone{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-top:20px;padding:18px;border:1px solid var(--color-border-danger);border-radius:12px}.danger-zone h3{display:flex;align-items:center;gap:7px;color:var(--color-danger-strong)}.danger-zone p{margin-top:6px;color:var(--color-text-secondary)}.status-form{margin-top:18px}.full-width{width:100%}@media(max-width:760px){.page-container{padding:18px}.risk-summary{grid-template-columns:1fr}.danger-zone{align-items:flex-start;flex-direction:column}}
</style>
@@ -206,8 +206,8 @@ onMounted(loadData)
.page-kicker {
color: var(--admin-primary);
font-size: 12px;
font-weight: 650;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
letter-spacing: 1px;
}