管理端:设计系统 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:
@@ -264,19 +264,20 @@ function handleLogout() {
|
|||||||
/* 背景图是 public/ 下的运行时资源,必须带 BASE_URL 前缀:
|
/* 背景图是 public/ 下的运行时资源,必须带 BASE_URL 前缀:
|
||||||
写死品牌目录的绝对路径,在产物部署于 /admin/ 子路径时会 404(页面背景变纯色)。
|
写死品牌目录的绝对路径,在产物部署于 /admin/ 子路径时会 404(页面背景变纯色)。
|
||||||
用 v-bind 把常量注入 CSS,避免在样式里各写一遍拼接。 */
|
用 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'));
|
background-image: url(v-bind('BRAND_BACKDROP'));
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-header {
|
.scene-header {
|
||||||
display: grid;
|
display: grid;
|
||||||
max-width: 1840px;
|
max-width: var(--admin-shell-max-width);
|
||||||
height: 188px;
|
height: 188px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
grid-template-columns: minmax(640px, 1fr) auto;
|
grid-template-columns: minmax(640px, 1fr) auto;
|
||||||
grid-template-rows: 112px 76px;
|
grid-template-rows: 112px 76px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
column-gap: 32px;
|
column-gap: var(--space-8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-brand,
|
.scene-brand,
|
||||||
@@ -295,23 +296,23 @@ function handleLogout() {
|
|||||||
|
|
||||||
.scene-slogan {
|
.scene-slogan {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding-right: 24px;
|
padding-right: var(--space-6);
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
color: var(--admin-coral);
|
color: var(--admin-coral-strong);
|
||||||
font-family: KaiTi, STKaiti, serif;
|
font-family: var(--font-family-brand);
|
||||||
line-height: 1.05;
|
line-height: 1.05;
|
||||||
transform: rotate(-7deg);
|
transform: rotate(-7deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-slogan span {
|
.scene-slogan span {
|
||||||
font-size: 17px;
|
font-size: var(--font-size-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-slogan strong {
|
.scene-slogan strong {
|
||||||
margin-top: 4px;
|
margin-top: var(--space-1);
|
||||||
font-size: 25px;
|
font-size: var(--font-size-3xl);
|
||||||
font-weight: 500;
|
font-weight: var(--font-weight-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-brand-copy {
|
.scene-brand-copy {
|
||||||
@@ -319,18 +320,18 @@ function handleLogout() {
|
|||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
color: var(--admin-coral);
|
color: var(--admin-coral-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-brand-copy strong {
|
.scene-brand-copy strong {
|
||||||
font-size: 27px;
|
font-size: var(--font-size-4xl);
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-brand-copy span {
|
.scene-brand-copy span {
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
font-size: 12px;
|
font-size: var(--font-size-xs);
|
||||||
letter-spacing: 4px;
|
letter-spacing: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,18 +339,18 @@ function handleLogout() {
|
|||||||
min-width: 370px;
|
min-width: 370px;
|
||||||
margin-left: 28px;
|
margin-left: 28px;
|
||||||
padding-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 {
|
.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;
|
line-height: 1.1;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-title p {
|
.scene-title p {
|
||||||
margin-top: 9px;
|
margin-top: 9px;
|
||||||
color: #66717a;
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-benefits {
|
.scene-benefits {
|
||||||
@@ -357,40 +358,41 @@ function handleLogout() {
|
|||||||
align-self: start;
|
align-self: start;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
gap: clamp(38px, 5vw, 92px);
|
gap: clamp(38px, 5vw, 92px);
|
||||||
padding: 4px 0 0 50px;
|
padding: var(--space-1) 0 0 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-benefits span {
|
.scene-benefits span {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 36px auto;
|
grid-template-columns: 36px auto;
|
||||||
column-gap: 8px;
|
column-gap: var(--space-2);
|
||||||
color: var(--admin-coral);
|
/* 13px 文字压在摄影背景图上,必须用 -strong 档(5.13:1),基础色只有 3.14:1 */
|
||||||
|
color: var(--admin-coral-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-benefits .el-icon {
|
.scene-benefits .el-icon {
|
||||||
grid-row: 1 / 3;
|
grid-row: 1 / 3;
|
||||||
font-size: 30px;
|
font-size: var(--font-size-4xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-benefits b {
|
.scene-benefits b {
|
||||||
font-size: 13px;
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-benefits small {
|
.scene-benefits small {
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
color: #7b7f86;
|
color: var(--color-text-tertiary);
|
||||||
font-size: 10px;
|
font-size: var(--font-size-micro);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-shell {
|
.admin-shell {
|
||||||
width: min(1840px, 100%);
|
width: min(var(--admin-shell-max-width), 100%);
|
||||||
height: calc(100vh - 238px);
|
height: calc(100vh - 238px);
|
||||||
min-height: 560px;
|
min-height: 560px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--admin-shell-bg);
|
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);
|
border-radius: var(--admin-shell-radius);
|
||||||
box-shadow: var(--admin-shadow);
|
box-shadow: var(--admin-shadow);
|
||||||
backdrop-filter: blur(14px);
|
backdrop-filter: blur(14px);
|
||||||
@@ -401,23 +403,23 @@ function handleLogout() {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--admin-sidebar-text);
|
color: var(--admin-sidebar-text);
|
||||||
background: linear-gradient(180deg, #202d38 0%, #192733 100%);
|
background: var(--admin-sidebar-gradient);
|
||||||
border-right: 1px solid rgba(255, 255, 255, 0.06);
|
border-right: 1px solid var(--admin-sidebar-hairline-soft);
|
||||||
transition: width 0.2s ease;
|
transition: width var(--motion-normal) var(--ease-standard);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 82px;
|
height: 82px;
|
||||||
padding: 17px 16px;
|
padding: 17px var(--space-4);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 11px;
|
gap: 11px;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
border-bottom: 1px solid var(--admin-sidebar-hairline);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand.compact {
|
.brand.compact {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 16px 8px;
|
padding: var(--space-4) var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-mark {
|
.brand-mark {
|
||||||
@@ -435,15 +437,15 @@ function handleLogout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.brand-copy strong {
|
.brand-copy strong {
|
||||||
color: #fff;
|
color: var(--color-text-inverse);
|
||||||
font-size: 17px;
|
font-size: var(--font-size-lg);
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-copy span {
|
.brand-copy span {
|
||||||
margin-top: 4px;
|
margin-top: var(--space-1);
|
||||||
color: #b5c0c8;
|
color: var(--color-text-on-dark-muted);
|
||||||
font-size: 10px;
|
font-size: var(--font-size-micro);
|
||||||
letter-spacing: 1.5px;
|
letter-spacing: 1.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,49 +459,54 @@ function handleLogout() {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 主导航行项:桌面密度下取 44px(兼顾触屏笔记本,且远高于 WCAG 2.5.8 的 24px 下限) */
|
||||||
.sidebar-menu :deep(.el-menu-item) {
|
.sidebar-menu :deep(.el-menu-item) {
|
||||||
height: 41px;
|
height: var(--tap-min);
|
||||||
margin: 2px 0;
|
margin: 2px 0;
|
||||||
color: var(--admin-sidebar-text);
|
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) {
|
.sidebar-menu :deep(.el-menu-item:hover) {
|
||||||
color: #fff;
|
color: var(--color-text-inverse);
|
||||||
background: var(--admin-sidebar-surface);
|
background: var(--admin-sidebar-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 激活态原先压的是明亮珊瑚渐变(#f45f68→#ee727b),白字只有 3.14:1;
|
||||||
|
改用达标渐变(两端 4.60:1 / 5.33:1)。 */
|
||||||
.sidebar-menu :deep(.el-menu-item.is-active) {
|
.sidebar-menu :deep(.el-menu-item.is-active) {
|
||||||
color: #fff;
|
color: var(--color-text-inverse);
|
||||||
font-weight: 650;
|
font-weight: var(--font-weight-semibold);
|
||||||
background: linear-gradient(135deg, #f45f68, #ee727b);
|
background: var(--gradient-brand-solid);
|
||||||
box-shadow: 0 8px 18px rgba(244, 95, 104, 0.26);
|
box-shadow: var(--shadow-brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-menu :deep(.el-icon) {
|
.sidebar-menu :deep(.el-icon) {
|
||||||
font-size: 17px;
|
font-size: var(--font-size-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-motto {
|
.sidebar-motto {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin: 8px 15px 2px;
|
margin: var(--space-2) 15px 2px;
|
||||||
padding: 14px 12px;
|
padding: 14px var(--space-3);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 11px;
|
gap: 11px;
|
||||||
color: #ff969c;
|
color: var(--admin-coral-on-dark);
|
||||||
font-family: KaiTi, STKaiti, serif;
|
font-family: var(--font-family-brand);
|
||||||
font-size: 15px;
|
font-size: var(--font-size-md);
|
||||||
line-height: 1.45;
|
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 {
|
.sidebar-motto .el-icon {
|
||||||
font-size: 31px;
|
font-size: var(--font-size-4xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.collapse-control {
|
.collapse-control {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 42px;
|
min-height: var(--tap-min);
|
||||||
margin: 2px 10px 10px;
|
margin: 2px 10px 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -508,10 +515,14 @@ function handleLogout() {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
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 {
|
.collapse-control:hover {
|
||||||
color: #fff;
|
color: var(--color-text-inverse);
|
||||||
|
background: var(--admin-sidebar-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace {
|
.workspace {
|
||||||
@@ -520,12 +531,12 @@ function handleLogout() {
|
|||||||
|
|
||||||
.admin-topbar {
|
.admin-topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 64px;
|
height: var(--header-height);
|
||||||
padding: 0 24px;
|
padding: 0 var(--space-6);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
background: rgba(255, 255, 255, 0.96);
|
background: var(--color-bg-overlay-strong);
|
||||||
border-bottom: 1px solid #edf0f2;
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-context,
|
.page-context,
|
||||||
@@ -537,15 +548,18 @@ function handleLogout() {
|
|||||||
|
|
||||||
.page-context {
|
.page-context {
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
gap: 12px;
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-collapse {
|
.mobile-collapse {
|
||||||
display: none;
|
display: none;
|
||||||
color: #56616a;
|
min-width: var(--tap-icon);
|
||||||
|
min-height: var(--tap-icon);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-tools {
|
.header-tools {
|
||||||
@@ -557,57 +571,59 @@ function handleLogout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.global-search :deep(.el-input__wrapper) {
|
.global-search :deep(.el-input__wrapper) {
|
||||||
background: #f6f7f8;
|
background: var(--color-bg-input);
|
||||||
border-radius: 9px;
|
border-radius: var(--border-radius-sm);
|
||||||
box-shadow: inset 0 0 0 1px #e8eaed;
|
box-shadow: inset 0 0 0 1px var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-button {
|
.notification-button {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 36px;
|
width: var(--tap-icon);
|
||||||
height: 36px;
|
height: var(--tap-icon);
|
||||||
color: #4f5b65;
|
color: var(--color-text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 9px;
|
border-radius: var(--border-radius-sm);
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
transition: background var(--motion-fast) var(--ease-standard);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-button:hover {
|
.notification-button:hover {
|
||||||
background: #f6f7f8;
|
background: var(--color-bg-input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 角标字号原为 9px(低于可读下限),提到 11px,容器同步放大以容纳 */
|
||||||
.notification-badge {
|
.notification-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 2px;
|
top: 2px;
|
||||||
right: 1px;
|
right: 1px;
|
||||||
min-width: 15px;
|
min-width: var(--space-4);
|
||||||
height: 15px;
|
height: var(--space-4);
|
||||||
padding: 0 3px;
|
padding: 0 3px;
|
||||||
color: #fff;
|
color: var(--color-text-inverse);
|
||||||
font-size: 9px;
|
font-size: var(--font-size-micro);
|
||||||
line-height: 15px;
|
line-height: var(--space-4);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
background: var(--admin-coral);
|
background: var(--admin-coral-solid);
|
||||||
border-radius: 999px;
|
border-radius: var(--border-radius-pill);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-info {
|
.user-info {
|
||||||
gap: 8px;
|
gap: var(--space-2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-info .el-avatar {
|
.user-info .el-avatar {
|
||||||
color: #fff;
|
color: var(--color-text-inverse);
|
||||||
background: var(--admin-coral);
|
background: var(--admin-coral-solid);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-info strong {
|
.user-info strong {
|
||||||
max-width: 92px;
|
max-width: 92px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 13px;
|
font-size: var(--font-size-sm);
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -615,27 +631,28 @@ function handleLogout() {
|
|||||||
.admin-content {
|
.admin-content {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
background: var(--admin-page-bg);
|
background: var(--color-bg-page);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-footer {
|
.scene-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
max-width: 1840px;
|
max-width: var(--admin-shell-max-width);
|
||||||
height: 32px;
|
height: var(--space-8);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
color: #8d8587;
|
color: var(--color-text-tertiary);
|
||||||
font-size: 11px;
|
font-size: var(--font-size-micro);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-footer strong {
|
.scene-footer strong {
|
||||||
color: var(--admin-coral);
|
color: var(--admin-coral-strong);
|
||||||
font-size: 14px;
|
font-size: var(--font-size-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-footer small {
|
.scene-footer small {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
font-size: var(--font-size-micro);
|
||||||
letter-spacing: 4px;
|
letter-spacing: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -653,15 +670,15 @@ function handleLogout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.scene-slogan {
|
.scene-slogan {
|
||||||
padding-right: 12px;
|
padding-right: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-slogan span {
|
.scene-slogan span {
|
||||||
font-size: 14px;
|
font-size: var(--font-size-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-slogan strong {
|
.scene-slogan strong {
|
||||||
font-size: 20px;
|
font-size: var(--font-size-2xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-brand > img {
|
.scene-brand > img {
|
||||||
@@ -670,7 +687,7 @@ function handleLogout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.scene-brand-copy strong {
|
.scene-brand-copy strong {
|
||||||
font-size: 22px;
|
font-size: var(--font-size-2xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-title {
|
.scene-title {
|
||||||
@@ -679,8 +696,9 @@ function handleLogout() {
|
|||||||
padding-left: 18px;
|
padding-left: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 紧凑态下正文标题略大于品牌字号,层级保持「页面 → 品牌」的正确指向 */
|
||||||
.scene-title h1 {
|
.scene-title h1 {
|
||||||
font-size: 27px;
|
font-size: var(--font-size-3xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-benefits {
|
.scene-benefits {
|
||||||
@@ -707,7 +725,7 @@ function handleLogout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-sidebar {
|
.admin-sidebar {
|
||||||
width: 76px !important;
|
width: var(--sidebar-width-collapsed) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-copy,
|
.brand-copy,
|
||||||
@@ -741,7 +759,7 @@ function handleLogout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-topbar {
|
.admin-topbar {
|
||||||
padding: 0 12px;
|
padding: 0 var(--space-3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -183,13 +183,13 @@ onMounted(loadActivities)
|
|||||||
}
|
}
|
||||||
.form-hint {
|
.form-hint {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
color: #909399;
|
color: var(--color-text-placeholder);
|
||||||
font-size: 12px;
|
font-size: var(--font-size-xs);
|
||||||
}
|
}
|
||||||
.opt-type {
|
.opt-type {
|
||||||
float: right;
|
float: right;
|
||||||
color: #909399;
|
color: var(--color-text-placeholder);
|
||||||
font-size: 12px;
|
font-size: var(--font-size-xs);
|
||||||
}
|
}
|
||||||
.result-card {
|
.result-card {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
|
|||||||
@@ -434,8 +434,8 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
.form-hint {
|
.form-hint {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
color: #8a90a2;
|
color: var(--color-text-placeholder);
|
||||||
font-size: 12px;
|
font-size: var(--font-size-xs);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
.partner-row {
|
.partner-row {
|
||||||
|
|||||||
@@ -213,8 +213,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { get } from '@/api/client'
|
import { get } from '@/api/client'
|
||||||
|
import { token } from '@/theme/tokens'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const dateRange = ref<[string, string]>([
|
const dateRange = ref<[string, string]>([
|
||||||
@@ -227,87 +228,108 @@ const safetyStats = ref<any>({})
|
|||||||
const v11 = ref<any>({})
|
const v11 = ref<any>({})
|
||||||
const dailySummary = ref<any[]>([])
|
const dailySummary = ref<any[]>([])
|
||||||
|
|
||||||
const funnelStages = [
|
// 漏斗卡与安全卡一律**派生自上面的 ref**,不再各自持有一份可变副本。
|
||||||
{ key: 'registered', label: '累计注册', value: 0, rate: null as string | null, color: '#409EFF' },
|
//
|
||||||
{ key: 'verified', label: '已实名', value: 0, rate: null as string | null, color: '#67C23A' },
|
// 历史缺陷(2026-09-23 渲染态实测发现):这三组曾是 `const x = [...]` 普通数组,
|
||||||
{ key: 'approved', label: '审核通过', value: 0, rate: null as string | null, color: '#E59A32' },
|
// loadFunnel() 直接对元素赋值(`x[0].value = ...`),模板读的却是同一个数组 ——
|
||||||
{ key: 'active', label: '7日活跃', value: 0, rate: null as string | null, color: '#E96B6B' }
|
// 非响应式写入不触发重渲染,于是卡片恒显示 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 = [
|
const matchStages = computed(() => [
|
||||||
{ key: 'recommended', label: '推荐', value: 0, rate: '-', color: '#409EFF' },
|
{
|
||||||
{ key: 'liked', label: '喜欢', value: 0, rate: '-', color: '#67C23A' },
|
key: 'recommended', label: '推荐',
|
||||||
{ key: 'matches', label: 'Match', value: 0, rate: '-', color: '#E59A32' },
|
value: matchFunnelStats.value.totalRecommended || 0,
|
||||||
{ key: 'chats', label: '聊天', value: 0, rate: '-', color: '#909399' },
|
rate: '-', color: token('--color-info')
|
||||||
{ key: 'knowing', label: '了解中', value: 0, rate: '-', color: '#E96B6B' },
|
},
|
||||||
{ key: 'success', label: '成功牵手', value: 0, rate: '-', color: '#F56C6C' }
|
{
|
||||||
]
|
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 = [
|
const safetyCards = computed(() => [
|
||||||
{ key: 'pending', label: '待处理举报', value: 0, color: '#E96B6B' },
|
{ key: 'pending', label: '待处理举报', value: safetyStats.value.pendingReports || 0, color: token('--color-primary') },
|
||||||
{ key: 'resolved', label: '已处理举报', value: 0, color: '#67C23A' },
|
{ key: 'resolved', label: '已处理举报', value: safetyStats.value.resolvedReports || 0, color: token('--color-success') },
|
||||||
{ key: 'highRisk', label: '高风险用户', value: 0, color: '#E59A32' },
|
{ key: 'highRisk', label: '高风险用户', value: safetyStats.value.highRiskUsers || 0, color: token('--color-warning') },
|
||||||
{ key: 'banned', label: '封禁用户', value: 0, color: '#F56C6C' }
|
{ key: 'banned', label: '封禁用户', value: safetyStats.value.bannedUsers || 0, color: token('--color-danger') }
|
||||||
]
|
])
|
||||||
|
|
||||||
async function loadFunnel() {
|
async function loadFunnel() {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('accessToken')
|
// 注意:这里曾是 `const token = ...`,与顶部从 @/theme/tokens 导入的**颜色助手**
|
||||||
|
// `token` 同名(函数内遮蔽)。现已改名 authToken,避免后续在函数内误用颜色助手。
|
||||||
|
const authToken = localStorage.getItem('accessToken')
|
||||||
if (dateRange.value[0] && dateRange.value[1]) {
|
if (dateRange.value[0] && dateRange.value[1]) {
|
||||||
const [userRes, matchRes, safetyRes, v11Res]: any[] = await Promise.all([
|
const [userRes, matchRes, safetyRes, v11Res]: any[] = await Promise.all([
|
||||||
get('/operator/analytics/user-funnel', {
|
get('/operator/analytics/user-funnel', {
|
||||||
since: dateRange.value[0], until: dateRange.value[1]
|
since: dateRange.value[0], until: dateRange.value[1]
|
||||||
}, { Authorization: `Bearer ${token}` }),
|
}, { Authorization: `Bearer ${authToken}` }),
|
||||||
get('/operator/analytics/match-funnel', {
|
get('/operator/analytics/match-funnel', {
|
||||||
since: dateRange.value[0], until: dateRange.value[1]
|
since: dateRange.value[0], until: dateRange.value[1]
|
||||||
}, { Authorization: `Bearer ${token}` }),
|
}, { Authorization: `Bearer ${authToken}` }),
|
||||||
get('/operator/analytics/safety', {}, { Authorization: `Bearer ${token}` }),
|
get('/operator/analytics/safety', {}, { Authorization: `Bearer ${authToken}` }),
|
||||||
// Phase 20:原先只接了 4/15 个端点,V1.1 公益服务的专项统计
|
// Phase 20:原先只接了 4/15 个端点,V1.1 公益服务的专项统计
|
||||||
// (服务用户数/人次、活动互选 vs 成功、Banner、专项项目)
|
// (服务用户数/人次、活动互选 vs 成功、Banner、专项项目)
|
||||||
// 后端已实现却无人调用 —— 本行起补齐。
|
// 后端已实现却无人调用 —— 本行起补齐。
|
||||||
get('/operator/analytics/v11-dashboard', {}, { Authorization: `Bearer ${token}` })
|
get('/operator/analytics/v11-dashboard', {}, { Authorization: `Bearer ${authToken}` })
|
||||||
])
|
])
|
||||||
const userData = userRes.data || {}
|
// 只落 ref,卡片由上面三个 computed 自动派生 —— 不再手工同步第二份副本。
|
||||||
const matchData = matchRes.data || {}
|
funnelStats.value = userRes.data || {}
|
||||||
const safetyData = safetyRes.data || {}
|
matchFunnelStats.value = matchRes.data || {}
|
||||||
const v11Data = v11Res.data || {}
|
safetyStats.value = safetyRes.data || {}
|
||||||
funnelStats.value = userData
|
v11.value = v11Res.data || {}
|
||||||
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
|
|
||||||
}
|
}
|
||||||
} catch (e) { console.error(e) }
|
} catch (e) { console.error(e) }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDailySummary() {
|
async function loadDailySummary() {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('accessToken')
|
const authToken = localStorage.getItem('accessToken')
|
||||||
const res: any = await get('/operator/analytics/daily-summary', { days: 30 }, { Authorization: `Bearer ${token}` })
|
const res: any = await get('/operator/analytics/daily-summary', { days: 30 }, { Authorization: `Bearer ${authToken}` })
|
||||||
dailySummary.value = res.data || []
|
dailySummary.value = res.data || []
|
||||||
} catch (e) { console.error(e) }
|
} catch (e) { console.error(e) }
|
||||||
}
|
}
|
||||||
@@ -329,21 +351,21 @@ onMounted(loadData)
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.funnel-label { font-size: 14px; color: #666; margin-bottom: 8px; }
|
.funnel-label { font-size: var(--font-size-base); color: var(--color-text-tertiary); margin-bottom: 8px; }
|
||||||
.funnel-value { font-size: 28px; font-weight: bold; color: #333; }
|
.funnel-value { font-size: var(--font-size-4xl); font-weight: var(--font-weight-bold); color: var(--color-text-primary); }
|
||||||
.funnel-rate { font-size: 12px; color: #999; margin-top: 4px; }
|
.funnel-rate { font-size: var(--font-size-xs); color: var(--color-text-placeholder); margin-top: 4px; }
|
||||||
.stat-card { text-align: center; }
|
.stat-card { text-align: center; }
|
||||||
.stat-value { font-size: 32px; font-weight: bold; }
|
.stat-value { font-size: var(--font-size-5xl); font-weight: var(--font-weight-bold); }
|
||||||
.stat-label { font-size: 13px; color: #666; margin-top: 6px; }
|
.stat-label { font-size: var(--font-size-sm); color: var(--color-text-tertiary); margin-top: 6px; }
|
||||||
.mini-stat {
|
.mini-stat {
|
||||||
border: 1px solid #ebeef5;
|
border: 1px solid var(--color-border-light);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
background: #fafafa;
|
background: var(--color-bg-secondary);
|
||||||
}
|
}
|
||||||
.mini-value { font-size: 22px; font-weight: bold; color: #303133; }
|
.mini-value { font-size: var(--font-size-3xl); font-weight: var(--font-weight-bold); color: var(--color-text-primary); }
|
||||||
.mini-label { font-size: 12px; color: #909399; margin-top: 6px; }
|
.mini-label { font-size: var(--font-size-xs); color: var(--color-text-placeholder); margin-top: 6px; }
|
||||||
.metric-note { font-size: 12px; color: #909399; margin-top: 12px; line-height: 1.6; }
|
.metric-note { font-size: var(--font-size-xs); color: var(--color-text-placeholder); margin-top: 12px; line-height: 1.6; }
|
||||||
.unavailable { color: #c0c4cc; font-style: italic; }
|
.unavailable { color: var(--color-text-disabled); font-style: italic; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
|
|
||||||
<template v-if="detail.status==='pending'||detail.status==='reviewing'||detail.status==='escalated'">
|
<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 label-width="100px">
|
||||||
<el-form-item label="处理结果">
|
<el-form-item label="处理结果">
|
||||||
<el-select v-model="decision" style="width:100%">
|
<el-select v-model="decision" style="width:100%">
|
||||||
@@ -86,6 +86,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { get, post } from '@/api/client'
|
import { get, post } from '@/api/client'
|
||||||
|
import { token as tokenColor } from '@/theme/tokens'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const processing = 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 res: any = await get('/operator/appeals/stats', {}, { Authorization: `Bearer ${token}` })
|
||||||
const data = res.data || {}
|
const data = res.data || {}
|
||||||
stats.value = [
|
stats.value = [
|
||||||
{ key: 'pending', label: '待审核', value: data.pendingCount || 0, color: '#E96B6B' },
|
{ key: 'pending', label: '待审核', value: data.pendingCount || 0, color: tokenColor('--color-primary') },
|
||||||
{ key: 'reviewing', label: '审核中', value: data.reviewingCount || 0, color: '#E59A32' },
|
{ key: 'reviewing', label: '审核中', value: data.reviewingCount || 0, color: tokenColor('--color-warning') },
|
||||||
{ key: 'approved', label: '已通过', value: data.approvedCount || 0, color: '#67C23A' },
|
{ key: 'approved', label: '已通过', value: data.approvedCount || 0, color: tokenColor('--color-success') },
|
||||||
{ key: 'rejected', label: '已驳回', value: data.rejectedCount || 0, color: '#909399' },
|
{ key: 'rejected', label: '已驳回', value: data.rejectedCount || 0, color: tokenColor('--color-neutral') },
|
||||||
{ key: 'escalated', label: '已升级', value: data.escalatedCount || 0, color: '#F56C6C' }
|
{ key: 'escalated', label: '已升级', value: data.escalatedCount || 0, color: tokenColor('--color-danger') }
|
||||||
]
|
]
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
@@ -168,8 +169,8 @@ onMounted(loadData)
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.page-container { padding: 24px; }
|
.page-container { padding: 24px; }
|
||||||
.stat-card { text-align: center; }
|
.stat-card { text-align: center; }
|
||||||
.stat-value { font-size: 32px; font-weight: bold; color: #409EFF; }
|
.stat-value { font-size: var(--font-size-5xl); font-weight: var(--font-weight-bold); color: var(--admin-coral-strong); }
|
||||||
.stat-label { font-size: 13px; color: #666; margin-top: 6px; }
|
.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; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -302,21 +302,21 @@ onMounted(loadBanners)
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
.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: 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 {
|
.preview-card {
|
||||||
padding: 20px 18px;
|
padding: 20px 18px;
|
||||||
border-radius: 16px;
|
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);
|
box-shadow: 0 6px 16px rgba(200, 120, 100, 0.16);
|
||||||
}
|
}
|
||||||
.preview-tag {
|
.preview-tag {
|
||||||
display: inline-block; padding: 2px 10px; border-radius: 999px;
|
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-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: 12px; line-height: 1.6; color: #6b6b78; }
|
.preview-subtitle { margin-top: 6px; font-size: var(--font-size-xs); line-height: 1.6; color: var(--color-text-tertiary); }
|
||||||
.preview-button {
|
.preview-button {
|
||||||
display: inline-block; margin-top: 14px; padding: 5px 16px; border-radius: 999px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -253,5 +253,5 @@ onMounted(() => { loadCategories(); loadArticles() })
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -284,5 +284,5 @@ onMounted(loadPage)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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>
|
</style>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
<el-descriptions-item label="补充说明" :span="2">{{ detail.report?.description || '无' }}</el-descriptions-item>
|
<el-descriptions-item label="补充说明" :span="2">{{ detail.report?.description || '无' }}</el-descriptions-item>
|
||||||
</el-descriptions>
|
</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 :column="2" border v-if="detail.riskProfile">
|
||||||
<el-descriptions-item label="用户">{{ detail.riskProfile.displayName }}</el-descriptions-item>
|
<el-descriptions-item label="用户">{{ detail.riskProfile.displayName }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="风险分数">{{ detail.riskProfile.totalRiskScore }}</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-item label="历史举报数">{{ detail.riskProfile.totalReportsCount }}</el-descriptions-item>
|
||||||
</el-descriptions>
|
</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 label-width="100px">
|
||||||
<el-form-item label="处理结果">
|
<el-form-item label="处理结果">
|
||||||
<el-select v-model="processResult" style="width:100%">
|
<el-select v-model="processResult" style="width:100%">
|
||||||
@@ -100,6 +100,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { get, post } from '@/api/client'
|
import { get, post } from '@/api/client'
|
||||||
|
import { token as tokenColor } from '@/theme/tokens'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const processing = 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 res: any = await get('/operator/risk/reports/stats', {}, { Authorization: `Bearer ${token}` })
|
||||||
const data = res.data || {}
|
const data = res.data || {}
|
||||||
stats.value = [
|
stats.value = [
|
||||||
{ key: 'pending', label: '待处理', value: data.pendingCount || 0, color: '#E96B6B' },
|
{ key: 'pending', label: '待处理', value: data.pendingCount || 0, color: tokenColor('--color-primary') },
|
||||||
{ key: 'processing', label: '处理中', value: data.processingCount || 0, color: '#E59A32' },
|
{ key: 'processing', label: '处理中', value: data.processingCount || 0, color: tokenColor('--color-warning') },
|
||||||
{ key: 'resolved', label: '已处理', value: data.resolvedCount || 0, color: '#67C23A' },
|
{ key: 'resolved', label: '已处理', value: data.resolvedCount || 0, color: tokenColor('--color-success') },
|
||||||
{ key: 'dismissed', label: '已驳回', value: data.dismissedCount || 0, color: '#909399' }
|
{ key: 'dismissed', label: '已驳回', value: data.dismissedCount || 0, color: tokenColor('--color-neutral') }
|
||||||
]
|
]
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
@@ -188,7 +189,7 @@ onMounted(loadData)
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.page-container { padding: 24px; }
|
.page-container { padding: 24px; }
|
||||||
.stat-card { text-align: center; }
|
.stat-card { text-align: center; }
|
||||||
.stat-value { font-size: 36px; font-weight: bold; }
|
.stat-value { font-size: var(--font-size-5xl); font-weight: var(--font-weight-bold); }
|
||||||
.stat-label { font-size: 14px; color: #666; margin-top: 8px; }
|
.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; }
|
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -348,6 +348,6 @@ onMounted(loadStations)
|
|||||||
.form-hint {
|
.form-hint {
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
font-size: 12px;
|
font-size: var(--font-size-xs);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<div class="identity-summary">
|
<div class="identity-summary">
|
||||||
<el-avatar :size="58">{{ (user.displayName || '用')[0] }}</el-avatar>
|
<el-avatar :size="58">{{ (user.displayName || '用')[0] }}</el-avatar>
|
||||||
<div><h2>{{ user.displayName || '未设置显示名称' }}</h2><span>用户编号:{{ user.id }}</span></div>
|
<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>
|
</div>
|
||||||
<el-tabs v-model="activeTab" class="detail-tabs">
|
<el-tabs v-model="activeTab" class="detail-tabs">
|
||||||
<el-tab-pane label="基础信息" name="basic">
|
<el-tab-pane label="基础信息" name="basic">
|
||||||
@@ -129,5 +129,5 @@ function formatTime(time?: string) { return time ? new Date(time).toLocaleString
|
|||||||
onMounted(loadDetail)
|
onMounted(loadDetail)
|
||||||
</script>
|
</script>
|
||||||
<style scoped>
|
<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>
|
</style>
|
||||||
|
|||||||
@@ -206,8 +206,8 @@ onMounted(loadData)
|
|||||||
|
|
||||||
.page-kicker {
|
.page-kicker {
|
||||||
color: var(--admin-primary);
|
color: var(--admin-primary);
|
||||||
font-size: 12px;
|
font-size: var(--font-size-xs);
|
||||||
font-weight: 650;
|
font-weight: var(--font-weight-semibold);
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+378
-59
@@ -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 {
|
:root {
|
||||||
--admin-coral: #f45f68;
|
/* ---------------- 品牌色 ---------------- */
|
||||||
--admin-coral-hover: #df505a;
|
/* 基础色:仅用于色块 / 渐变 / 图标 / 边框氛围。白底作文字只有 3.14:1,禁止承载文字。 */
|
||||||
--admin-coral-soft: #fff0f1;
|
--admin-coral: #F45F68;
|
||||||
--admin-shell-bg: rgba(255, 255, 255, 0.96);
|
--admin-coral-hover: #E5535D;
|
||||||
--admin-sidebar-bg: #202d38;
|
--admin-coral-strong: #C0414A; /* 白底文字 5.13:1 */
|
||||||
--admin-sidebar-surface: #2b3a46;
|
--admin-coral-solid: #C84A53; /* 实心底压白字 4.60:1 */
|
||||||
--admin-sidebar-text: #D9E1E6;
|
--admin-coral-solid-hover: #B8434A; /* hover 反而更深更清晰 5.33:1 */
|
||||||
--admin-sidebar-muted: #8FA1AC;
|
--admin-coral-on-dark: #FF969C; /* 侧栏深底文字 6.76:1 */
|
||||||
--admin-page-bg: #f7f9fb;
|
--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: var(--admin-coral);
|
||||||
--admin-primary-hover: var(--admin-coral-hover);
|
--admin-primary-hover: var(--admin-coral-hover);
|
||||||
--admin-card-radius: 14px;
|
--admin-primary-strong: var(--admin-coral-strong);
|
||||||
--admin-shell-radius: 22px;
|
--admin-primary-solid: var(--admin-coral-solid);
|
||||||
--admin-shadow: 0 18px 48px rgba(57, 40, 45, 0.14);
|
--color-primary: var(--admin-coral);
|
||||||
--admin-header-bg: #FFFFFF;
|
--color-primary-hover: var(--admin-coral-hover);
|
||||||
--admin-soft-pink: var(--admin-coral-soft);
|
--color-primary-strong: var(--admin-coral-strong);
|
||||||
--admin-soft-blue: #EEF5FF;
|
--color-primary-solid: var(--admin-coral-solid);
|
||||||
--admin-soft-green: #EDF8F1;
|
--color-primary-light: var(--admin-coral-soft);
|
||||||
--admin-soft-purple: #F4EFFF;
|
--color-primary-lighter: var(--admin-coral-lighter);
|
||||||
--color-primary: var(--admin-primary);
|
|
||||||
--color-primary-hover: var(--admin-primary-hover);
|
/* ---------------- 语义色五组 ----------------
|
||||||
--color-primary-light: #FFF3F2;
|
基色做图形/色块;-strong 做文字(实算 ≥4.5:1);-soft 为配套浅底。 */
|
||||||
--color-primary-lighter: #FFF8F7;
|
--color-success: #2E9B68; /* 白底 3.50:1 —— 仅图形 */
|
||||||
--color-success: #2E9B68;
|
--color-success-strong: #1F7A4F; /* 白底 5.31:1 */
|
||||||
--color-warning: #E59A32;
|
--color-success-soft: #EDF8F1;
|
||||||
--color-danger: #D94A4A;
|
--color-warning: #E59A32; /* 白底 2.33:1 —— 仅图形 */
|
||||||
--color-info: #4A7CD4;
|
--color-warning-strong: #8F5D0F; /* 白底 5.61:1 */
|
||||||
--color-text-primary: #222222;
|
--color-warning-soft: #FFF6E6;
|
||||||
--color-text-secondary: #555555;
|
--color-danger: #D94A4A; /* 白底 4.18:1 —— 图形 / ≥24px 大字 */
|
||||||
--color-text-tertiary: #888888;
|
--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-primary: #FFFFFF;
|
||||||
--color-bg-secondary: #FFF8F7;
|
--color-bg-page: #F7F9FB;
|
||||||
--color-bg-page: #FAFAF8;
|
--color-bg-secondary: #FAFAFB;
|
||||||
--color-border: #E8E5E2;
|
--color-bg-subtle: #F5F6F8;
|
||||||
--color-border-light: #F0EDE9;
|
--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: 220px;
|
||||||
|
--sidebar-width-collapsed: 76px;
|
||||||
--header-height: 64px;
|
--header-height: 64px;
|
||||||
|
|
||||||
|
/* ---------------- 圆角 ---------------- */
|
||||||
|
--border-radius-xs: 6px;
|
||||||
--border-radius-sm: 8px;
|
--border-radius-sm: 8px;
|
||||||
--border-radius-md: var(--admin-card-radius);
|
--border-radius-md: 14px;
|
||||||
--border-radius-lg: 16px;
|
--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);
|
--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; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
html, body, #app {
|
html, body, #app {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
font-family: var(--font-family-base);
|
||||||
font-size: 14px;
|
font-size: var(--font-size-base);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
background-color: var(--color-bg-page);
|
background-color: var(--color-bg-page);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 管理端是桌面密度布局,1100px 以下由 MainLayout 的 .admin-scene 承担横向滚动。 */
|
||||||
body { min-width: 1100px; }
|
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,
|
button:focus-visible,
|
||||||
a:focus-visible,
|
a:focus-visible,
|
||||||
input:focus-visible,
|
input:focus-visible,
|
||||||
[tabindex]:focus-visible {
|
[tabindex]:focus-visible {
|
||||||
outline: 3px solid rgba(238, 102, 107, 0.32);
|
outline: 3px solid var(--admin-coral-border);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- Element Plus 组件级覆盖 ---------------- */
|
||||||
|
/* EP 默认 hover 是把主色「加白」(白字对比度随之掉到 2.2:1)。这里改成更深一档,
|
||||||
|
既保住白字可读性(5.33:1),hover 反馈也更明确。 */
|
||||||
.el-button--primary {
|
.el-button--primary {
|
||||||
--el-button-bg-color: var(--color-primary);
|
--el-button-bg-color: var(--admin-coral-solid);
|
||||||
--el-button-border-color: var(--color-primary);
|
--el-button-border-color: var(--admin-coral-solid);
|
||||||
--el-button-hover-bg-color: var(--color-primary-hover);
|
--el-button-hover-bg-color: var(--admin-coral-solid-hover);
|
||||||
--el-button-hover-border-color: var(--color-primary-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 {
|
.el-radio-group .el-radio-button__inner:checked {
|
||||||
background-color: var(--color-primary);
|
background-color: var(--admin-coral-solid);
|
||||||
border-color: var(--color-primary);
|
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-card {
|
||||||
.el-tag { border-radius: 999px; }
|
--el-card-border-radius: var(--border-radius-md);
|
||||||
.page-container { padding: 24px; }
|
--el-card-border-color: var(--color-border-light);
|
||||||
.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-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); }
|
.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-primary { color: var(--color-text-primary); }
|
||||||
.text-secondary { color: var(--color-text-secondary); }
|
.text-secondary { color: var(--color-text-secondary); }
|
||||||
.text-tertiary { color: var(--color-text-tertiary); }
|
.text-tertiary { color: var(--color-text-tertiary); }
|
||||||
.mt-sm { margin-top: 8px; }
|
.text-muted { color: var(--color-text-tertiary); }
|
||||||
.mt-md { margin-top: 16px; }
|
.text-brand { color: var(--admin-coral-strong); }
|
||||||
.mt-lg { margin-top: 24px; }
|
.text-success { color: var(--color-success-strong); }
|
||||||
.mb-sm { margin-bottom: 8px; }
|
.text-warning { color: var(--color-warning-strong); }
|
||||||
.mb-md { margin-bottom: 16px; }
|
.text-danger { color: var(--color-danger-strong); }
|
||||||
.mb-lg { margin-bottom: 24px; }
|
|
||||||
|
/* 语义底色工具类 */
|
||||||
|
.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; }
|
.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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
}
|
||||||
@@ -283,9 +283,9 @@ onMounted(retryLoad)
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.dashboard-page {
|
.dashboard-page {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 1500px;
|
max-width: var(--admin-content-max-width);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 20px 26px 28px;
|
padding: var(--space-5) 26px 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-welcome {
|
.dashboard-welcome {
|
||||||
@@ -293,33 +293,34 @@ onMounted(retryLoad)
|
|||||||
min-height: 66px;
|
min-height: 66px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 20px;
|
gap: var(--space-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-welcome h1 {
|
.dashboard-welcome h1 {
|
||||||
font-size: 24px;
|
font-size: var(--font-size-3xl);
|
||||||
line-height: 1.25;
|
line-height: var(--font-line-snug);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-welcome p {
|
.dashboard-welcome p {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
color: #78828b;
|
color: var(--color-text-tertiary);
|
||||||
font-size: 13px;
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-date {
|
.dashboard-date {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
color: #7b858e;
|
color: var(--color-text-tertiary);
|
||||||
font-size: 12px;
|
font-size: var(--font-size-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-date small {
|
.dashboard-date small {
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
color: #d75a63;
|
color: var(--admin-coral-strong);
|
||||||
background: #fff0f1;
|
background: var(--admin-coral-soft);
|
||||||
border-radius: 999px;
|
border-radius: var(--border-radius-pill);
|
||||||
|
font-size: var(--font-size-micro);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-loading,
|
.dashboard-loading,
|
||||||
@@ -335,7 +336,7 @@ onMounted(retryLoad)
|
|||||||
|
|
||||||
.metric-skeleton {
|
.metric-skeleton {
|
||||||
height: 105px;
|
height: 105px;
|
||||||
border-radius: 12px;
|
border-radius: var(--border-radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-error,
|
.dashboard-error,
|
||||||
@@ -343,14 +344,14 @@ onMounted(retryLoad)
|
|||||||
.dashboard-notice {
|
.dashboard-notice {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 48px;
|
min-height: 48px;
|
||||||
margin: 8px 0 12px;
|
margin: var(--space-2) 0 var(--space-3);
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
color: #6f5355;
|
color: var(--color-danger-strong);
|
||||||
background: #fff6f6;
|
background: var(--color-danger-soft);
|
||||||
border: 1px solid #f7dfe1;
|
border: 1px solid var(--color-border-danger);
|
||||||
border-radius: 11px;
|
border-radius: var(--border-radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-error > span,
|
.dashboard-error > span,
|
||||||
@@ -363,7 +364,8 @@ onMounted(retryLoad)
|
|||||||
.dashboard-error small,
|
.dashboard-error small,
|
||||||
.dashboard-forbidden small {
|
.dashboard-forbidden small {
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
color: #8c7d7e;
|
color: var(--color-text-tertiary);
|
||||||
|
font-size: var(--font-size-micro);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-metrics {
|
.dashboard-metrics {
|
||||||
@@ -375,33 +377,40 @@ onMounted(retryLoad)
|
|||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 108px;
|
min-height: 108px;
|
||||||
padding: 14px 12px;
|
padding: 14px var(--space-3);
|
||||||
grid-template-columns: 38px 1fr;
|
grid-template-columns: 38px 1fr;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: #fff;
|
background: var(--color-bg-primary);
|
||||||
border: 1px solid rgba(235, 235, 238, 0.9);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 12px;
|
border-radius: var(--border-radius-md);
|
||||||
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
transition: transform var(--motion-normal) var(--ease-standard),
|
||||||
|
box-shadow var(--motion-normal) var(--ease-standard);
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-card:hover {
|
.metric-card:hover {
|
||||||
box-shadow: 0 10px 24px rgba(68, 48, 55, 0.1);
|
box-shadow: var(--shadow-raise);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.metric-card:active {
|
||||||
|
transform: translateY(0) scale(var(--press-scale));
|
||||||
|
}
|
||||||
|
|
||||||
.metric-card.unavailable {
|
.metric-card.unavailable {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
opacity: 0.72;
|
opacity: 0.72;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 图标字形属「有意义的非文本内容」,按 WCAG 需 ≥3:1。
|
||||||
|
原先用鲜亮色调压同色系浅底(amber 仅 2.2:1),统一改用 -ink 档(≥4.6:1)。 */
|
||||||
.metric-icon,
|
.metric-icon,
|
||||||
.todo-icon {
|
.todo-icon {
|
||||||
display: grid;
|
display: grid;
|
||||||
color: var(--tone-color);
|
color: var(--tone-ink);
|
||||||
background: var(--tone-soft);
|
background: var(--tone-soft);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
@@ -410,7 +419,7 @@ onMounted(retryLoad)
|
|||||||
.metric-icon {
|
.metric-icon {
|
||||||
width: 38px;
|
width: 38px;
|
||||||
height: 38px;
|
height: 38px;
|
||||||
font-size: 19px;
|
font-size: var(--font-size-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-copy {
|
.metric-copy {
|
||||||
@@ -421,24 +430,25 @@ onMounted(retryLoad)
|
|||||||
|
|
||||||
.metric-copy small {
|
.metric-copy small {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: #55616a;
|
color: var(--color-text-secondary);
|
||||||
font-size: 11px;
|
font-size: var(--font-size-xs);
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-copy strong {
|
.metric-copy strong {
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
color: #171a1d;
|
color: var(--color-text-primary);
|
||||||
font-size: 25px;
|
font-size: var(--font-size-3xl);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 原为 9px(低于可读下限),提到 11px */
|
||||||
.metric-copy em {
|
.metric-copy em {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--tone-color);
|
color: var(--tone-ink);
|
||||||
font-size: 9px;
|
font-size: var(--font-size-micro);
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -446,18 +456,20 @@ onMounted(retryLoad)
|
|||||||
|
|
||||||
.metric-arrow {
|
.metric-arrow {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 8px;
|
right: var(--space-2);
|
||||||
bottom: 8px;
|
bottom: var(--space-2);
|
||||||
color: var(--tone-color);
|
color: var(--tone-ink);
|
||||||
font-size: 11px;
|
font-size: var(--font-size-micro);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tone-rose { --tone-color: #f05e69; --tone-soft: #ffe9eb; }
|
/* 色调组改为引用 theme/index.css 的 --tone-*-ink / --tone-*-soft。
|
||||||
.tone-amber { --tone-color: #e58b39; --tone-soft: #fff1df; }
|
--tone-ink 是本组件内的局部变量(由下面各 tone-* 类赋值),供图标与文字统一取用。 */
|
||||||
.tone-blue { --tone-color: #568ce9; --tone-soft: #eaf2ff; }
|
.tone-rose { --tone-ink: var(--tone-rose-ink); --tone-soft: var(--tone-rose-soft); }
|
||||||
.tone-green { --tone-color: #4aad76; --tone-soft: #e9f7ef; }
|
.tone-amber { --tone-ink: var(--tone-amber-ink); --tone-soft: var(--tone-amber-soft); }
|
||||||
.tone-purple { --tone-color: #9c69df; --tone-soft: #f2eaff; }
|
.tone-blue { --tone-ink: var(--tone-blue-ink); --tone-soft: var(--tone-blue-soft); }
|
||||||
.tone-red { --tone-color: #ed5b64; --tone-soft: #ffe9eb; }
|
.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 {
|
.dashboard-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -468,11 +480,11 @@ onMounted(retryLoad)
|
|||||||
|
|
||||||
.dashboard-panel {
|
.dashboard-panel {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 14px 16px;
|
padding: 14px var(--space-4);
|
||||||
background: #fff;
|
background: var(--color-bg-primary);
|
||||||
border: 1px solid #e9edf0;
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--admin-card-radius);
|
border-radius: var(--admin-card-radius);
|
||||||
box-shadow: 0 8px 20px rgba(48, 58, 67, 0.035);
|
box-shadow: var(--shadow-panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
@@ -480,42 +492,49 @@ onMounted(retryLoad)
|
|||||||
min-height: 30px;
|
min-height: 30px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
border-bottom: 1px solid #f0f1f2;
|
border-bottom: 1px solid var(--color-border-hairline);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header h2 {
|
.panel-header h2 {
|
||||||
padding-bottom: 10px;
|
padding-bottom: 10px;
|
||||||
font-size: 16px;
|
font-size: var(--font-size-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header h2 span {
|
.panel-header h2 span {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
min-width: 20px;
|
min-width: var(--space-5);
|
||||||
height: 20px;
|
height: var(--space-5);
|
||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
padding: 0 5px;
|
padding: 0 5px;
|
||||||
color: #fff;
|
color: var(--color-text-inverse);
|
||||||
font-size: 10px;
|
font-size: var(--font-size-micro);
|
||||||
line-height: 20px;
|
line-height: var(--space-5);
|
||||||
background: var(--admin-coral);
|
background: var(--admin-coral-solid);
|
||||||
border-radius: 999px;
|
border-radius: var(--border-radius-pill);
|
||||||
place-items: center;
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header button {
|
.panel-header button {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
min-height: var(--tap-control);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: var(--space-1);
|
||||||
color: #89929a;
|
color: var(--color-text-tertiary);
|
||||||
font-size: 11px;
|
font-size: var(--font-size-xs);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
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 {
|
.panel-header > span {
|
||||||
color: #98a0a7;
|
color: var(--color-text-tertiary);
|
||||||
font-size: 10px;
|
font-size: var(--font-size-micro);
|
||||||
}
|
}
|
||||||
|
|
||||||
.todo-list,
|
.todo-list,
|
||||||
@@ -535,12 +554,13 @@ onMounted(retryLoad)
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
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,
|
.todo-row:hover,
|
||||||
.activity-row:hover {
|
.activity-row:hover {
|
||||||
background: #fffafa;
|
background: var(--color-bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.todo-icon {
|
.todo-icon {
|
||||||
@@ -560,8 +580,8 @@ onMounted(retryLoad)
|
|||||||
.todo-copy strong,
|
.todo-copy strong,
|
||||||
.activity-copy strong {
|
.activity-copy strong {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: #30373d;
|
color: var(--color-text-primary);
|
||||||
font-size: 12px;
|
font-size: var(--font-size-xs);
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -570,22 +590,22 @@ onMounted(retryLoad)
|
|||||||
.activity-copy small {
|
.activity-copy small {
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: #939ba2;
|
color: var(--color-text-tertiary);
|
||||||
font-size: 9px;
|
font-size: var(--font-size-micro);
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.todo-row > b {
|
.todo-row > b {
|
||||||
min-width: 29px;
|
min-width: 29px;
|
||||||
color: var(--admin-coral);
|
color: var(--admin-coral-strong);
|
||||||
font-size: 13px;
|
font-size: var(--font-size-sm);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.todo-row > em {
|
.todo-row > em {
|
||||||
color: var(--admin-coral);
|
color: var(--admin-coral-strong);
|
||||||
font-size: 10px;
|
font-size: var(--font-size-micro);
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,21 +614,21 @@ onMounted(retryLoad)
|
|||||||
height: 38px;
|
height: 38px;
|
||||||
flex: none;
|
flex: none;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 7px;
|
border-radius: var(--border-radius-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.activity-status {
|
.activity-status {
|
||||||
padding: 3px 6px;
|
padding: 3px 6px;
|
||||||
color: #318b5a;
|
color: var(--color-success-strong);
|
||||||
font-size: 9px;
|
font-size: var(--font-size-micro);
|
||||||
background: #eaf7ef;
|
background: var(--color-success-soft);
|
||||||
border-radius: 999px;
|
border-radius: var(--border-radius-pill);
|
||||||
}
|
}
|
||||||
|
|
||||||
.activity-row > em {
|
.activity-row > em {
|
||||||
min-width: 76px;
|
min-width: 76px;
|
||||||
color: #7d878f;
|
color: var(--color-text-tertiary);
|
||||||
font-size: 9px;
|
font-size: var(--font-size-micro);
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
@@ -620,47 +640,47 @@ onMounted(retryLoad)
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
color: #a0a7ad;
|
color: var(--color-text-tertiary);
|
||||||
font-size: 11px;
|
font-size: var(--font-size-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-empty .el-icon {
|
.dashboard-empty .el-icon {
|
||||||
color: #d7dce0;
|
color: var(--color-icon-muted);
|
||||||
font-size: 34px;
|
font-size: var(--font-size-5xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.overview-grid {
|
.overview-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 118px;
|
min-height: 118px;
|
||||||
padding-top: 12px;
|
padding-top: var(--space-3);
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 8px;
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.overview-grid > div {
|
.overview-grid > div {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 12px 10px;
|
padding: var(--space-3) 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--tone-soft);
|
background: var(--tone-soft);
|
||||||
border-radius: 8px;
|
border-radius: var(--border-radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.overview-grid small {
|
.overview-grid small {
|
||||||
color: var(--tone-color);
|
color: var(--tone-ink);
|
||||||
font-size: 10px;
|
font-size: var(--font-size-micro);
|
||||||
}
|
}
|
||||||
|
|
||||||
.overview-grid strong {
|
.overview-grid strong {
|
||||||
margin-top: 7px;
|
margin-top: 7px;
|
||||||
font-size: 24px;
|
font-size: var(--font-size-3xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.overview-grid em {
|
.overview-grid em {
|
||||||
margin-top: 7px;
|
margin-top: 7px;
|
||||||
color: #8a9298;
|
color: var(--color-text-tertiary);
|
||||||
font-size: 9px;
|
font-size: var(--font-size-micro);
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -675,52 +695,53 @@ onMounted(retryLoad)
|
|||||||
grid-template-columns: 92px 1fr 76px;
|
grid-template-columns: 92px 1fr 76px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
font-size: 10px;
|
font-size: var(--font-size-micro);
|
||||||
}
|
}
|
||||||
|
|
||||||
.effectiveness-row > span {
|
.effectiveness-row > span {
|
||||||
color: #747e87;
|
color: var(--color-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 轨道与填充的对比原为 2.53:1(条几乎看不出进度),改后 4.01:1 */
|
||||||
.effectiveness-row > div {
|
.effectiveness-row > div {
|
||||||
height: 8px;
|
height: var(--space-2);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #fdebee;
|
background: var(--color-primary-light);
|
||||||
border-radius: 999px;
|
border-radius: var(--border-radius-pill);
|
||||||
}
|
}
|
||||||
|
|
||||||
.effectiveness-row i {
|
.effectiveness-row i {
|
||||||
display: block;
|
display: block;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, #f56b75, #ef5a65);
|
background: var(--gradient-brand-bar);
|
||||||
border-radius: inherit;
|
border-radius: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.effectiveness-row strong {
|
.effectiveness-row strong {
|
||||||
color: #75808a;
|
color: var(--color-text-tertiary);
|
||||||
font-size: 10px;
|
font-size: var(--font-size-micro);
|
||||||
font-weight: 500;
|
font-weight: var(--font-weight-medium);
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1450px), (max-height: 820px) {
|
@media (max-width: 1450px), (max-height: 820px) {
|
||||||
.dashboard-page { padding: 12px 18px 20px; }
|
.dashboard-page { padding: 12px 18px 20px; }
|
||||||
.dashboard-welcome { min-height: 54px; }
|
.dashboard-welcome { min-height: 54px; }
|
||||||
.dashboard-welcome h1 { font-size: 20px; }
|
.dashboard-welcome h1 { font-size: var(--font-size-2xl); }
|
||||||
.dashboard-welcome p { margin-top: 3px; font-size: 11px; }
|
.dashboard-welcome p { margin-top: 3px; font-size: var(--font-size-xs); }
|
||||||
.dashboard-metrics { margin-top: 6px; gap: 7px; }
|
.dashboard-metrics { margin-top: 6px; gap: 7px; }
|
||||||
.metric-card { min-height: 90px; padding: 10px 8px; grid-template-columns: 32px 1fr; gap: 6px; }
|
.metric-card { min-height: 90px; padding: 10px var(--space-2); grid-template-columns: var(--space-8) 1fr; gap: 6px; }
|
||||||
.metric-icon { width: 32px; height: 32px; font-size: 16px; }
|
.metric-icon { width: var(--space-8); height: var(--space-8); font-size: var(--font-size-lg); }
|
||||||
.metric-copy strong { font-size: 21px; }
|
.metric-copy strong { font-size: var(--font-size-2xl); }
|
||||||
.dashboard-grid { margin-top: 9px; gap: 9px; }
|
.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-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; }
|
.activity-row img { width: 58px; height: 31px; }
|
||||||
.dashboard-grid-bottom .dashboard-panel { min-height: 150px; }
|
.dashboard-grid-bottom .dashboard-panel { min-height: 150px; }
|
||||||
.overview-grid, .effectiveness-list { min-height: 98px; }
|
.overview-grid, .effectiveness-list { min-height: 98px; }
|
||||||
.overview-grid > div { padding: 8px 6px; }
|
.overview-grid > div { padding: var(--space-2) 6px; }
|
||||||
.overview-grid strong { font-size: 20px; }
|
.overview-grid strong { font-size: var(--font-size-2xl); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ async function handleLogin() {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1.25fr) minmax(420px, 0.75fr);
|
grid-template-columns: minmax(0, 1.25fr) minmax(420px, 0.75fr);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #FFFFFF;
|
background: var(--color-bg-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-panel {
|
.brand-panel {
|
||||||
@@ -144,28 +144,28 @@ async function handleLogin() {
|
|||||||
|
|
||||||
.brand-title strong {
|
.brand-title strong {
|
||||||
color: var(--admin-primary);
|
color: var(--admin-primary);
|
||||||
font-size: 25px;
|
font-size: var(--font-size-3xl);
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-title span {
|
.brand-title span {
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-size: 12px;
|
font-size: var(--font-size-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-message {
|
.brand-message {
|
||||||
max-width: 590px;
|
max-width: 590px;
|
||||||
padding: 26px 30px;
|
padding: 26px 30px;
|
||||||
background: rgba(255, 255, 255, 0.86);
|
background: var(--color-bg-overlay);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.94);
|
border: 1px solid var(--color-bg-overlay-strong);
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-message p {
|
.brand-message p {
|
||||||
color: var(--admin-primary);
|
color: var(--admin-primary);
|
||||||
font-weight: 650;
|
font-weight: var(--font-weight-semibold);
|
||||||
letter-spacing: 3px;
|
letter-spacing: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +188,7 @@ async function handleLogin() {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 48px;
|
padding: 48px;
|
||||||
background: #FFFFFF;
|
background: var(--color-bg-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card {
|
.login-card {
|
||||||
@@ -201,14 +201,14 @@ async function handleLogin() {
|
|||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
color: var(--admin-primary);
|
color: var(--admin-primary);
|
||||||
font-size: 12px;
|
font-size: var(--font-size-xs);
|
||||||
font-weight: 650;
|
font-weight: var(--font-weight-semibold);
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-header h2 {
|
.login-header h2 {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
font-size: 30px;
|
font-size: var(--font-size-5xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-header p {
|
.login-header p {
|
||||||
@@ -228,7 +228,7 @@ async function handleLogin() {
|
|||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-size: 12px;
|
font-size: var(--font-size-xs);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
background: var(--admin-soft-green);
|
background: var(--admin-soft-green);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -243,7 +243,7 @@ async function handleLogin() {
|
|||||||
footer {
|
footer {
|
||||||
margin-top: 40px;
|
margin-top: 40px;
|
||||||
color: var(--color-text-tertiary);
|
color: var(--color-text-tertiary);
|
||||||
font-size: 12px;
|
font-size: var(--font-size-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -31,10 +31,18 @@ const UI_TESTS_DIR = resolve(root, 'ui-tests')
|
|||||||
// wxss-component-selector-contracts(组件 wxss 用了标签/ID/属性选择器);
|
// wxss-component-selector-contracts(组件 wxss 用了标签/ID/属性选择器);
|
||||||
// 17 → 18:新增 miniapp-api-client-behavior(**唯一一组行为断言**:桩掉 wx.request,
|
// 17 → 18:新增 miniapp-api-client-behavior(**唯一一组行为断言**:桩掉 wx.request,
|
||||||
// 真跑 api.js,验证成功/失败/幂等键释放/收尾容错 4 组行为)。
|
// 真跑 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 覆盖 —— 唯一目的是让「数量下限」这条分支本身可被自检
|
||||||
// (`UI_GUARDS_MIN=99 node scripts/run-ui-contracts.mjs` 必须拒绝报绿)。
|
// (`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 cliArgs = process.argv.slice(2)
|
||||||
const explicit = cliArgs.length > 0
|
const explicit = cliArgs.length > 0
|
||||||
|
|||||||
@@ -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 已全部落地,无硬编码色值 / 无越界字号 / 对比度达标')
|
||||||
@@ -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')
|
||||||
Reference in New Issue
Block a user