管理端:设计系统 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
+110 -92
View File
@@ -264,19 +264,20 @@ function handleLogout() {
/* 背景图是 public/ 下的运行时资源,必须带 BASE_URL 前缀:
写死品牌目录的绝对路径,在产物部署于 /admin/ 子路径时会 404(页面背景变纯色)。
用 v-bind 把常量注入 CSS,避免在样式里各写一遍拼接。 */
background: #fff7f7 center / cover fixed no-repeat;
/* 先给纯色兜底,避免图未加载完成时闪白 */
background: var(--admin-coral-lighter) center / cover fixed no-repeat;
background-image: url(v-bind('BRAND_BACKDROP'));
}
.scene-header {
display: grid;
max-width: 1840px;
max-width: var(--admin-shell-max-width);
height: 188px;
margin: 0 auto;
grid-template-columns: minmax(640px, 1fr) auto;
grid-template-rows: 112px 76px;
align-items: center;
column-gap: 32px;
column-gap: var(--space-8);
}
.scene-brand,
@@ -295,23 +296,23 @@ function handleLogout() {
.scene-slogan {
display: flex;
padding-right: 24px;
padding-right: var(--space-6);
align-items: flex-end;
flex-direction: column;
color: var(--admin-coral);
font-family: KaiTi, STKaiti, serif;
color: var(--admin-coral-strong);
font-family: var(--font-family-brand);
line-height: 1.05;
transform: rotate(-7deg);
}
.scene-slogan span {
font-size: 17px;
font-size: var(--font-size-lg);
}
.scene-slogan strong {
margin-top: 4px;
font-size: 25px;
font-weight: 500;
margin-top: var(--space-1);
font-size: var(--font-size-3xl);
font-weight: var(--font-weight-medium);
}
.scene-brand-copy {
@@ -319,18 +320,18 @@ function handleLogout() {
margin-left: 10px;
align-items: flex-start;
flex-direction: column;
color: var(--admin-coral);
color: var(--admin-coral-strong);
}
.scene-brand-copy strong {
font-size: 27px;
font-size: var(--font-size-4xl);
line-height: 1.15;
letter-spacing: 2px;
}
.scene-brand-copy span {
margin-top: 5px;
font-size: 12px;
font-size: var(--font-size-xs);
letter-spacing: 4px;
}
@@ -338,18 +339,18 @@ function handleLogout() {
min-width: 370px;
margin-left: 28px;
padding-left: 28px;
border-left: 1px solid rgba(244, 95, 104, 0.22);
border-left: 1px solid var(--admin-coral-border);
}
.scene-title h1 {
font-size: clamp(26px, 2.2vw, 38px);
font-size: clamp(var(--font-size-3xl), 2.2vw, var(--font-size-display));
line-height: 1.1;
letter-spacing: 1px;
}
.scene-title p {
margin-top: 9px;
color: #66717a;
color: var(--color-text-secondary);
}
.scene-benefits {
@@ -357,40 +358,41 @@ function handleLogout() {
align-self: start;
justify-content: flex-start;
gap: clamp(38px, 5vw, 92px);
padding: 4px 0 0 50px;
padding: var(--space-1) 0 0 50px;
}
.scene-benefits span {
display: grid;
grid-template-columns: 36px auto;
column-gap: 8px;
color: var(--admin-coral);
column-gap: var(--space-2);
/* 13px 文字压在摄影背景图上,必须用 -strong 档(5.13:1),基础色只有 3.14:1 */
color: var(--admin-coral-strong);
}
.scene-benefits .el-icon {
grid-row: 1 / 3;
font-size: 30px;
font-size: var(--font-size-4xl);
}
.scene-benefits b {
font-size: 13px;
font-size: var(--font-size-sm);
}
.scene-benefits small {
margin-top: 2px;
color: #7b7f86;
font-size: 10px;
color: var(--color-text-tertiary);
font-size: var(--font-size-micro);
white-space: nowrap;
}
.admin-shell {
width: min(1840px, 100%);
width: min(var(--admin-shell-max-width), 100%);
height: calc(100vh - 238px);
min-height: 560px;
margin: 0 auto;
overflow: hidden;
background: var(--admin-shell-bg);
border: 1px solid rgba(255, 255, 255, 0.9);
border: 1px solid var(--color-bg-overlay-strong);
border-radius: var(--admin-shell-radius);
box-shadow: var(--admin-shadow);
backdrop-filter: blur(14px);
@@ -401,23 +403,23 @@ function handleLogout() {
flex-direction: column;
overflow: hidden;
color: var(--admin-sidebar-text);
background: linear-gradient(180deg, #202d38 0%, #192733 100%);
border-right: 1px solid rgba(255, 255, 255, 0.06);
transition: width 0.2s ease;
background: var(--admin-sidebar-gradient);
border-right: 1px solid var(--admin-sidebar-hairline-soft);
transition: width var(--motion-normal) var(--ease-standard);
}
.brand {
display: flex;
height: 82px;
padding: 17px 16px;
padding: 17px var(--space-4);
align-items: center;
gap: 11px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
border-bottom: 1px solid var(--admin-sidebar-hairline);
}
.brand.compact {
justify-content: center;
padding: 16px 8px;
padding: var(--space-4) var(--space-2);
}
.brand-mark {
@@ -435,15 +437,15 @@ function handleLogout() {
}
.brand-copy strong {
color: #fff;
font-size: 17px;
color: var(--color-text-inverse);
font-size: var(--font-size-lg);
letter-spacing: 1px;
}
.brand-copy span {
margin-top: 4px;
color: #b5c0c8;
font-size: 10px;
margin-top: var(--space-1);
color: var(--color-text-on-dark-muted);
font-size: var(--font-size-micro);
letter-spacing: 1.5px;
}
@@ -457,49 +459,54 @@ function handleLogout() {
background: transparent;
}
/* 主导航行项:桌面密度下取 44px(兼顾触屏笔记本,且远高于 WCAG 2.5.8 的 24px 下限) */
.sidebar-menu :deep(.el-menu-item) {
height: 41px;
height: var(--tap-min);
margin: 2px 0;
color: var(--admin-sidebar-text);
border-radius: 9px;
border-radius: var(--border-radius-sm);
transition: background var(--motion-fast) var(--ease-standard),
color var(--motion-fast) var(--ease-standard);
}
.sidebar-menu :deep(.el-menu-item:hover) {
color: #fff;
color: var(--color-text-inverse);
background: var(--admin-sidebar-surface);
}
/* 激活态原先压的是明亮珊瑚渐变(#f45f68→#ee727b),白字只有 3.14:1;
改用达标渐变(两端 4.60:1 / 5.33:1)。 */
.sidebar-menu :deep(.el-menu-item.is-active) {
color: #fff;
font-weight: 650;
background: linear-gradient(135deg, #f45f68, #ee727b);
box-shadow: 0 8px 18px rgba(244, 95, 104, 0.26);
color: var(--color-text-inverse);
font-weight: var(--font-weight-semibold);
background: var(--gradient-brand-solid);
box-shadow: var(--shadow-brand);
}
.sidebar-menu :deep(.el-icon) {
font-size: 17px;
font-size: var(--font-size-lg);
}
.sidebar-motto {
display: flex;
margin: 8px 15px 2px;
padding: 14px 12px;
margin: var(--space-2) 15px 2px;
padding: 14px var(--space-3);
align-items: center;
gap: 11px;
color: #ff969c;
font-family: KaiTi, STKaiti, serif;
font-size: 15px;
color: var(--admin-coral-on-dark);
font-family: var(--font-family-brand);
font-size: var(--font-size-md);
line-height: 1.45;
border-top: 1px solid rgba(255, 255, 255, 0.08);
border-top: 1px solid var(--admin-sidebar-hairline);
}
.sidebar-motto .el-icon {
font-size: 31px;
font-size: var(--font-size-4xl);
}
.collapse-control {
display: flex;
height: 42px;
min-height: var(--tap-min);
margin: 2px 10px 10px;
align-items: center;
justify-content: center;
@@ -508,10 +515,14 @@ function handleLogout() {
cursor: pointer;
background: transparent;
border: 0;
border-radius: var(--border-radius-sm);
transition: color var(--motion-fast) var(--ease-standard),
background var(--motion-fast) var(--ease-standard);
}
.collapse-control:hover {
color: #fff;
color: var(--color-text-inverse);
background: var(--admin-sidebar-surface);
}
.workspace {
@@ -520,12 +531,12 @@ function handleLogout() {
.admin-topbar {
display: flex;
height: 64px;
padding: 0 24px;
height: var(--header-height);
padding: 0 var(--space-6);
align-items: center;
justify-content: space-between;
background: rgba(255, 255, 255, 0.96);
border-bottom: 1px solid #edf0f2;
background: var(--color-bg-overlay-strong);
border-bottom: 1px solid var(--color-border-light);
}
.page-context,
@@ -537,15 +548,18 @@ function handleLogout() {
.page-context {
min-width: 180px;
gap: 12px;
gap: var(--space-3);
}
.mobile-collapse {
display: none;
color: #56616a;
min-width: var(--tap-icon);
min-height: var(--tap-icon);
color: var(--color-text-secondary);
cursor: pointer;
background: transparent;
border: 0;
border-radius: var(--border-radius-sm);
}
.header-tools {
@@ -557,57 +571,59 @@ function handleLogout() {
}
.global-search :deep(.el-input__wrapper) {
background: #f6f7f8;
border-radius: 9px;
box-shadow: inset 0 0 0 1px #e8eaed;
background: var(--color-bg-input);
border-radius: var(--border-radius-sm);
box-shadow: inset 0 0 0 1px var(--color-border);
}
.notification-button {
position: relative;
display: grid;
width: 36px;
height: 36px;
color: #4f5b65;
width: var(--tap-icon);
height: var(--tap-icon);
color: var(--color-text-secondary);
cursor: pointer;
background: transparent;
border: 0;
border-radius: 9px;
border-radius: var(--border-radius-sm);
place-items: center;
transition: background var(--motion-fast) var(--ease-standard);
}
.notification-button:hover {
background: #f6f7f8;
background: var(--color-bg-input);
}
/* 角标字号原为 9px(低于可读下限),提到 11px,容器同步放大以容纳 */
.notification-badge {
position: absolute;
top: 2px;
right: 1px;
min-width: 15px;
height: 15px;
min-width: var(--space-4);
height: var(--space-4);
padding: 0 3px;
color: #fff;
font-size: 9px;
line-height: 15px;
color: var(--color-text-inverse);
font-size: var(--font-size-micro);
line-height: var(--space-4);
text-align: center;
background: var(--admin-coral);
border-radius: 999px;
background: var(--admin-coral-solid);
border-radius: var(--border-radius-pill);
}
.user-info {
gap: 8px;
gap: var(--space-2);
cursor: pointer;
}
.user-info .el-avatar {
color: #fff;
background: var(--admin-coral);
color: var(--color-text-inverse);
background: var(--admin-coral-solid);
}
.user-info strong {
max-width: 92px;
overflow: hidden;
font-size: 13px;
font-size: var(--font-size-sm);
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -615,27 +631,28 @@ function handleLogout() {
.admin-content {
padding: 0;
overflow: auto;
background: var(--admin-page-bg);
background: var(--color-bg-page);
}
.scene-footer {
display: flex;
max-width: 1840px;
height: 32px;
max-width: var(--admin-shell-max-width);
height: var(--space-8);
margin: 0 auto;
align-items: flex-end;
gap: 10px;
color: #8d8587;
font-size: 11px;
color: var(--color-text-tertiary);
font-size: var(--font-size-micro);
}
.scene-footer strong {
color: var(--admin-coral);
font-size: 14px;
color: var(--admin-coral-strong);
font-size: var(--font-size-base);
}
.scene-footer small {
margin-left: auto;
font-size: var(--font-size-micro);
letter-spacing: 4px;
}
@@ -653,15 +670,15 @@ function handleLogout() {
}
.scene-slogan {
padding-right: 12px;
padding-right: var(--space-3);
}
.scene-slogan span {
font-size: 14px;
font-size: var(--font-size-base);
}
.scene-slogan strong {
font-size: 20px;
font-size: var(--font-size-2xl);
}
.scene-brand > img {
@@ -670,7 +687,7 @@ function handleLogout() {
}
.scene-brand-copy strong {
font-size: 22px;
font-size: var(--font-size-2xl);
}
.scene-title {
@@ -679,8 +696,9 @@ function handleLogout() {
padding-left: 18px;
}
/* 紧凑态下正文标题略大于品牌字号,层级保持「页面 → 品牌」的正确指向 */
.scene-title h1 {
font-size: 27px;
font-size: var(--font-size-3xl);
}
.scene-benefits {
@@ -707,7 +725,7 @@ function handleLogout() {
}
.admin-sidebar {
width: 76px !important;
width: var(--sidebar-width-collapsed) !important;
}
.brand-copy,
@@ -741,7 +759,7 @@ function handleLogout() {
}
.admin-topbar {
padding: 0 12px;
padding: 0 var(--space-3);
}
}
</style>
@@ -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;
}
+378 -59
View File
@@ -1,93 +1,412 @@
/* ============================================================================
* 管理端设计系统 · v2
*
* 本文件是管理端唯一的设计 token 事实源。24 个 .vue 的 <style> 只允许通过
* var(--token) 取色 / 取字号 / 取间距,不应出现字面色值(由门控脚本
* scripts/lint-admin-tokens.mjs 拦截)。
*
* 立此版本的原因(2026-09-22 审计实测,不是主观判断):
* · 97 处硬编码颜色 / 67 种色值:6 种近似粉底(#FFF0F1 #FFF3F2 #FFF6F6
* #FFF7F7 #FFF8F7 #FFFAFA)、6 种近似浅灰边框、大量灰阶碎片。
* · 88 处硬编码字号 / 23 种档位,其中 20 处 ≤11px(9px 见于 .metric-copy em、
* .activity-status、.todo-copy small),低于可读下限。
* · --color-text-tertiary(#888888) 白底 3.54:1、--color-warning(#E59A32) 2.33:1、
* --admin-coral(#F45F68) 3.14:1 —— 在各自用法下均不达 WCAG AA 的 4.5:1。
* · Element Plus 的 --el-color-*-light-N / -dark-2 / -rgb 是**编译期静态生成**的,
* 项目只覆盖了 --el-color-primary,于是开关、滑块、焦点环、分页、标签等
* 组件仍是默认蓝 —— 品牌色与 EP 蓝混用。
*
* 三条硬规则:
* 1) 品牌色三级分化:基础色只做色块/渐变/图形(不承载文字);
* -strong 做白底文字;-solid 做实心底压白字;两者均实算 ≥4.5:1。
* 2) 字重只用 400 / 500 / 600 / 700 四档(跨平台缺字重会回退,导致渲染不一致)。
* 3) 字号有下限:正文最小 12px,角标/计数不得低于 11px。
*
* 所有对比度数值均由脚本按 WCAG 相对亮度公式实算(非估算),标注在各 token 后。
* ========================================================================== */
:root {
--admin-coral: #f45f68;
--admin-coral-hover: #df505a;
--admin-coral-soft: #fff0f1;
--admin-shell-bg: rgba(255, 255, 255, 0.96);
--admin-sidebar-bg: #202d38;
--admin-sidebar-surface: #2b3a46;
--admin-sidebar-text: #D9E1E6;
--admin-sidebar-muted: #8FA1AC;
--admin-page-bg: #f7f9fb;
/* ---------------- 品牌色 ---------------- */
/* 基础色:仅用于色块 / 渐变 / 图标 / 边框氛围。白底作文字只有 3.14:1,禁止承载文字。 */
--admin-coral: #F45F68;
--admin-coral-hover: #E5535D;
--admin-coral-strong: #C0414A; /* 白底文字 5.13:1 */
--admin-coral-solid: #C84A53; /* 实心底压白字 4.60:1 */
--admin-coral-solid-hover: #B8434A; /* hover 反而更深更清晰 5.33:1 */
--admin-coral-on-dark: #FF969C; /* 侧栏深底文字 6.76:1 */
--admin-coral-soft: #FFF0F1; /* 品牌浅底(角标 / 选中态底) */
--admin-coral-lighter: #FFF8F7; /* 更浅一档,hover 底 */
--admin-coral-border: rgba(244, 95, 104, 0.22);
/* 承载白字的品牌渐变(两端均 ≥4.5:1)。基础色渐变见 --gradient-brand-vivid。 */
--gradient-brand-solid: linear-gradient(135deg, #C84A53 0%, #B8434A 100%);
/* 鲜亮品牌渐变:只允许用在「无文字覆盖」的装饰,或预览态还原(如 Banner 预览)。 */
--gradient-brand-vivid: linear-gradient(135deg, #FF7D6E 0%, #FF5C82 100%);
--gradient-brand-soft: linear-gradient(135deg, #FFE9DF 0%, #FFF7F2 100%);
/* 水平进度条填充(对轨道 --color-primary-light):原 #F56B75→#EF5A65 仅 2.53:1,
条与轨道难以区分;改达标渐变后 4.01:1。 */
--gradient-brand-bar: linear-gradient(90deg, #C84A53 0%, #B8434A 100%);
/* 兼容既有引用名(全站仍在用) */
--admin-primary: var(--admin-coral);
--admin-primary-hover: var(--admin-coral-hover);
--admin-card-radius: 14px;
--admin-shell-radius: 22px;
--admin-shadow: 0 18px 48px rgba(57, 40, 45, 0.14);
--admin-header-bg: #FFFFFF;
--admin-soft-pink: var(--admin-coral-soft);
--admin-soft-blue: #EEF5FF;
--admin-soft-green: #EDF8F1;
--admin-soft-purple: #F4EFFF;
--color-primary: var(--admin-primary);
--color-primary-hover: var(--admin-primary-hover);
--color-primary-light: #FFF3F2;
--color-primary-lighter: #FFF8F7;
--color-success: #2E9B68;
--color-warning: #E59A32;
--color-danger: #D94A4A;
--color-info: #4A7CD4;
--color-text-primary: #222222;
--color-text-secondary: #555555;
--color-text-tertiary: #888888;
--admin-primary-strong: var(--admin-coral-strong);
--admin-primary-solid: var(--admin-coral-solid);
--color-primary: var(--admin-coral);
--color-primary-hover: var(--admin-coral-hover);
--color-primary-strong: var(--admin-coral-strong);
--color-primary-solid: var(--admin-coral-solid);
--color-primary-light: var(--admin-coral-soft);
--color-primary-lighter: var(--admin-coral-lighter);
/* ---------------- 语义色五组 ----------------
基色做图形/色块;-strong 做文字(实算 ≥4.5:1);-soft 为配套浅底。 */
--color-success: #2E9B68; /* 白底 3.50:1 —— 仅图形 */
--color-success-strong: #1F7A4F; /* 白底 5.31:1 */
--color-success-soft: #EDF8F1;
--color-warning: #E59A32; /* 白底 2.33:1 —— 仅图形 */
--color-warning-strong: #8F5D0F; /* 白底 5.61:1 */
--color-warning-soft: #FFF6E6;
--color-danger: #D94A4A; /* 白底 4.18:1 —— 图形 / ≥24px 大字 */
--color-danger-strong: #B8353A; /* 白底 5.82:1 */
--color-danger-soft: #FFF6F6;
--color-info: #4A7CD4; /* 白底 4.10:1 —— 图形 */
--color-info-strong: #3A63B0; /* 白底 5.85:1 */
--color-info-soft: #EEF5FF;
--color-purple: #8B63BC;
--color-purple-strong: #6B47A0; /* 白底 6.92:1 */
--color-purple-soft: #F4EFFF;
--color-neutral: #8A9099;
--color-neutral-strong: #656C79; /* 白底 5.28:1 */
--color-neutral-soft: #F4F5F7;
/* ---------------- 文字 ---------------- */
--color-text-primary: #1F2328; /* 白底 15.80:1 */
--color-text-secondary: #555555; /* 白底 7.46:1 */
--color-text-tertiary: #676E7B; /* 白底 5.13:1;浅底 #F5F6F8 上 4.74:1(原 #888 仅 3.54:1) */
--color-text-placeholder: #8A9099; /* 3.25:1:占位符按惯例弱于正文一档(EP 默认 2.5:1) */
--color-text-disabled: #B9BEC7;
--color-text-inverse: #FFFFFF;
--color-text-on-dark: #D9E1E6; /* 侧栏深底 10.62:1 */
--color-text-on-dark-muted: #8FA1AC; /* 侧栏深底 5.26:1 */
--color-icon-muted: #D7DCE0; /* 空态大图标等纯装饰 */
/* ---------------- 背景 ---------------- */
--color-bg-primary: #FFFFFF;
--color-bg-secondary: #FFF8F7;
--color-bg-page: #FAFAF8;
--color-border: #E8E5E2;
--color-border-light: #F0EDE9;
--color-bg-page: #F7F9FB;
--color-bg-secondary: #FAFAFB;
--color-bg-subtle: #F5F6F8;
--color-bg-input: #F6F7F8;
--color-bg-overlay: rgba(255, 255, 255, 0.86);
--color-bg-overlay-strong: rgba(255, 255, 255, 0.96);
/* ---------------- 边框 ---------------- */
--color-border: #E4E7EB;
--color-border-light: #EDF0F2;
--color-border-hairline: #F2F3F4;
--color-border-strong: #D7DCE0;
--color-border-danger: #F3CCCC;
/* ---------------- 侧栏(深底) ---------------- */
--admin-sidebar-bg: #202D38;
--admin-sidebar-bg-deep: #192733;
--admin-sidebar-gradient: linear-gradient(180deg, #202D38 0%, #192733 100%);
--admin-sidebar-surface: #2B3A46;
--admin-sidebar-text: #D9E1E6;
--admin-sidebar-muted: #8FA1AC;
--admin-sidebar-hairline: rgba(255, 255, 255, 0.08);
--admin-sidebar-hairline-soft: rgba(255, 255, 255, 0.06);
/* ---------------- 壳与栅格 ---------------- */
--admin-shell-bg: rgba(255, 255, 255, 0.96);
--admin-header-bg: #FFFFFF;
--admin-shell-radius: 22px;
--admin-card-radius: 14px;
--admin-shell-max-width: 1840px;
--admin-page-max-width: 1440px;
--admin-content-max-width: 1500px;
--sidebar-width: 220px;
--sidebar-width-collapsed: 76px;
--header-height: 64px;
/* ---------------- 圆角 ---------------- */
--border-radius-xs: 6px;
--border-radius-sm: 8px;
--border-radius-md: var(--admin-card-radius);
--border-radius-md: 14px;
--border-radius-lg: 16px;
--border-radius-xl: 18px;
--border-radius-pill: 999px;
/* ---------------- 阴影 ---------------- */
--shadow-panel: 0 8px 20px rgba(48, 58, 67, 0.035);
--shadow-card: 0 8px 24px rgba(75, 55, 46, 0.06);
--el-color-primary: var(--color-primary);
--shadow-raise: 0 10px 24px rgba(68, 48, 55, 0.1);
--shadow-brand: 0 8px 18px rgba(200, 74, 83, 0.26);
--admin-shadow: 0 18px 48px rgba(57, 40, 45, 0.14);
/* ---------------- 排版刻度 ----------------
正文 14px 起;角标/计数下限 11px;元信息下限 12px。 */
--font-family-base: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
--font-family-brand: KaiTi, STKaiti, serif; /* 品牌书法体:仅装饰性标语 */
--font-size-micro: 11px;
--font-size-xs: 12px;
--font-size-sm: 13px;
--font-size-base: 14px;
--font-size-md: 15px;
--font-size-lg: 16px;
--font-size-xl: 18px;
--font-size-2xl: 20px;
--font-size-3xl: 24px;
--font-size-4xl: 28px;
--font-size-5xl: 32px;
--font-size-display: 38px;
--font-line-tight: 1.2;
--font-line-snug: 1.35;
--font-line-base: 1.6;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* ---------------- 间距 ---------------- */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
/* ---------------- 动效 ---------------- */
--motion-fast: 140ms;
--motion-normal: 200ms;
--motion-slow: 320ms;
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--press-scale: 0.985;
--press-opacity: 0.88;
/* ---------------- 交互热区 ----------------
桌面端按下限为 WCAG 2.5.8(AA) 的 24×24px;本项目常规控件取 32px(舒适),
主导航行取 44px(兼顾触屏笔记本)。不套用移动端 44px 全量规则,
否则会把管理端的单屏信息密度牺牲掉。 */
--tap-min: 44px;
--tap-control: 32px;
--tap-icon: 36px;
/* ---------------- tone 色调组(工作台指标卡 / 待办图标) ----------------
每组三档:--tone-x 用于图标与图形;--tone-x-ink 用于文字(压 --tone-x-soft
实算均 ≥4.6:1);--tone-x-soft 为浅底。 */
--tone-rose: #F45F68; --tone-rose-ink: var(--admin-coral-strong); --tone-rose-soft: #FFF0F1;
--tone-amber: #E59A32; --tone-amber-ink: var(--color-warning-strong); --tone-amber-soft: #FFF6E6;
--tone-blue: #4A7CD4; --tone-blue-ink: var(--color-info-strong); --tone-blue-soft: #EEF5FF;
--tone-green: #2E9B68; --tone-green-ink: var(--color-success-strong); --tone-green-soft: #EDF8F1;
--tone-purple: #8B63BC; --tone-purple-ink: var(--color-purple-strong); --tone-purple-soft: #F4EFFF;
--tone-slate: #8A9099; --tone-slate-ink: var(--color-neutral-strong); --tone-slate-soft: #F4F5F7;
--tone-red: #D94A4A; --tone-red-ink: var(--color-danger-strong); --tone-red-soft: #FFF6F6;
/* 兼容既有引用名 */
--admin-soft-pink: var(--admin-coral-soft);
--admin-soft-blue: var(--color-info-soft);
--admin-soft-green: var(--color-success-soft);
--admin-soft-purple: var(--color-purple-soft);
/* ==========================================================================
Element Plus 联动
EP 的 --el-color-{t}-light-{i} / -dark-2 / -rgb 由 SCSS 在**编译期**按默认色
静态生成,运行时改 --el-color-primary 不会带动它们(开关、滑块、焦点环、
分页、标签因此仍是默认蓝)。这里按 EP 的派生公式补全:
light-{i} = mix(#FFFFFF, base, i*10%) i ∈ 1..9
dark-2 = mix(#000000, base, 20%)
基色取 -strong / -solid 档 —— 既能当文字色也能做实心底,使 EP 全组件与品牌一致。
info 保持中性灰:EP 的 info 语义是「中性/未知」,不应变成品牌红。
========================================================================== */
--el-color-primary: #C84A53;
--el-color-primary-rgb: 200, 74, 83;
--el-color-primary-light-3: #d98087;
--el-color-primary-light-5: #e4a5a9;
--el-color-primary-light-7: #efc9cb;
--el-color-primary-light-8: #f4dbdd;
--el-color-primary-light-9: #faedee;
--el-color-primary-dark-2: #a03b42;
--el-color-success: #1F7A4F;
--el-color-success-rgb: 31, 122, 79;
--el-color-success-light-3: #62a284;
--el-color-success-light-5: #8fbda7;
--el-color-success-light-7: #bcd7ca;
--el-color-success-light-8: #d2e4dc;
--el-color-success-light-9: #e9f2ed;
--el-color-success-dark-2: #19623f;
--el-color-warning: #8F5D0F;
--el-color-warning-rgb: 143, 93, 15;
--el-color-warning-light-3: #b18e57;
--el-color-warning-light-5: #c7ae87;
--el-color-warning-light-7: #ddceb7;
--el-color-warning-light-8: #e9dfcf;
--el-color-warning-light-9: #f4efe7;
--el-color-warning-dark-2: #724a0c;
--el-color-danger: #B8353A;
--el-color-danger-rgb: 184, 53, 58;
--el-color-danger-light-3: #cd7275;
--el-color-danger-light-5: #dc9a9d;
--el-color-danger-light-7: #eac2c4;
--el-color-danger-light-8: #f1d7d8;
--el-color-danger-light-9: #f8ebeb;
--el-color-danger-dark-2: #932a2e;
--el-color-info: #656C79;
--el-color-info-rgb: 101, 108, 121;
--el-color-info-light-3: #9398a1;
--el-color-info-light-5: #b2b6bc;
--el-color-info-light-7: #d1d3d7;
--el-color-info-light-8: #e0e2e4;
--el-color-info-light-9: #f0f0f2;
--el-color-info-dark-2: #515661;
/* EP 中性色(文字 / 边框 / 填充)接到同一套 token,避免表格、输入框另成一套灰 */
--el-text-color-primary: var(--color-text-primary);
--el-text-color-regular: var(--color-text-secondary);
--el-text-color-secondary: var(--color-text-tertiary);
--el-text-color-placeholder: var(--color-text-placeholder);
--el-text-color-disabled: var(--color-text-disabled);
--el-border-color: var(--color-border);
--el-border-color-light: var(--color-border-light);
--el-border-color-lighter: var(--color-border-light);
--el-border-color-extra-light: var(--color-border-hairline);
--el-fill-color: var(--color-bg-subtle);
--el-fill-color-light: var(--color-bg-secondary);
--el-fill-color-lighter: var(--color-bg-secondary);
--el-bg-color: var(--color-bg-primary);
--el-bg-color-page: var(--color-bg-page);
--el-border-radius-base: var(--border-radius-sm);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body, #app {
width: 100%;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
font-size: 14px;
font-family: var(--font-family-base);
font-size: var(--font-size-base);
color: var(--color-text-primary);
background-color: var(--color-bg-page);
}
/* 管理端是桌面密度布局,1100px 以下由 MainLayout 的 .admin-scene 承担横向滚动。 */
body { min-width: 1100px; }
/* <small> 的浏览器默认样式是 font-size: smaller(0.833em),会按父级字号再缩一档:
父级 12px 时实际渲染 10px、11px 时渲染 9.17px —— 静默跌破 11px 下限,
且源码里看不到任何非法字号,静态检查抓不到(2026-09-22 由渲染态实测发现)。
这里统一改为继承父级字号,需要更小的场景必须显式声明 token 字号(守卫会校验)。 */
small { font-size: inherit; }
button:focus-visible,
a:focus-visible,
input:focus-visible,
[tabindex]:focus-visible {
outline: 3px solid rgba(238, 102, 107, 0.32);
outline: 3px solid var(--admin-coral-border);
outline-offset: 2px;
}
/* ---------------- Element Plus 组件级覆盖 ---------------- */
/* EP 默认 hover 是把主色「加白」(白字对比度随之掉到 2.2:1)。这里改成更深一档,
既保住白字可读性(5.33:1),hover 反馈也更明确。 */
.el-button--primary {
--el-button-bg-color: var(--color-primary);
--el-button-border-color: var(--color-primary);
--el-button-hover-bg-color: var(--color-primary-hover);
--el-button-hover-border-color: var(--color-primary-hover);
--el-button-bg-color: var(--admin-coral-solid);
--el-button-border-color: var(--admin-coral-solid);
--el-button-hover-bg-color: var(--admin-coral-solid-hover);
--el-button-hover-border-color: var(--admin-coral-solid-hover);
--el-button-active-bg-color: var(--el-color-primary-dark-2);
--el-button-active-border-color: var(--el-color-primary-dark-2);
}
.el-radio-group .el-radio-button__inner:checked {
background-color: var(--color-primary);
border-color: var(--color-primary);
background-color: var(--admin-coral-solid);
border-color: var(--admin-coral-solid);
}
.el-card { --el-card-border-radius: var(--border-radius-md); --el-card-border-color: var(--color-border-light); }
.el-table { --el-table-header-bg-color: #FBFAF8; --el-table-border-color: var(--color-border-light); }
.el-tag { border-radius: 999px; }
.page-container { padding: 24px; }
.page-shell { max-width: 1440px; margin: 0 auto; }
.page-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
.page-heading h1 { font-size: 24px; line-height: 1.35; font-weight: 650; }
.el-card {
--el-card-border-radius: var(--border-radius-md);
--el-card-border-color: var(--color-border-light);
}
.el-table {
--el-table-header-bg-color: var(--color-bg-secondary);
--el-table-border-color: var(--color-border-light);
}
.el-tag { border-radius: var(--border-radius-pill); }
/* ---------------- 通用版式 ---------------- */
.page-container { padding: var(--space-6); }
.page-shell { max-width: var(--admin-page-max-width); margin: 0 auto; }
.page-heading {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: var(--space-5);
}
.page-heading h1 {
font-size: var(--font-size-3xl);
line-height: var(--font-line-snug);
font-weight: var(--font-weight-semibold);
}
.page-heading p { margin-top: 6px; color: var(--color-text-tertiary); }
.surface-card { background: var(--color-bg-primary); border: 1px solid var(--color-border-light); border-radius: var(--border-radius-md); box-shadow: var(--shadow-card); }
.surface-card {
background: var(--color-bg-primary);
border: 1px solid var(--color-border-light);
border-radius: var(--border-radius-md);
box-shadow: var(--shadow-card);
}
/* 语义文字色工具类(比在页面里各写一遍 color: #xxx 更不容易漂移) */
.text-primary { color: var(--color-text-primary); }
.text-secondary { color: var(--color-text-secondary); }
.text-tertiary { color: var(--color-text-tertiary); }
.mt-sm { margin-top: 8px; }
.mt-md { margin-top: 16px; }
.mt-lg { margin-top: 24px; }
.mb-sm { margin-bottom: 8px; }
.mb-md { margin-bottom: 16px; }
.mb-lg { margin-bottom: 24px; }
.text-muted { color: var(--color-text-tertiary); }
.text-brand { color: var(--admin-coral-strong); }
.text-success { color: var(--color-success-strong); }
.text-warning { color: var(--color-warning-strong); }
.text-danger { color: var(--color-danger-strong); }
/* 语义底色工具类 */
.bg-card { background: var(--color-bg-primary); }
.bg-subtle { background: var(--color-bg-subtle); }
.bg-brand-soft { background: var(--admin-coral-soft); }
/* 间距工具类 */
.mt-sm { margin-top: var(--space-2); }
.mt-md { margin-top: var(--space-4); }
.mt-lg { margin-top: var(--space-6); }
.mb-sm { margin-bottom: var(--space-2); }
.mb-md { margin-bottom: var(--space-4); }
.mb-lg { margin-bottom: var(--space-6); }
.flex-center { display: flex; align-items: center; justify-content: center; }
@media (max-width: 1200px) { body { min-width: 0; } }
@media (max-width: 900px) { .page-container { padding: 16px; } .page-heading { align-items: flex-start; flex-direction: column; } }
/* ---------------- 交互反馈 ---------------- */
/* 桌面端控件热区下限:32px(WCAG 2.5.8 AA 为 24px,留出舒适余量) */
.tap-control { min-height: var(--tap-control); min-width: var(--tap-control); }
/* 按压反馈:统一走 token,避免各页面各写一套 scale/opacity */
.pressable { transition: transform var(--motion-fast) var(--ease-standard), opacity var(--motion-fast) var(--ease-standard); }
.pressable:active { transform: scale(var(--press-scale)); opacity: var(--press-opacity); }
@media (max-width: 1200px) {
body { min-width: 0; }
}
@media (max-width: 900px) {
.page-container { padding: var(--space-4); }
.page-heading { align-items: flex-start; flex-direction: column; }
}
+77
View File
@@ -0,0 +1,77 @@
/**
* 管理端调色板常量(TS 侧)。
*
* 为什么需要这个文件:
* 部分模板用 `:style="{ backgroundColor: stage.color + '15' }"` 把 6 位 hex 拼成
* 8 位带 alpha 的颜色('#409EFF' + '15' → '#409EFF15',即 8% 不透明度)。
* 这类位置**不能**用 CSS var —— `'var(--x)' + '15'` 是无效值,浏览器会整条丢弃。
* 于是「色值只有一处事实源」这条规则在 TS 侧会漏。本文件补上这个缺口。
*
* 单一事实源约定:
* 键名就是 src/theme/index.css 里的 var 名,值必须与之一致。
* 门控脚本 scripts/lint-admin-tokens.mjs 会逐项比对,任一侧改了而另一侧没改即失败。
*/
export const TOKEN_HEX: Record<string, string> = {
'--color-primary': '#F45F68',
'--color-primary-strong': '#C0414A',
'--color-primary-solid': '#C84A53',
'--color-success': '#2E9B68',
'--color-success-strong': '#1F7A4F',
'--color-warning': '#E59A32',
'--color-warning-strong': '#8F5D0F',
'--color-danger': '#D94A4A',
'--color-danger-strong': '#B8353A',
'--color-info': '#4A7CD4',
'--color-info-strong': '#3A63B0',
'--color-purple': '#8B63BC',
'--color-purple-strong': '#6B47A0',
'--color-neutral': '#8A9099',
'--color-neutral-strong': '#656C79',
'--color-text-primary': '#1F2328',
'--color-text-secondary': '#555555',
'--color-text-tertiary': '#676E7B',
'--color-bg-page': '#F7F9FB',
'--color-border': '#E4E7EB',
}
/**
* 取 token 的 hex 字面量。用于 :style 绑定(含 hex 拼接透明度的场景)。
* @example token('--color-success') // '#2E9B68'
*/
export function token(name: string): string {
const value = TOKEN_HEX[name]
if (!value) throw new Error(`未登记的 token: ${name}(请先加入 TOKEN_HEX 并同步 theme/index.css)`)
return value
}
/**
* 生成带 alpha 的 8 位 hex。alpha 传 2 位十六进制('15' = 21/255 ≈ 8%)。
* 与 `color + '15'` 等价,但把「必须 6 位 hex」这个约束显式化,避免误把 var() 传进来。
*/
export function tint(hex: string, alphaHex: string): string {
if (!/^#[0-9a-fA-F]{6}$/.test(hex)) {
throw new Error(`tint() 需要 6 位 hex(不能是 var() 或 3/8 位 hex):${hex}`)
}
return `${hex}${alphaHex}`
}
/** 指标卡 / 待办项的色调组,与 theme/index.css 的 --tone-* 一一对应。 */
export interface Tone {
/** 图标与图形用的鲜亮色 */
vivid: string
/** 文字用的可读色(压 soft 底 ≥4.5:1) */
ink: string
/** 浅底 */
soft: string
}
export const TONES: Record<string, Tone> = {
rose: { vivid: '#F45F68', ink: '#C0414A', soft: '#FFF0F1' },
amber: { vivid: '#E59A32', ink: '#8F5D0F', soft: '#FFF6E6' },
blue: { vivid: '#4A7CD4', ink: '#3A63B0', soft: '#EEF5FF' },
green: { vivid: '#2E9B68', ink: '#1F7A4F', soft: '#EDF8F1' },
purple: { vivid: '#8B63BC', ink: '#6B47A0', soft: '#F4EFFF' },
slate: { vivid: '#8A9099', ink: '#656C79', soft: '#F4F5F7' },
red: { vivid: '#D94A4A', ink: '#B8353A', soft: '#FFF6F6' },
}
+130 -109
View File
@@ -283,9 +283,9 @@ onMounted(retryLoad)
<style scoped>
.dashboard-page {
min-width: 0;
max-width: 1500px;
max-width: var(--admin-content-max-width);
margin: 0 auto;
padding: 20px 26px 28px;
padding: var(--space-5) 26px 28px;
}
.dashboard-welcome {
@@ -293,33 +293,34 @@ onMounted(retryLoad)
min-height: 66px;
align-items: center;
justify-content: space-between;
gap: 20px;
gap: var(--space-5);
}
.dashboard-welcome h1 {
font-size: 24px;
line-height: 1.25;
font-size: var(--font-size-3xl);
line-height: var(--font-line-snug);
}
.dashboard-welcome p {
margin-top: 6px;
color: #78828b;
font-size: 13px;
color: var(--color-text-tertiary);
font-size: var(--font-size-sm);
}
.dashboard-date {
display: flex;
align-items: center;
gap: 14px;
color: #7b858e;
font-size: 12px;
color: var(--color-text-tertiary);
font-size: var(--font-size-xs);
}
.dashboard-date small {
padding: 6px 10px;
color: #d75a63;
background: #fff0f1;
border-radius: 999px;
color: var(--admin-coral-strong);
background: var(--admin-coral-soft);
border-radius: var(--border-radius-pill);
font-size: var(--font-size-micro);
}
.dashboard-loading,
@@ -335,7 +336,7 @@ onMounted(retryLoad)
.metric-skeleton {
height: 105px;
border-radius: 12px;
border-radius: var(--border-radius-md);
}
.dashboard-error,
@@ -343,14 +344,14 @@ onMounted(retryLoad)
.dashboard-notice {
display: flex;
min-height: 48px;
margin: 8px 0 12px;
margin: var(--space-2) 0 var(--space-3);
padding: 10px 14px;
align-items: center;
gap: 10px;
color: #6f5355;
background: #fff6f6;
border: 1px solid #f7dfe1;
border-radius: 11px;
color: var(--color-danger-strong);
background: var(--color-danger-soft);
border: 1px solid var(--color-border-danger);
border-radius: var(--border-radius-sm);
}
.dashboard-error > span,
@@ -363,7 +364,8 @@ onMounted(retryLoad)
.dashboard-error small,
.dashboard-forbidden small {
margin-top: 3px;
color: #8c7d7e;
color: var(--color-text-tertiary);
font-size: var(--font-size-micro);
}
.dashboard-metrics {
@@ -375,33 +377,40 @@ onMounted(retryLoad)
display: grid;
min-width: 0;
min-height: 108px;
padding: 14px 12px;
padding: 14px var(--space-3);
grid-template-columns: 38px 1fr;
align-items: center;
gap: 9px;
overflow: hidden;
text-align: left;
cursor: pointer;
background: #fff;
border: 1px solid rgba(235, 235, 238, 0.9);
border-radius: 12px;
transition: transform 0.18s ease, box-shadow 0.18s ease;
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-md);
transition: transform var(--motion-normal) var(--ease-standard),
box-shadow var(--motion-normal) var(--ease-standard);
}
.metric-card:hover {
box-shadow: 0 10px 24px rgba(68, 48, 55, 0.1);
box-shadow: var(--shadow-raise);
transform: translateY(-2px);
}
.metric-card:active {
transform: translateY(0) scale(var(--press-scale));
}
.metric-card.unavailable {
cursor: default;
opacity: 0.72;
}
/* 图标字形属「有意义的非文本内容」,按 WCAG 需 ≥3:1。
原先用鲜亮色调压同色系浅底(amber 仅 2.2:1),统一改用 -ink 档(≥4.6:1)。 */
.metric-icon,
.todo-icon {
display: grid;
color: var(--tone-color);
color: var(--tone-ink);
background: var(--tone-soft);
border-radius: 50%;
place-items: center;
@@ -410,7 +419,7 @@ onMounted(retryLoad)
.metric-icon {
width: 38px;
height: 38px;
font-size: 19px;
font-size: var(--font-size-xl);
}
.metric-copy {
@@ -421,24 +430,25 @@ onMounted(retryLoad)
.metric-copy small {
overflow: hidden;
color: #55616a;
font-size: 11px;
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
text-overflow: ellipsis;
white-space: nowrap;
}
.metric-copy strong {
margin-top: 3px;
color: #171a1d;
font-size: 25px;
color: var(--color-text-primary);
font-size: var(--font-size-3xl);
line-height: 1;
}
/* 原为 9px(低于可读下限),提到 11px */
.metric-copy em {
margin-top: 6px;
overflow: hidden;
color: var(--tone-color);
font-size: 9px;
color: var(--tone-ink);
font-size: var(--font-size-micro);
font-style: normal;
text-overflow: ellipsis;
white-space: nowrap;
@@ -446,18 +456,20 @@ onMounted(retryLoad)
.metric-arrow {
position: absolute;
right: 8px;
bottom: 8px;
color: var(--tone-color);
font-size: 11px;
right: var(--space-2);
bottom: var(--space-2);
color: var(--tone-ink);
font-size: var(--font-size-micro);
}
.tone-rose { --tone-color: #f05e69; --tone-soft: #ffe9eb; }
.tone-amber { --tone-color: #e58b39; --tone-soft: #fff1df; }
.tone-blue { --tone-color: #568ce9; --tone-soft: #eaf2ff; }
.tone-green { --tone-color: #4aad76; --tone-soft: #e9f7ef; }
.tone-purple { --tone-color: #9c69df; --tone-soft: #f2eaff; }
.tone-red { --tone-color: #ed5b64; --tone-soft: #ffe9eb; }
/* 色调组改为引用 theme/index.css 的 --tone-*-ink / --tone-*-soft。
--tone-ink 是本组件内的局部变量(由下面各 tone-* 类赋值),供图标与文字统一取用。 */
.tone-rose { --tone-ink: var(--tone-rose-ink); --tone-soft: var(--tone-rose-soft); }
.tone-amber { --tone-ink: var(--tone-amber-ink); --tone-soft: var(--tone-amber-soft); }
.tone-blue { --tone-ink: var(--tone-blue-ink); --tone-soft: var(--tone-blue-soft); }
.tone-green { --tone-ink: var(--tone-green-ink); --tone-soft: var(--tone-green-soft); }
.tone-purple { --tone-ink: var(--tone-purple-ink); --tone-soft: var(--tone-purple-soft); }
.tone-red { --tone-ink: var(--tone-red-ink); --tone-soft: var(--tone-red-soft); }
.dashboard-grid {
display: grid;
@@ -468,11 +480,11 @@ onMounted(retryLoad)
.dashboard-panel {
min-width: 0;
padding: 14px 16px;
background: #fff;
border: 1px solid #e9edf0;
padding: 14px var(--space-4);
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: var(--admin-card-radius);
box-shadow: 0 8px 20px rgba(48, 58, 67, 0.035);
box-shadow: var(--shadow-panel);
}
.panel-header {
@@ -480,42 +492,49 @@ onMounted(retryLoad)
min-height: 30px;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #f0f1f2;
border-bottom: 1px solid var(--color-border-hairline);
}
.panel-header h2 {
padding-bottom: 10px;
font-size: 16px;
font-size: var(--font-size-lg);
}
.panel-header h2 span {
display: inline-grid;
min-width: 20px;
height: 20px;
min-width: var(--space-5);
height: var(--space-5);
margin-left: 6px;
padding: 0 5px;
color: #fff;
font-size: 10px;
line-height: 20px;
background: var(--admin-coral);
border-radius: 999px;
color: var(--color-text-inverse);
font-size: var(--font-size-micro);
line-height: var(--space-5);
background: var(--admin-coral-solid);
border-radius: var(--border-radius-pill);
place-items: center;
}
.panel-header button {
display: flex;
min-height: var(--tap-control);
align-items: center;
gap: 4px;
color: #89929a;
font-size: 11px;
gap: var(--space-1);
color: var(--color-text-tertiary);
font-size: var(--font-size-xs);
cursor: pointer;
background: transparent;
border: 0;
border-radius: var(--border-radius-xs);
transition: color var(--motion-fast) var(--ease-standard);
}
.panel-header button:hover {
color: var(--admin-coral-strong);
}
.panel-header > span {
color: #98a0a7;
font-size: 10px;
color: var(--color-text-tertiary);
font-size: var(--font-size-micro);
}
.todo-list,
@@ -535,12 +554,13 @@ onMounted(retryLoad)
cursor: pointer;
background: transparent;
border: 0;
border-bottom: 1px solid #f2f3f4;
border-bottom: 1px solid var(--color-border-hairline);
transition: background var(--motion-fast) var(--ease-standard);
}
.todo-row:hover,
.activity-row:hover {
background: #fffafa;
background: var(--color-bg-secondary);
}
.todo-icon {
@@ -560,8 +580,8 @@ onMounted(retryLoad)
.todo-copy strong,
.activity-copy strong {
overflow: hidden;
color: #30373d;
font-size: 12px;
color: var(--color-text-primary);
font-size: var(--font-size-xs);
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -570,22 +590,22 @@ onMounted(retryLoad)
.activity-copy small {
margin-top: 3px;
overflow: hidden;
color: #939ba2;
font-size: 9px;
color: var(--color-text-tertiary);
font-size: var(--font-size-micro);
text-overflow: ellipsis;
white-space: nowrap;
}
.todo-row > b {
min-width: 29px;
color: var(--admin-coral);
font-size: 13px;
color: var(--admin-coral-strong);
font-size: var(--font-size-sm);
text-align: center;
}
.todo-row > em {
color: var(--admin-coral);
font-size: 10px;
color: var(--admin-coral-strong);
font-size: var(--font-size-micro);
font-style: normal;
}
@@ -594,21 +614,21 @@ onMounted(retryLoad)
height: 38px;
flex: none;
object-fit: cover;
border-radius: 7px;
border-radius: var(--border-radius-xs);
}
.activity-status {
padding: 3px 6px;
color: #318b5a;
font-size: 9px;
background: #eaf7ef;
border-radius: 999px;
color: var(--color-success-strong);
font-size: var(--font-size-micro);
background: var(--color-success-soft);
border-radius: var(--border-radius-pill);
}
.activity-row > em {
min-width: 76px;
color: #7d878f;
font-size: 9px;
color: var(--color-text-tertiary);
font-size: var(--font-size-micro);
font-style: normal;
text-align: right;
}
@@ -620,47 +640,47 @@ onMounted(retryLoad)
justify-content: center;
flex-direction: column;
gap: 10px;
color: #a0a7ad;
font-size: 11px;
color: var(--color-text-tertiary);
font-size: var(--font-size-xs);
}
.dashboard-empty .el-icon {
color: #d7dce0;
font-size: 34px;
color: var(--color-icon-muted);
font-size: var(--font-size-5xl);
}
.overview-grid {
display: grid;
min-height: 118px;
padding-top: 12px;
padding-top: var(--space-3);
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
gap: var(--space-2);
}
.overview-grid > div {
display: flex;
padding: 12px 10px;
padding: var(--space-3) 10px;
align-items: center;
justify-content: center;
flex-direction: column;
background: var(--tone-soft);
border-radius: 8px;
border-radius: var(--border-radius-sm);
}
.overview-grid small {
color: var(--tone-color);
font-size: 10px;
color: var(--tone-ink);
font-size: var(--font-size-micro);
}
.overview-grid strong {
margin-top: 7px;
font-size: 24px;
font-size: var(--font-size-3xl);
}
.overview-grid em {
margin-top: 7px;
color: #8a9298;
font-size: 9px;
color: var(--color-text-tertiary);
font-size: var(--font-size-micro);
font-style: normal;
}
@@ -675,52 +695,53 @@ onMounted(retryLoad)
grid-template-columns: 92px 1fr 76px;
align-items: center;
gap: 9px;
font-size: 10px;
font-size: var(--font-size-micro);
}
.effectiveness-row > span {
color: #747e87;
color: var(--color-text-tertiary);
}
/* 轨道与填充的对比原为 2.53:1(条几乎看不出进度),改后 4.01:1 */
.effectiveness-row > div {
height: 8px;
height: var(--space-2);
overflow: hidden;
background: #fdebee;
border-radius: 999px;
background: var(--color-primary-light);
border-radius: var(--border-radius-pill);
}
.effectiveness-row i {
display: block;
height: 100%;
background: linear-gradient(90deg, #f56b75, #ef5a65);
background: var(--gradient-brand-bar);
border-radius: inherit;
}
.effectiveness-row strong {
color: #75808a;
font-size: 10px;
font-weight: 500;
color: var(--color-text-tertiary);
font-size: var(--font-size-micro);
font-weight: var(--font-weight-medium);
text-align: right;
}
@media (max-width: 1450px), (max-height: 820px) {
.dashboard-page { padding: 12px 18px 20px; }
.dashboard-welcome { min-height: 54px; }
.dashboard-welcome h1 { font-size: 20px; }
.dashboard-welcome p { margin-top: 3px; font-size: 11px; }
.dashboard-welcome h1 { font-size: var(--font-size-2xl); }
.dashboard-welcome p { margin-top: 3px; font-size: var(--font-size-xs); }
.dashboard-metrics { margin-top: 6px; gap: 7px; }
.metric-card { min-height: 90px; padding: 10px 8px; grid-template-columns: 32px 1fr; gap: 6px; }
.metric-icon { width: 32px; height: 32px; font-size: 16px; }
.metric-copy strong { font-size: 21px; }
.metric-card { min-height: 90px; padding: 10px var(--space-2); grid-template-columns: var(--space-8) 1fr; gap: 6px; }
.metric-icon { width: var(--space-8); height: var(--space-8); font-size: var(--font-size-lg); }
.metric-copy strong { font-size: var(--font-size-2xl); }
.dashboard-grid { margin-top: 9px; gap: 9px; }
.dashboard-panel { padding: 10px 12px; }
.dashboard-panel { padding: 10px var(--space-3); }
.todo-list, .activity-list { min-height: 152px; }
.todo-row, .activity-row { min-height: 38px; padding: 4px; }
.todo-row, .activity-row { min-height: 38px; padding: var(--space-1); }
.activity-row img { width: 58px; height: 31px; }
.dashboard-grid-bottom .dashboard-panel { min-height: 150px; }
.overview-grid, .effectiveness-list { min-height: 98px; }
.overview-grid > div { padding: 8px 6px; }
.overview-grid strong { font-size: 20px; }
.overview-grid > div { padding: var(--space-2) 6px; }
.overview-grid strong { font-size: var(--font-size-2xl); }
}
@media (max-width: 1180px) {
+12 -12
View File
@@ -101,7 +101,7 @@ async function handleLogin() {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(420px, 0.75fr);
min-height: 100vh;
background: #FFFFFF;
background: var(--color-bg-primary);
}
.brand-panel {
@@ -144,28 +144,28 @@ async function handleLogin() {
.brand-title strong {
color: var(--admin-primary);
font-size: 25px;
font-size: var(--font-size-3xl);
letter-spacing: 2px;
}
.brand-title span {
margin-top: 3px;
color: var(--color-text-secondary);
font-size: 12px;
font-size: var(--font-size-xs);
}
.brand-message {
max-width: 590px;
padding: 26px 30px;
background: rgba(255, 255, 255, 0.86);
border: 1px solid rgba(255, 255, 255, 0.94);
background: var(--color-bg-overlay);
border: 1px solid var(--color-bg-overlay-strong);
border-radius: 18px;
backdrop-filter: blur(10px);
}
.brand-message p {
color: var(--admin-primary);
font-weight: 650;
font-weight: var(--font-weight-semibold);
letter-spacing: 3px;
}
@@ -188,7 +188,7 @@ async function handleLogin() {
align-items: center;
justify-content: center;
padding: 48px;
background: #FFFFFF;
background: var(--color-bg-primary);
}
.login-card {
@@ -201,14 +201,14 @@ async function handleLogin() {
.eyebrow {
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;
}
.login-header h2 {
margin-top: 10px;
font-size: 30px;
font-size: var(--font-size-5xl);
}
.login-header p {
@@ -228,7 +228,7 @@ async function handleLogin() {
margin-top: 24px;
padding: 14px 16px;
color: var(--color-text-secondary);
font-size: 12px;
font-size: var(--font-size-xs);
line-height: 1.6;
background: var(--admin-soft-green);
border-radius: 10px;
@@ -243,7 +243,7 @@ async function handleLogin() {
footer {
margin-top: 40px;
color: var(--color-text-tertiary);
font-size: 12px;
font-size: var(--font-size-xs);
}
@media (max-width: 900px) {
File diff suppressed because one or more lines are too long
+9 -1
View File
@@ -31,10 +31,18 @@ const UI_TESTS_DIR = resolve(root, 'ui-tests')
// wxss-component-selector-contracts(组件 wxss 用了标签/ID/属性选择器);
// 17 → 18:新增 miniapp-api-client-behavior(**唯一一组行为断言**:桩掉 wx.request,
// 真跑 api.js,验证成功/失败/幂等键释放/收尾容错 4 组行为)。
// 18 → 19:新增 admin-design-tokens(管理端设计 token 守卫:未定义 token / 硬编码色值 /
// 字号下限 / 非法字重 / EP 调色板残留 / **对比度实算** —— 最后一项是此前
// 完全无人把关的一类,且人工目视很难覆盖 24 个页面)。
// 19 → 20:新增 admin-reactivity-contracts(管理端响应式契约:**非响应式状态**——
// 普通 `const x = [...]` 被直接改元素且绑到 v-for 时,改动了但渲染不更新,
// 界面显示陈旧/默认值。2026-09-23 实测数据统计页漏斗卡恒为 0 即此因。
// 该缺陷无 TS/lint/构建报错,前 19 个守卫全绿也抓不到,只有真跑起来看渲染
// 结果才暴露 → 必须由源码结构断言接管)。
// 新增/删减守卫时同步修改本默认值。
// 可用环境变量 UI_GUARDS_MIN 覆盖 —— 唯一目的是让「数量下限」这条分支本身可被自检
// (`UI_GUARDS_MIN=99 node scripts/run-ui-contracts.mjs` 必须拒绝报绿)。
const MIN_GUARDS = Number(process.env.UI_GUARDS_MIN ?? 18)
const MIN_GUARDS = Number(process.env.UI_GUARDS_MIN ?? 20)
const cliArgs = process.argv.slice(2)
const explicit = cliArgs.length > 0
+412
View File
@@ -0,0 +1,412 @@
#!/usr/bin/env node
/**
* 管理端设计 token 守卫(admin-web)
*
* 为什么需要它:
* admin-web 的样式是「裸变量 + 内联硬编码」的老底子。2026-09-22 审计实测:
* 97 处硬编码颜色 / 67 种色值、88 处硬编码字号(20 处 ≤11px,最低 9px)、
* 6 处非法字重 650、17 处热区 <44px,且 --color-text-tertiary(#888) 白底只有
* 3.54:1、--color-warning(#E59A32) 只有 2.33:1 —— 均不达 WCAG AA 4.5:1。
* 更隐蔽的一类:Element Plus 的 --el-color-*-light-N 是编译期静态生成,
* 只覆盖 --el-color-primary 会让开关/滑块/焦点环/分页/标签仍是 EP 默认蓝。
*
* 本守卫把上面每一类都变成可执行断言。宁可在此处失败,也不要等到上线后
* 靠人工目视发现「有些控件是蓝的」「这行字看不清」。
*
* 八项检查:
* 1) 未定义 token 引用 —— var(--x) 找不到定义(样式会静默失效,最难排查)
* 2) 硬编码色值 —— app 层 style 里出现字面 hex / rgb(a)
* 3) 字号下限 —— 正文 <12px、角标 <11px
* 3b) <small> 字号降级风险 —— 选中 small 的规则未显式声明 font-size 时,
* 浏览器的 UA 默认 `font-size: smaller`(0.833em) 会按父级再缩一档:
* 父级 12px → 实际 10px、11px → 9.17px。源码里看不到任何非法值,
* 静态检查天然抓不到(2026-09-22 由渲染态实测才发现),故单独守卫。
* 3c) 相对单位字号 —— font-size 用 em/%/vw/smaller 等时,实际像素取决于父级与
* 视口,无法用统一下限约束;一律改用 px token。
* 4) 非法字重 —— 非 400/500/600/700(跨平台缺字重会回退,渲染不一致)
* 5) EP 调色板残留 —— EP 默认色阶值不得再出现
* 6) 对比度实算 —— 同一规则块内 color 与 background 均可解析时,按 WCAG
* 相对亮度公式实算,正文 <4.5:1、≥24px 大字 <3:1 判失败
* 另有:tokens.ts ↔ theme/index.css 同名 token 取值必须一致(避免两处漂移)。
*
* 退出码:0 通过;1 有失败项。
*/
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
import { resolve, dirname, relative, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const ADMIN = join(root, 'admin-web')
const SRC = join(ADMIN, 'src')
const THEME = join(SRC, 'theme', 'index.css')
const TOKENS_TS = join(SRC, 'theme', 'tokens.ts')
if (!existsSync(THEME)) {
console.error(`ERROR: 找不到设计系统底座 ${THEME}`)
process.exit(1)
}
// ---------------------------------------------------------------------------
// 工具
// ---------------------------------------------------------------------------
function walk(dir, out = []) {
for (const e of readdirSync(dir, { withFileTypes: true })) {
if (e.name === 'node_modules' || e.name.startsWith('.')) continue
const p = join(dir, e.name)
if (e.isDirectory()) walk(p, out)
else if (/\.(vue|css)$/.test(e.name)) out.push(p)
}
return out
}
function hexToRgb(h) {
let s = h.replace('#', '')
if (s.length === 3) s = s.split('').map((c) => c + c).join('')
return [parseInt(s.slice(0, 2), 16), parseInt(s.slice(2, 4), 16), parseInt(s.slice(4, 6), 16)]
}
function relLuminance(rgb) {
const [r, g, b] = rgb.map((v) => {
const s = v / 255
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
})
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
function contrastRatio(fg, bg) {
const l1 = relLuminance(hexToRgb(fg))
const l2 = relLuminance(hexToRgb(bg))
const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1]
return (hi + 0.05) / (lo + 0.05)
}
const stripComments = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '')
// ---------------------------------------------------------------------------
// 1. 解析设计系统底座:token -> 值(递归解 alias)
// ---------------------------------------------------------------------------
const themeRaw = readFileSync(THEME, 'utf8')
const themeCss = stripComments(themeRaw)
const declMap = new Map() // name -> raw value
for (const m of themeCss.matchAll(/(--[a-zA-Z0-9-]+)\s*:\s*([^;]+);/g)) {
// 同名重复声明时后写覆盖(CSS 语义)
declMap.set(m[1], m[2].trim())
}
const resolveCache = new Map()
/**
* 解析一个 token 为 { kind: 'color'|'gradient'|'other', hex? }
*/
function resolveToken(name, localMap = null, seen = new Set()) {
if (seen.has(name)) return { kind: 'other' }
seen.add(name)
const cacheKey = localMap ? null : name
if (cacheKey && resolveCache.has(cacheKey)) return resolveCache.get(cacheKey)
let raw = localMap?.get(name) ?? declMap.get(name)
if (raw == null || !raw) return { kind: 'other' }
const result = resolveValue(raw, localMap, seen)
if (cacheKey) resolveCache.set(cacheKey, result)
return result
}
function resolveValue(raw, localMap, seen = new Set()) {
const v = raw.trim()
const ref = /^var\(\s*(--[a-zA-Z0-9-]+)\s*\)$/.exec(v)
if (ref) return resolveToken(ref[1], localMap, seen)
if (/gradient\(/i.test(v)) return { kind: 'gradient' }
if (/^#[0-9a-fA-F]{3,8}$/.test(v)) {
return v.length === 9 ? { kind: 'gradient' } : { kind: 'color', hex: v.toUpperCase() }
}
const rgba = /^rgba?\(\s*([0-9.]+)[ ,]+([0-9.]+)[ ,]+([0-9.]+)(?:[ ,/]+([0-9.]+%?))?\s*\)$/.exec(v)
if (rgba) {
const a = rgba[4]
if (a && a !== '1' && a !== '100%') return { kind: 'other' } // 半透明:无法判定实际底
const toHex = (n) => Math.round(Number(n)).toString(16).padStart(2, '0')
return { kind: 'color', hex: `#${toHex(rgba[1])}${toHex(rgba[2])}${toHex(rgba[3])}`.toUpperCase() }
}
return { kind: 'other' }
}
// ---------------------------------------------------------------------------
// 2. 文件清单与 <style> 区域
// ---------------------------------------------------------------------------
const files = walk(SRC)
const rel = (p) => relative(root, p).replace(/\\/g, '/')
const themeRel = rel(THEME)
function styleRegions(file) {
const src = readFileSync(file, 'utf8')
if (file.endsWith('.css')) return [{ css: src, offset: 0, src }]
const regions = []
for (const m of src.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g)) {
regions.push({ css: m[1], offset: m.index + m[0].indexOf(m[1]), src })
}
// 模板里内联 style="..." / :style="..." 的字面色值
const tpl = src.replace(/<style[\s\S]*?<\/style>/g, '')
for (const m of tpl.matchAll(/:?style="([^"]*)"/g)) {
regions.push({ css: m[1], offset: m.index + m[0].indexOf(m[1]), src, inline: true })
}
return regions
}
const lineOf = (src, abs) => src.slice(0, abs).split('\n').length
// ---------------------------------------------------------------------------
// 3. 逐项检查
// ---------------------------------------------------------------------------
const failures = []
const notes = []
const fail = (file, line, kind, msg) => failures.push({ file, line, kind, msg })
// 每文件的局部自定义属性(:deep 之后的选择器块内赋值,如 --tone-ink)
function localPropsOf(file) {
const src = readFileSync(file, 'utf8')
const map = new Map()
for (const m of src.matchAll(/(--[a-zA-Z0-9-]+)\s*:\s*([^;{}]+);/g)) {
if (declMap.has(m[1])) continue // 全局同名以全局为准
map.set(m[1], m[2].trim())
}
return map
}
const FLOOR_BODY = 12
const FLOOR_BADGE = 11
const BADGE_HINT = /(badge|dot|count|tag|chip|mark|indicator|arrow|caret|icon|star|pill)/i
const EP_PALETTE = new Set(
[
'#409eff', '#79bbff', '#a0cfff', '#c6e2ff', '#d9ecff', '#ecf5ff', '#337ecc',
'#67c23a', '#95d475', '#b3e19d', '#d1edc4', '#e1f3d8', '#f0f9eb', '#529b2e',
'#e6a23c', '#eebe77', '#f3d19e', '#f8e3c5', '#faeecd', '#fdf6ec', '#b88230',
'#f56c6c', '#f89898', '#fab6b6', '#fcd3d3', '#fde2e2', '#fef0f0', '#c45656',
'#909399', '#a6a9ad', '#b1b3b8', '#c8c9cc', '#dcdde0', '#f4f4f5', '#73767a',
].map((c) => c.toUpperCase()),
)
const LEGAL_WEIGHTS = new Set(['400', '500', '600', '700'])
const shadowOnlyRgba = [] // 观察项:阴影里的 rgba(有 token 更佳,但不阻塞)
let undefinedRefs = 0
let hardcodedColors = 0
let tinyFonts = 0
let badWeights = 0
let uaShrinkRisks = 0
let relativeFonts = 0
let epLeaks = 0
let contrastChecks = 0
let contrastSkipped = 0
const contrastSkips = []
for (const file of files) {
const r = rel(file)
if (r === themeRel) continue // 底座是 token 定义处,允许写具体值
const localProps = localPropsOf(file)
for (const region of styleRegions(file)) {
const css = stripComments(region.css)
// --- 1) 未定义 token ---
for (const m of css.matchAll(/var\(\s*(--[a-zA-Z0-9-]+)/g)) {
const name = m[1]
if (name.startsWith('--el-')) continue // Element Plus 运行时/第三方
if (declMap.has(name) || localProps.has(name)) continue
undefinedRefs++
fail(r, region.inline ? undefined : lineOf(region.src, region.offset + m.index), '未定义 token',
`var(${name}) 无定义 —— 该声明会被浏览器整条丢弃`)
}
// --- 2) 硬编码色值 ---
for (const m of css.matchAll(/(#[0-9a-fA-F]{3,8})\b/g)) {
const hex = m[1].toUpperCase()
hardcodedColors++
const isEp = EP_PALETTE.has(hex)
if (isEp) epLeaks++
fail(r, region.inline ? undefined : lineOf(region.src, region.offset + m.index), '硬编码色值',
`${m[1]}${isEp ? ' ← Element Plus 默认调色板残留' : ''}(应改用 var(--token))`)
}
for (const m of css.matchAll(/(^|[;{\s:])((?:color|background(?:-color)?|border(?:-[a-z]+)?|fill|stroke)\s*:\s*[^;{}]*?\brgba?\([^)]*\))/g)) {
hardcodedColors++
fail(r, region.inline ? undefined : lineOf(region.src, region.offset + m.index), '硬编码色值',
`${m[2].trim()}(应改用 var(--token))`)
}
for (const m of css.matchAll(/box-shadow\s*:\s*([^;{}]*rgba?\([^)]*\))/g)) {
shadowOnlyRgba.push(`${r} ${m[1].trim()}`)
}
// --- 3) 字号下限 / 4) 非法字重 ---
// 按规则块逐个判定,才能拿到「选择器 + 该块内的字号」上下文
for (const block of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
const selector = block[1].replace(/\s+/g, ' ').trim()
const body = block[2]
const fsm = /font-size\s*:\s*([0-9.]+)px/.exec(body)
if (fsm) {
const px = parseFloat(fsm[1])
const isBadge = BADGE_HINT.test(selector)
const floor = isBadge ? FLOOR_BADGE : FLOOR_BODY
if (px < floor) {
tinyFonts++
fail(r, undefined, '字号下限',
`${selector} → font-size:${px}px 低于下限 ${floor}px${isBadge ? '(角标类)' : ''}`)
}
}
for (const wm of body.matchAll(/font-weight\s*:\s*([0-9]{3})/g)) {
if (LEGAL_WEIGHTS.has(wm[1])) continue
badWeights++
fail(r, undefined, '非法字重',
`${selector} → font-weight:${wm[1]}(只允许 400/500/600/700,其余在缺字重平台会回退)`)
}
// --- 3b) <small> 依赖 UA 默认字号会跌破下限(渲染态才可见) ---
// 选择器里出现 small 元素选择器(排除 .small / --small 这类词片段)。
// 注意:判定「有没有声明字号」必须用宽松匹配(token 写法 var(--font-size-x) 不含 px),
// 否则会把已经声明过的规则误报成裸奔。
const declaresFontSize = /font-size\s*:/.test(body)
const targetsSmall = /(^|[\s,>+~])small(\s*$|[\s,>+~:.[])/.test(selector + ' ')
if (targetsSmall && !declaresFontSize) {
uaShrinkRisks++
fail(r, undefined, '<small> 字号降级风险',
`${selector} 未声明 font-size —— <small> 的 UA 默认 smaller(0.833em) 会按父级再缩一档,` +
`实测会落到 10px / 9.17px,跌破 ${FLOOR_BADGE}px 下限`)
}
// --- 3c) 相对单位字号:实际像素取决于父级/视口,无法用统一下限约束 ---
const relFs = /font-size\s*:\s*(?:-?[0-9.]+(?:em|%|ex|ch|vw|vh|vmin|vmax)|smaller|larger|x-small|xx-small|x-large|xx-large)\b/.exec(body)
if (relFs) {
relativeFonts++
fail(r, undefined, '相对单位字号',
`${selector} → font-size:${relFs[1] ?? relFs[0].split(':')[1].trim()}(改用 var(--font-size-*) 像素刻度)`)
}
// --- 6) 对比度实算:仅当同一规则块内同时给出 color 与 background 时才判定 ---
const fgDecl = /(?:^|;)\s*color\s*:\s*([^;{}]+)/.exec(body)
const bgDecl = /(?:^|;)\s*background(?:-color)?\s*:\s*([^;{}]+)/.exec(body)
if (fgDecl && bgDecl) {
const fg = resolveValue(fgDecl[1].trim(), localProps)
const bgRaw = bgDecl[1].trim()
// background 简写里带 url()/center/cover 等时仍可取出首个颜色
const bgPart = /\bvar\([^)]*\)|#[0-9a-fA-F]{3,8}|rgba?\([^)]*\)/.exec(bgRaw)
const bg = bgPart ? resolveValue(bgPart[0], localProps) : { kind: 'other' }
if (fg.kind === 'color' && bg.kind === 'color') {
const ratio = contrastRatio(fg.hex, bg.hex)
const large = fsm ? parseFloat(fsm[1]) >= 24 : false
const need = large ? 3 : 4.5
contrastChecks++
if (ratio < need) {
fail(r, undefined, '对比度',
`${selector} → ${fg.hex} on ${bg.hex} = ${ratio.toFixed(2)}:1,低于 ${need}:1`)
}
} else {
contrastSkipped++
contrastSkips.push(`${r} ${selector} → color:${fgDecl[1].trim()} background:${bgRaw}`)
}
}
}
}
}
// --- 2b) 模板 / 脚本里的硬编码色值 ---
// 典型形态:`<el-progress color="#EE666B">`、TS 数据里的 `color: '#409EFF'`。
// 这类值不会出现在 <style> 里,但同样是「色值漂移」的来源(TS 侧应走 src/theme/tokens.ts)。
for (const file of files) {
const r = rel(file)
if (r === themeRel) continue
const src = readFileSync(file, 'utf8')
const outsideStyle = src
.replace(/<style[\s\S]*?<\/style>/g, '')
.replace(/:?style="[^"]*"/g, '') // 已由上面的内联 style 扫描覆盖,避免重复计数
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/^\s*(\/\/|\*).*$/gm, '') // 去掉行注释,避免注释里的示例色值误报
for (const m of outsideStyle.matchAll(/(#[0-9a-fA-F]{3,8})\b/g)) {
const hex = m[1].toUpperCase()
const isEp = EP_PALETTE.has(hex)
if (isEp) epLeaks++
hardcodedColors++
fail(r, lineOf(src, m.index), '硬编码色值(模板/脚本)',
`${m[1]}${isEp ? ' ← Element Plus 默认调色板残留' : ''}(style 用 var(--token);TS 用 src/theme/tokens.ts 的 token())`)
}
}
// --- 5) EP 色阶只允许在底座里定义 ---
for (const file of files) {
const r = rel(file)
if (r === themeRel) continue
const src = stripComments(readFileSync(file, 'utf8'))
for (const m of src.matchAll(/(--el-color-[a-z0-9-]+)\s*:/g)) {
epLeaks++
fail(r, undefined, 'EP 色阶越权',
`${m[1]} 只应在 src/theme/index.css 定义(散落定义会造成组件配色不一致)`)
}
}
// --- tokens.ts ↔ theme/index.css 一致性 ---
if (existsSync(TOKENS_TS)) {
const ts = readFileSync(TOKENS_TS, 'utf8')
let checked = 0
for (const m of ts.matchAll(/'(--[a-zA-Z0-9-]+)'\s*:\s*'(#[0-9a-fA-F]{3,8})'/g)) {
const [, name, hex] = m
const resolved = resolveToken(name)
checked++
if (resolved.kind !== 'color') {
fail(rel(TOKENS_TS), undefined, 'tokens.ts 漂移', `${name} 在 theme/index.css 中不是可解析的颜色(实际 kind=${resolved.kind})`)
} else if (resolved.hex !== hex.toUpperCase()) {
fail(rel(TOKENS_TS), undefined, 'tokens.ts 漂移',
`${name}: tokens.ts=${hex} 但 theme/index.css=${resolved.hex} —— 两处必须同步`)
}
}
notes.push(`tokens.ts ↔ theme/index.css 一致性校验:${checked} 项`)
}
// ---------------------------------------------------------------------------
// 4. 汇总
// ---------------------------------------------------------------------------
const byKind = new Map()
for (const f of failures) byKind.set(f.kind, (byKind.get(f.kind) || 0) + 1)
console.log('=========== admin-web 设计 token 守卫 ===========')
console.log(`扫描文件: ${files.length}(.vue / .css,排除 node_modules)`)
console.log(`底座 token 数: ${declMap.size}`)
console.log('')
console.log(`1) 未定义 token 引用 : ${undefinedRefs}`)
console.log(`2) 硬编码色值 : ${hardcodedColors}${epLeaks ? `(含 EP 调色板残留 ${epLeaks})` : ''}`)
console.log(`3) 低于下限的字号 : ${tinyFonts}`)
console.log(`3b) <small> 降级风险 : ${uaShrinkRisks}`)
console.log(`3c) 相对单位字号 : ${relativeFonts}`)
console.log(`4) 非法字重 : ${badWeights}`)
console.log(`5) 对比度实算 : ${contrastChecks} 组判定 / ${contrastSkipped} 组跳过(跨选择器变量或渐变)`)
if (shadowOnlyRgba.length) {
console.log(`观察项) 阴影内联 rgba : ${shadowOnlyRgba.length} 处(有对应 token 更佳,暂不阻塞)`)
}
// 跳过项必须可审计:设 ADMIN_GATE_VERBOSE=1 打印明细,确认跳过理由都是
// 「跨选择器变量(如 tone 组)」或「渐变底」,而不是把真正的低对比度放过去了。
if (process.env.ADMIN_GATE_VERBOSE && contrastSkips.length) {
console.log(`跳过明细(${contrastSkips.length}):`)
for (const s of contrastSkips) console.log(` · ${s}`)
}
for (const n of notes) console.log(` · ${n}`)
console.log('')
if (failures.length) {
console.log(`失败项 ${failures.length} 处:`)
const grouped = new Map()
for (const f of failures) {
if (!grouped.has(f.file)) grouped.set(f.file, [])
grouped.get(f.file).push(f)
}
for (const [file, list] of [...grouped.entries()].sort()) {
console.log(`\n--- ${file} (${list.length})`)
for (const f of list.slice(0, 25)) {
console.log(` ${f.line ? `L${f.line} ` : ''}[${f.kind}] ${f.msg}`)
}
if (list.length > 25) console.log(` … 另有 ${list.length - 25} 处`)
}
console.log('')
console.log(`RESULT: FAIL —— ${[...byKind.entries()].map(([k, v]) => `${k} ${v}`).join(' / ')}`)
process.exit(1)
}
console.log('RESULT: PASS —— 设计 token 已全部落地,无硬编码色值 / 无越界字号 / 对比度达标')
+240
View File
@@ -0,0 +1,240 @@
#!/usr/bin/env node
/**
* 管理端响应式契约守卫(admin-web)
*
* 为什么需要它:
* 2026-09-23 渲染态实测(Edge 无头 + CDP 取计算样式/DOM 文本)发现数据统计页
* **漏斗卡恒显示 0**,而正下方同一张卡里的汇总表却显示 12846 / 9217 / 8462 / 3812
* —— 同一个页面、同一个数据源,两处互相矛盾。
*
* 根因不是取数失败(控制台 0 错误、网络 0 失败):这三组是
* const funnelStages = [ { value: 0, ... } ] // 普通数组,非响应式
* funnelStages[0].value = userData.totalRegistered // 直接改元素
* 而模板读的就是同一个数组 —— **非响应式写入不触发重渲染**,Vue 看不到变化。
* 对比之下,凡走 `ref` 的值(汇总表、V11 区块、日程)全部正常。
*
* 这类缺陷的可怕之处:
* - 源码里完全合法(无 TS 报错、无 lint 报错、构建通过);
* - 19 个既有守卫全绿(都是源码结构断言,看不出「渲染后没更新」);
* - 只有**真跑起来看渲染结果**才暴露 —— 即「全绿 ≠ 有效」的又一例。
* 所以必须把它变成可执行断言:**非响应式声明 + 被当状态写 + 出现在模板里 = 失败**。
*
* 三项检查:
* 1) 非响应式状态(核心)—— 普通 `const x = [...]` / `= {}` 若在 <script> 里被写
* (`x[i] = `、`x.k = `、`.push()/.splice()` 等)且出现在 <template> 中,
* 则渲染不会更新。注意:**未被写过的静态常量清单(如 ACTIVITY_TYPES)是合法的**,
* 初始化后不再变化,不需要响应式 —— 故按「是否被写」区分,不做一刀切。
* 同时排除 `router.push()` 这类**实例方法调用**(判据:只有字面量数组/对象/Map/Set
* 才把 `.push()` 视为状态写入),以及模板局部变量(外层 v-for 解构出来的)。
* 2) 数据统计页同源契约 —— 漏斗卡与汇总表必须取自同一变量(卡片不得硬编码演示数字),
* 且 Phase 20 补齐的 4 个端点不得被删。
* 3) 颜色助手遮蔽 —— 从 @/theme/tokens **非别名**导入 `token` 时,不得再有同名局部
* 变量(`const token = localStorage.getItem('accessToken')` 是本项目常见写法)。
* 若有人在该函数内调用 token('--color-x') 会运行期崩溃。
* 已用 `token as tokenColor` 的文件不算遮蔽(检查会识别别名)。
*
* 实现注意:分析前**必须剥离注释**。否则注释里出现的 `const token = ...` 或
* `x.push(` 会造成误报(本守卫首次运行即被自己的说明注释误伤)。
*
* 退出码:0 通过;1 有失败项(含守卫自身异常)。
*/
import assert from 'node:assert/strict'
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import { join, relative, resolve } from 'node:path'
const root = resolve(import.meta.dirname, '..')
const SRC = resolve(root, 'admin-web/src')
const ANALYTICS = resolve(SRC, 'modules/analytics/AnalyticsView.vue')
assert.ok(existsSync(SRC), `找不到管理端源码目录 ${SRC}`)
// ---------------------------------------------------------------------------
// 工具
// ---------------------------------------------------------------------------
function walk(dir, out = []) {
for (const e of readdirSync(dir, { withFileTypes: true })) {
if (e.name === 'node_modules' || e.name.startsWith('.')) continue
const p = join(dir, e.name)
if (e.isDirectory()) walk(p, out)
else if (e.name.endsWith('.vue')) out.push(p)
}
return out
}
/** 抽 SFC 的根级块。注意嵌套 <template #header> 会让非贪婪匹配提前截断。 */
function block(src, tag, attrs = '') {
const m = src.match(new RegExp(`^<${tag}${attrs}[^>]*>([\\s\\S]*?)^</${tag}>`, 'm'))
return m ? m[1] : null
}
/** 剥离 JS 注释(含字符串感知,避免 comment 里的内容参与判定) */
function stripComments(code) {
let out = ''
let i = 0
let quote = null
while (i < code.length) {
const c = code[i]
const n = code[i + 1]
if (quote) {
if (c === '\\') { out += c + (n ?? ''); i += 2; continue }
if (c === quote) quote = null
out += c; i++; continue
}
if (c === '"' || c === "'" || c === '`') { quote = c; out += c; i++; continue }
if (c === '/' && n === '/') { while (i < code.length && code[i] !== '\n') i++; continue }
if (c === '/' && n === '*') {
i += 2
while (i < code.length && !(code[i] === '*' && code[i + 1] === '/')) i++
i += 2
out += ' '
continue
}
out += c; i++
}
return out
}
const stripHtmlComments = (s) => s.replace(/<!--[\s\S]*?-->/g, '')
const REACTIVE_CTORS =
/^(ref|reactive|computed|shallowRef|shallowReactive|toRef|toRefs|customRef|readonly|markRaw)\s*[(<]/
/** 非别名导入颜色助手 → 存在遮蔽风险 */
const IMPORTS_TOKEN_HELPER = /import\s*\{[^}]*\btoken\b(?!\s+as\b)[^}]*\}\s*from\s*'@\/theme\/tokens'/
// ---------------------------------------------------------------------------
// 扫全量 .vue
// ---------------------------------------------------------------------------
const files = walk(SRC)
let totalVfor = 0
let reactiveOk = 0
let staticConst = 0
let skipped = 0
const violations = []
const shadowed = []
for (const file of files) {
const src = readFileSync(file, 'utf8')
const rel = relative(SRC, file).replace(/\\/g, '/')
const rawScr = block(src, 'script', ' setup')
const rawTpl = block(src, 'template')
if (!rawScr) continue
const scr = stripComments(rawScr)
const tpl = rawTpl ? stripHtmlComments(rawTpl) : null
// --- 3) 颜色助手遮蔽 ---
if (IMPORTS_TOKEN_HELPER.test(scr) && /(?:const|let|var)\s+token\s*=/.test(scr)) {
shadowed.push(rel)
}
if (!tpl) continue
// --- 声明表 ---
const kind = new Map()
const init = new Map()
for (const m of scr.matchAll(/\b(?:const|let)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*([^\n]*)/g)) {
if (kind.has(m[1])) continue
kind.set(m[1], REACTIVE_CTORS.test(m[2].trim()) ? 'reactive' : 'plain')
init.set(m[1], m[2])
}
/**
* 是否在 <script> 里被「当状态写」。声明行本身不算。
* `.push()/.splice()` 这类只有当容器是**字面量数组/对象/Map/Set** 时才算状态写入,
* 否则 `router.push()`(useRouter() 实例方法)会被误判。
*/
const isMutated = (name) => {
const esc = name.replace(/[$]/g, '\\$')
const literal = /^\s*[[{]|^\s*new\s+(?:Map|Set|Array|WeakMap|WeakSet)\b/.test(init.get(name) ?? '')
const mutator = literal
? '|\\\\.(?:push|pop|shift|unshift|splice|sort|reverse|set|add|delete)\\('
: ''
const write = new RegExp(
`^\\s*(?:if|while|for|return|await|void|el|else)?\\s*` +
`${esc}(?:\\[[^\\]]*\\]|\\.[A-Za-z_$][\\w$]*)*` +
`\\s*(?:=[^=]|\\+=|-=|\\+\\+|--${mutator})`,
)
return scr
.split('\n')
.some((l) => !/^\s*(?:const|let|var)\s/.test(l) && write.test(l))
}
/** 模板里是否引用了该标识符(排除属性名/字符串内的误匹配) */
const inTemplate = (name) =>
new RegExp(`(?<![\\w$.'"])${name}(?![\\w$])`).test(tpl)
// --- 1a) v-for 绑定非响应式集合 ---
for (const m of tpl.matchAll(/v-for\s*=\s*"([^"]*)"/g)) {
totalVfor++
const parts = m[1].split(/\s+in\s+|\s+of\s+/)
if (parts.length < 2) continue
const iter = parts[parts.length - 1].trim().replace(/^\(|\)$/g, '').trim()
// 成员表达式 / 函数调用 / 下标 → 不是本守卫能判定的裸标识符,跳过
if (/[.[\]()]/.test(iter)) { skipped++; continue }
const bare = iter.replace(/[!?]+$/, '')
if (!/^[A-Za-z_$][\w$]*$/.test(bare)) { skipped++; continue }
// 模板局部(外层 v-for 解构)不在此次声明表中 → 跳过
if (!kind.has(bare)) { skipped++; continue }
if (kind.get(bare) === 'reactive') { reactiveOk++; continue }
if (!isMutated(bare)) { staticConst++; continue }
violations.push({ rel, name: bare, via: 'v-for' })
}
// --- 1b) 非响应式 + 被写 + 模板引用(插值 / :prop 同样读不到更新) ---
for (const [name, k] of kind) {
if (k !== 'plain' || !isMutated(name) || !inTemplate(name)) continue
if (violations.some((v) => v.rel === rel && v.name === name)) continue
violations.push({ rel, name, via: 'template-ref' })
}
}
// ---------------------------------------------------------------------------
// 2) 数据统计页同源契约
// ---------------------------------------------------------------------------
assert.ok(existsSync(ANALYTICS), '缺少数据统计页 admin-web/src/modules/analytics/AnalyticsView.vue')
const avRaw = readFileSync(ANALYTICS, 'utf8')
const avScr = stripComments(block(avRaw, 'script', ' setup') ?? '')
const avTpl = stripHtmlComments(block(avRaw, 'template') ?? '')
for (const ep of ['user-funnel', 'match-funnel', 'safety', 'v11-dashboard']) {
assert.match(avScr, new RegExp(`/operator/analytics/${ep}`), `数据统计页必须调用 /operator/analytics/${ep}`)
}
for (const name of ['funnelStages', 'matchStages', 'safetyCards']) {
assert.match(
avScr,
new RegExp(`const ${name} = computed\\(`),
`${name} 必须用 computed 从 ref 派生(否则卡片与汇总表各持一份、无法同步)`,
)
}
// 同源:卡片取 ref,汇总表也必须渲染**同一个 ref 的同一字段**
for (const n of ['totalRegistered', 'verifiedCount', 'profileApproved', 'activeUsers']) {
assert.ok(avScr.includes(`funnelStats.value.${n}`), `漏斗卡字段 ${n} 必须取自 funnelStats(同源)`)
assert.ok(avTpl.includes(`funnelStats.${n}`), `同卡汇总表必须渲染 funnelStats.${n}(与卡片同源)`)
}
assert.doesNotMatch(
avScr,
/label:\s*'累计注册'[^\n]*value:\s*[\d,]/,
'卡片不得硬编码演示数字(应为 ref 派生)',
)
assert.deepEqual(shadowed, [], `局部变量 token 遮蔽了 @/theme/tokens 导入的颜色助手:${shadowed.join(', ')}`)
// ---------------------------------------------------------------------------
// 结果
// ---------------------------------------------------------------------------
if (violations.length > 0) {
console.error('发现非响应式状态(改动了但渲染不会更新,界面会显示陈旧/默认值):\n')
for (const v of violations) {
console.error(` ${v.rel} → ${v.name} [${v.via}]`)
}
console.error('\n修法:改用 ref()/reactive() 持有状态;或更彻底地 computed() 从单一数据源派生,')
console.error(' 让「同一份数据的两处展示」结构上不可能对不上。')
process.exit(1)
}
console.log('Admin reactivity contracts passed')
console.log(` 扫描 .vue : ${files.length}`)
console.log(` v-for 绑定点 : ${totalVfor}(响应式 ${reactiveOk} / 静态常量 ${staticConst} / 跳过 ${skipped})`)
console.log(' 非响应式状态 : 0')
console.log(' 颜色助手遮蔽 : 0')