From 631e3000d343bfeef62792d9fa406358419a4cd8 Mon Sep 17 00:00:00 2001 From: WorkBuddy Date: Wed, 23 Sep 2026 01:06:57 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AE=A1=E7=90=86=E7=AB=AF=EF=BC=9A=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E7=B3=BB=E7=BB=9F=20v2=20=E6=94=B6=E6=95=9B=20+=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=BC=8F=E6=96=97=E5=8D=A1=E6=81=92=E4=B8=BA?= =?UTF-8?q?=200=20=E7=9A=84=E5=93=8D=E5=BA=94=E5=BC=8F=E7=BC=BA=E9=99=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 一、设计系统收敛 - 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 --- admin-web/src/layouts/MainLayout.vue | 202 ++++---- .../modules/activity/ActivityCheckinView.vue | 8 +- .../activity/ActivityManagementView.vue | 4 +- .../src/modules/analytics/AnalyticsView.vue | 166 ++++--- .../src/modules/appeal/AppealCenterView.vue | 19 +- .../modules/banner/BannerManagementView.vue | 12 +- .../modules/content/ContentManagementView.vue | 2 +- admin-web/src/modules/review/ReviewView.vue | 2 +- .../src/modules/risk/ReportCenterView.vue | 17 +- .../service-station/ServiceStationView.vue | 2 +- admin-web/src/modules/user/UserDetailView.vue | 4 +- .../src/modules/user/UserManagementView.vue | 4 +- admin-web/src/theme/index.css | 437 +++++++++++++++--- admin-web/src/theme/tokens.ts | 77 +++ admin-web/src/views/DashboardView.vue | 239 +++++----- admin-web/src/views/LoginView.vue | 24 +- artifacts/admin-design-v2-preview.html | 265 +++++++++++ scripts/run-ui-contracts.mjs | 10 +- ui-tests/admin-design-tokens.mjs | 412 +++++++++++++++++ ui-tests/admin-reactivity-contracts.mjs | 240 ++++++++++ 20 files changed, 1765 insertions(+), 381 deletions(-) create mode 100644 admin-web/src/theme/tokens.ts create mode 100644 artifacts/admin-design-v2-preview.html create mode 100644 ui-tests/admin-design-tokens.mjs create mode 100644 ui-tests/admin-reactivity-contracts.mjs diff --git a/admin-web/src/layouts/MainLayout.vue b/admin-web/src/layouts/MainLayout.vue index f87ea5c..ff99013 100644 --- a/admin-web/src/layouts/MainLayout.vue +++ b/admin-web/src/layouts/MainLayout.vue @@ -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); } } diff --git a/admin-web/src/modules/activity/ActivityCheckinView.vue b/admin-web/src/modules/activity/ActivityCheckinView.vue index b6ff1b9..defcf01 100644 --- a/admin-web/src/modules/activity/ActivityCheckinView.vue +++ b/admin-web/src/modules/activity/ActivityCheckinView.vue @@ -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; diff --git a/admin-web/src/modules/activity/ActivityManagementView.vue b/admin-web/src/modules/activity/ActivityManagementView.vue index f9aa0f0..22da0e8 100644 --- a/admin-web/src/modules/activity/ActivityManagementView.vue +++ b/admin-web/src/modules/activity/ActivityManagementView.vue @@ -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 { diff --git a/admin-web/src/modules/analytics/AnalyticsView.vue b/admin-web/src/modules/analytics/AnalyticsView.vue index cad6db2..c62907d 100644 --- a/admin-web/src/modules/analytics/AnalyticsView.vue +++ b/admin-web/src/modules/analytics/AnalyticsView.vue @@ -213,8 +213,9 @@ diff --git a/admin-web/src/modules/risk/ReportCenterView.vue b/admin-web/src/modules/risk/ReportCenterView.vue index ef188f8..6554044 100644 --- a/admin-web/src/modules/risk/ReportCenterView.vue +++ b/admin-web/src/modules/risk/ReportCenterView.vue @@ -60,7 +60,7 @@ {{ detail.report?.description || '无' }} -

被举报人风险画像

+

被举报人风险画像

{{ detail.riskProfile.displayName }} {{ detail.riskProfile.totalRiskScore }} @@ -72,7 +72,7 @@ {{ detail.riskProfile.totalReportsCount }} -

处置操作

+

处置操作

@@ -100,6 +100,7 @@ diff --git a/admin-web/src/modules/user/UserManagementView.vue b/admin-web/src/modules/user/UserManagementView.vue index 722ca01..23a15d6 100644 --- a/admin-web/src/modules/user/UserManagementView.vue +++ b/admin-web/src/modules/user/UserManagementView.vue @@ -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; } diff --git a/admin-web/src/theme/index.css b/admin-web/src/theme/index.css index d1fbc51..742fb7f 100644 --- a/admin-web/src/theme/index.css +++ b/admin-web/src/theme/index.css @@ -1,93 +1,412 @@ +/* ============================================================================ + * 管理端设计系统 · v2 + * + * 本文件是管理端唯一的设计 token 事实源。24 个 .vue 的 + + +
+ + 管理端 · admin-web + 20/20 契约门禁通过 +

管理端设计系统 v2 改造对照

+

用与小程端相同的收敛手法,把 admin-web(Vue3 + Element Plus)的样式统一到一套 token 上:颜色、字号、字重、触摸目标、对比度,并补上 Element Plus 派生色阶联动。

+

范围:24 个 .vue 中的 style 块 + src/theme/index.css(重写为底座)+ 新增 src/theme/tokens.ts(TS 侧色值镜像)。另含复验中咬出并修复的 1 个既有功能性缺陷(见第六节)。

+ +
+
0硬编码色值(原 97 处 / 67 种)
+
0越界字号(原 88 处,最低 9px)
+
0EP 默认调色板残留(数据统计页原 133 处)
+
4 / 4页面实测无 <11px 文本
+
20 / 20UI 契约门禁(新增 1 个响应式守卫)
+
1复验咬出并已修复的既有缺陷
+
+ +

一、修复的缺陷类别(附实测数字)

+ + + + + + + + + +
类别改造前(实测)改造后证据来源
Element Plus 派生色阶残留light-3/5/7/8/9、dark-2 及 success/warning/danger/info 的 light-N 全是 EP 默认值;数据统计页实测 133 处元素仍在使用 EP 默认调色板(其中主色蓝 8 处、success 14、danger 10、warning 8、info 92)全部档位按品牌基调重算;4 个页面实测 0 处渲染态 CDP 实测
硬编码色值97 处 / 67 种色值0守卫
硬编码字号88 处;其中 20 处 ≤11px(最低 9px,工作台实测 9px×27 / 10px×42)0 处越界;实测最低 11px守卫 + 渲染态
非法字重6 处 650(缺字重平台会回退,渲染不一致)0;仅用 400/500/600/700守卫 + 渲染态
对比度不达标text-tertiary #888 3.54:1、warning #E59A32 2.33:1、coral #F45F68 作文字 3.14:1全部 ≥4.5:1(11 组实算判定)守卫(WCAG 实算)
触摸目标偏小17 处 <44px全部 ≥44px守卫
依赖 UA 默认字号浏览器对 <small> 默认 font-size: smaller(0.833em),父级 11px→实际 9.17px、12px→10px统一继承父级字号 + 关键处显式声明,实测 ≥11px本轮渲染态新发现
+ +

二、核心 token 对照

+

Element Plus 的 --el-color-*-light-N 是 SCSS 编译期静态生成的:只改 --el-color-primary 不会带动它们,残留下来的就是 EP 默认蓝。

+ + + + + + + + + + + + + + + +
token改造前改造后说明
--el-color-primary#f45f68(只覆盖了基色)#C84A53实心底 + 白字 4.60:1
--el-color-primary-light-3#79bbff ← EP 默认蓝#d98087按 mix(#fff,base,30%) 重算
--el-color-primary-light-5#a0cfff ← EP 默认蓝#e4a5a9mix(#fff,base,50%)
--el-color-primary-light-7#c6e2ff ← EP 默认蓝#efc9cbmix(#fff,base,70%)
--el-color-primary-light-8#d9ecff ← EP 默认蓝#f4dbddmix(#fff,base,80%)
--el-color-primary-light-9#ecf5ff ← EP 默认蓝#faedeemix(#fff,base,90%)
--el-color-primary-dark-2#337ecc ← EP 默认蓝#a03b42mix(#000,base,20%)
--el-color-info#909399#656C79info 语义是「中性/未知」,保持灰调
--color-text-tertiary#888888(3.54:1)#676E7B白底 5.13:1 / 浅底 4.74:1
--admin-coral-strong(未定义)#C0414A深一档,专供白底文字 5.13:1
--admin-coral-solid(未定义)#C84A53实心底 + 白字 4.60:1
--tap-min(未定义)44px触摸目标下限
--font-size-base(未定义)14px正文基准
+ +

三、字号与字重收敛(工作台渲染态实测)

+ + + + + + + +
维度改造前改造后
字号分布9px×27 · 10px×42 · 11px×35 · 12px×22 · 13px×9 · 14px×123 · 15px×3 · 16px×29 · 17px×73 · 20px×6 · 21px×6 · 22px×1 · 27px×1 · 30px×13 · 31px×311px×90 · 12px×37 · 13px×9 · 14px×123 · 15px×3 · 16px×102 · 20px×13 · 24px×1 · 28px×16
字重分布400 / 500 / 600 / 650×6 / 700仅 400 / 500 / 600 / 700
层级倒挂指标数字 30px > 页面标题 24px(数字比标题更抢眼)标题 24px / 指标 24px / 区块 20px,层级归位
+

字号刻度收敛为:micro 11 / xs 12 / sm 13 / base 14 / md 15 / lg 16 / xl 18 / 2xl 20 / 3xl 24 / 4xl 28 / 5xl 32 / display 38。

+ +

四、1440×900 目视对照

+

系统自带 Edge(Chromium 内核)无头截图,视口 1440×900、缩放 1x,页面数据来自本地 mock。 + 已目视核对工作台与数据统计的前后对照;登录页与申诉管理页未逐张细看,请人工过目。 + 数值层面另以「计算样式实测 + 守卫断言」为准。

+

提示:本节截图的「改造后」列取自已修复漏斗缺陷的产物, + 故数据统计页的漏斗卡显示的是真实数字;修复前后的专项对照见第六节。

+ +
+

登录页 品牌视觉与表单密度

+
+
+ 登录页 改造前 +
改造前
+
+
+ 登录页 改造后 +
改造后
+
+
+
+
+

工作台 指标卡 / 待办 / 活动列表 / 漏斗

+
+
+ 工作台 改造前 +
改造前
+
+
+ 工作台 改造后 +
改造后
+
+
+
+
+

申诉管理 统计卡 + 列表 + 弹窗触发器

+
+
+ 申诉管理 改造前 +
改造前
+
+
+ 申诉管理 改造后 +
改造后
+
+
+
+
+

数据统计 漏斗阶段色 / 表格 / 标签(EP 默认色重灾区)

+
+
+ 数据统计 改造前 +
改造前
+
+
+ 数据统计 改造后 +
改造后
+
+
+
+ +

五、验证结果

+ + + + + + + + + + + +
项目结果命令 / 方式
UI 契约门禁(全量)20 / 20 PASS,0 失败,10.4snode scripts/run-ui-contracts.mjs
新增守卫 admin-design-tokensPASS:未定义 token 0 / 硬编码色值 0 / 越界字号 0 / 非法字重 0 / 对比度 11 组实算 / tokens.ts↔index.css 20 项一致node ui-tests/admin-design-tokens.mjs
新增守卫 admin-reactivity-contractsPASS:扫描 24 个 .vue、23 个 v-for 绑定点 → 非响应式状态 0、颜色助手遮蔽 0(静态常量清单 3 处正确豁免)node ui-tests/admin-reactivity-contracts.mjs
守卫判别力(负向注入 · 设计守卫)抽掉 .scene-footer small 的 font-size → 守卫 FAIL(exit 1),3b 计数 1注入 → 复测 → 还原
守卫判别力(负向注入 · 响应式守卫)3 例注入全部咬住(exit 1):①普通数组+直接改元素+v-for ②汇总表改硬编码数字(同源契约)③颜色助手被同名局部变量遮蔽;还原后复跑 exit 0node .workbuddy/neg-inject-admin.mjs
漏斗缺陷修复(行为验证)同一 mock、同一路由,只差代码:修复前漏斗卡/安全卡 全 0、比率全 - → 修复后 12846 / 9217 / 8462 / 3812 / 48210 / 51236 / 7420 / 4380 / 1926 / 1284,安全卡 17 / 214 / 23 / 9Edge 无头 + CDP 读渲染后 DOM 文本
类型检查exit 0,无错误vue-tsc --noEmit
生产构建成功,17.74svite build
渲染态实测(CDP)登录 / 工作台 / 申诉 / 数据统计 4 页:EP 默认色 0 处、<11px 文本 0 处、字重仅 400/500/600/700Edge 无头 + CDP 取计算样式
+ + +

六、复验中咬出并已修复的既有缺陷

+
+

数据统计页:漏斗卡片与安全卡片恒显示 0 —— 已修复 + 已加守卫

+

+ 现象:同一张卡片里,上方的漏斗数字全是 0,下方「累计注册 / 实名用户 / 审核通过 / 7日活跃」的汇总表却是真实数字(12846 / 9217 / 8462 / 3812),界面自相矛盾。 +

+

+ 根因:这三组曾是普通数组,loadFunnel() 直接改元素属性,而模板读的就是同一个数组 —— + 非响应式写入不触发重渲染,Vue 看不到变化: +

+

const funnelStages = [ { value: 0, ... } ]  // 普通数组
+ funnelStages[0].value = userData.totalRegistered // 直接改元素 → 渲染不更新

+

+ 定位依据(渲染态实测,同一 mock、同一路由,只差代码) +

+ + + + + + +
取值来源是否响应式修复前渲染修复后渲染
funnelStats.totalRegistered 等(汇总表)是(ref)正常 12846 / 9217 / 8462 / 3812正常
漏斗卡 10 张(funnelStages / matchStages)否(普通数组)全 012846 / 9217 / 8462 / 3812 / 48210 / 51236 / 7420 / 4380 / 1926 / 1284
安全卡 4 张(safetyCards)否(普通数组)全 017 / 214 / 23 / 9
比率(funnel-rate)否全 -71.7% / 65.9% / 29.7% / 106.3% / 15.4% / 9.1% / 2.7%
+

+ 已排除环境因素:CDP 抓取页面运行期 控制台错误 0 条、网络失败 0 条, + 且 汇总表在同一页渲染出了 12846 ⇒ 数据确实已到达前端,纯粹是响应式问题。 + 改造前的构建产物表现完全相同 ⇒ 该缺陷先于本次改动存在,不是本次收敛引入的回归。 +

+

+ 修法(按根因而非最小改动):三个集合改为 computed() 从既有 ref 派生, + 卡片与汇总表因此同源 —— 结构上不可能再出现「同一份数据的两处展示对不上」。 + 另把 loadFunnel() 里遮蔽颜色助手的局部变量 const token 改名 authToken。 +

+

+ 为何前 19 个守卫全绿也没发现它:既有守卫都是源码结构断言,而这一段代码完全合法 + —— 无 TS 报错、无 lint 报错、构建通过。只有真跑起来看渲染结果才暴露。 + 故新增 ui-tests/admin-reactivity-contracts.mjs: + ①非响应式状态(普通声明 + 被当状态写 + 出现在模板里 → 失败;未被写过的静态常量清单合法,按「是否被写」区分) + ②数据统计页同源契约(卡片与汇总表必须取自同一变量、端点不得被删) + ③颜色助手遮蔽。已用负向注入证明判别力:3 例注入全部 FAIL(exit 1),还原后复跑 PASS。 +

+
+ +
+

漏斗缺陷修复对照 数据统计页 · 同为设计 v2,只差这一处修复,mock 数据完全相同

+
+
+ 修复前 +
修复前:漏斗卡 0 / 0 / 0 / 0 / 0 / 0 / 0 / 0 / 0 / 0,安全卡 0 / 0 / 0 / 0
+
+
+ 修复后 +
修复后:12846 / 9217 / 8462 / 3812 / 48210 / 51236 / 7420 / 4380 / 1926 / 1284,安全卡 17 / 214 / 23 / 9
+
+
+
+ + +
+

七、边界与未验证项(请人工确认)

+
    +
  • 漏斗修复是用 mock 数据验证的:验证的是「取数落到 ref 后能否正确传导到 DOM」这一链路(修复前全 0、修复后为真实值,同一 mock 对照)。 + 真实后端的响应字段名未在本轮重新核对 —— 但这部分本次未改动,修复前后一致。
  • +
  • 截图里「Match 按来源分离」与部分 V11 小卡显示 0 是 mock 缺口,不是缺陷:本地 mock 未提供 + matchesBySource / matchmakerRecommendations 等字段(这几个值同样走 ref, + 若后端返回即会渲染)。请勿据此判定为同类问题。
  • +
  • 登录页 / 申诉管理页截图未逐张细看:已目视核对工作台与数据统计的前后对照;其余两页的版式细节(间距节奏、换行、对齐)请人工过目。
  • +
  • 预览使用示意数据:为让页面渲染出真实版式,本地预览服务器对 /api/v1/* 返回了按前端读取字段构造的 mock 数据,不是真实业务数据;本次只看版式/配色/字号/热区,不验证业务正确性。
  • +
  • 无自动化视觉回归:布局类改动(栅格、间距)没有像素级基线,回归只覆盖 token、字号、字重、对比度、响应式状态与关键行为契约。
  • +
  • 13.33px 一档保留:工作台仍有 64 个元素计算为 13.33px,但其中仅 1 个自身带文本,来源是容器字号继承(非刻意的排版设定),不构成字号越界。
  • +
  • 未跑真机 / 未逐页人工走查:管理端 20 个业务页面仅按代表性的 4 个页面做了渲染态实测,其余靠源码级守卫覆盖。
  • +
+
+ +
+ 生成时间:2026-09-23 · 基线 825fd41(未提交,待评审)· 截图来源:本地生产构建产物(.workbuddy/tmp/admin-dist4 及其前序版本)+ mock API +
+ +
+ + \ No newline at end of file diff --git a/scripts/run-ui-contracts.mjs b/scripts/run-ui-contracts.mjs index d51db15..3a9a6b9 100644 --- a/scripts/run-ui-contracts.mjs +++ b/scripts/run-ui-contracts.mjs @@ -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 diff --git a/ui-tests/admin-design-tokens.mjs b/ui-tests/admin-design-tokens.mjs new file mode 100644 index 0000000..40268d1 --- /dev/null +++ b/ui-tests/admin-design-tokens.mjs @@ -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 的规则未显式声明 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. 文件清单与