feat(ui): 墨观 InkWatch 大前端设计系统落地(跨端令牌 + 深色皮肤 + 管理台/Flutter 同步)

将单端 Flutter 浅色令牌升级为覆盖 Web 桌面 / 移动 PWA / macOS 桌面 /
数据大屏的统一 --iw-* 令牌层,并落地到管理台与 Flutter 客户端。

新增
- server/business/admin/assets/tokens.css:Core 45 项 + 四端派生 +
  深色 58 个同名键覆写 + E-Ink 独立分支;color-scheme: light 锁定,
  深色由 html[data-iw-theme] 显式控制,不跟随系统
- 管理台 6 页引入 tokens.css 与首屏防闪脚本(7 处逐字一致)
- admin.js 主题控制器(inkwatch.theme / storage 跨标签 / E-Ink 互斥)
  与两处 ThemeToggle
- client/test/token_parity_test.dart:CSS :root 与 Dart theme.dart
  双向护栏,webOnly 白名单,深色键不进 parity
- tests/phase3b-static-guard.js:静态护栏(零硬编码色值 / 键名 / 层叠)

收敛
- admin.css 删除旧 :root 18 变量与全部硬编码色值,全量改为 var(--iw-*);
  10px/14px 圆角收敛到 8/16,删除 filter: brightness,
  Toast 文字改用 var(--iw-surface) 以适配深色反相
- Flutter:PmonColors 补 6 个 *-text,statusStyle() 扩为 4 字段,
  PmonText.micro,刻度收敛(6/10/10.5/11.5px 收敛到 8/11px)
- 浅色辅助文字色 #8A93A3 点修为 #646D7D(WCAG AA:白底 5.22:1 /
  page 4.87:1 / muted 4.74:1);--iw-chart-axis 同步副本。
  这是 Core 45 项中唯一被点修的取值,18 个品牌原色一个不动;
  深色 58 键与 E-Ink 分支显式不同步。依据 08 裁决(皇上批准路径 1)

验证
- Dart 610/610、定向护栏 18/18、静态护栏 87/0、Docker 53/0、Schema 116/0
- 管理台 6 页 x 浅/深 目视 217/0,12 张截图
- 设计系统侧:axe 42/42 格 violations=0、真 1920 视口 A-01~A-20 20/20、
  verify 15/15、tsc 0 errors、file:// 直开 exit 0
- 两份 tokens.css sha256 一致,git diff --check 通过

本轮 Flutter 不引入深色 ThemeData;E-Ink 保持纯黑白 / 圆角 0 / 无动画。
This commit is contained in:
PMon Dev committed 2026-11-08 19:47:55 +08:00
1 parent 4cca14d1b9
commit e7481a43d9
16 files changed
+1712 -128

No files matched your search

+2 -1
View File
@@ -25,7 +25,8 @@ class SkeletonBox extends StatelessWidget {
super.key,
this.width,
this.height = 12,
this.radius = 6,
// 圆角走既有刻度,不写就地 6:与 viewer_chart 的字号收敛同一纪律
this.radius = PmonRadius.sm,
});
final double? width;
+49 -8
View File
@@ -48,7 +48,7 @@ abstract final class PmonColors {
static const textSecondary = Color(0xFF4B5563);
/// 辅助文字(单位、时间戳、脚注)
static const textTertiary = Color(0xFF8A93A3);
static const textTertiary = Color(0xFF646D7D);
// ── 主色(唯一强调色:主操作 + 选中态 + 链接)──────────────────
static const primary = Color(0xFF2563EB);
@@ -72,6 +72,26 @@ abstract final class PmonColors {
static const danger = Color(0xFFDC2626);
static const dangerSoft = Color(0xFFFDECEC);
// ── 状态文字色(软底上的字形/文字,对比度 AA 以上)──────────────
/// 在线徽标文字(onlineSoft 底 4.68:1)
static const onlineText = Color(0xFF117E39);
/// 疑似离线徽标文字(suspectSoft 底 4.88:1)
static const suspectText = Color(0xFFA45904);
/// 危险提示文字(dangerSoft 底 5.67:1)
static const dangerText = Color(0xFFB81E1E);
/// 离线徽标文字。**与 [textSecondary] 同值也必须独立命名**:
/// 语义优先于色值,将来离线文字换色不必满地找哪些地方借用了正文色。
static const offlineText = Color(0xFF4B5563);
/// 未知徽标文字(同上,语义独立别名,不复用 offlineText 的名字)
static const unknownText = Color(0xFF4B5563);
/// 信息条 / 选中态文字(primarySoft 底 6.73:1)
static const infoText = Color(0xFF124BC4);
/// 图表主色(与主色同源,单独命名以便日后独立调整)
static const chartLine = primary;
@@ -119,6 +139,12 @@ abstract final class PmonText {
static const caption = TextStyle(
fontSize: 12, color: PmonColors.textTertiary, height: 1.5);
/// 图表轴标签 / Tooltip 的最小档(11px)。
/// 与 E-Ink 分支同纪律:界面里不写就地字号,需要小字就走这一档
/// (`viewer_chart.dart` 的 10 / 10.5 / 11.5 全部收敛到此处)。
static const micro = TextStyle(
fontSize: 11, color: PmonColors.textTertiary, height: 1.4);
/// 指标数值(等宽数字,避免跳动)
static const metric = TextStyle(
fontSize: 22,
@@ -272,21 +298,36 @@ ThemeData buildPmonTheme() {
);
}
/// 状态 → (文字, 颜色, 浅底)的**唯一映射**。
/// 状态 → (文字, 颜色, 浅底, 文字色)的**唯一映射**。
///
/// 为什么单独抽出来:状态颜色曾在采集页/查看页/设备列表三处各写一套 if-else,
/// 于是"疑似离线"在一处是橙、在另一处是黄。这类"同一语义两处判定"是本仓库的老病,
/// 故收敛成一条函数,新增状态只改这里。
({String label, Color color, Color soft}) statusStyle(String key) {
///
/// 只扩到 **4 字段、不带 `border`**:Flutter 的语义描边由
/// `base.withValues(alpha: 0.28)` 动态生成(见 [PmonCard.notice]),
/// 没有 Web 端「预混实色描边」这一需求,不要把 CSS 的实现概念搬进 Dart。
({String label, Color color, Color soft, Color text}) statusStyle(String key) {
switch (key) {
case 'online':
return (label: '在线', color: PmonColors.online, soft: PmonColors.onlineSoft);
return (label: '在线', color: PmonColors.online,
soft: PmonColors.onlineSoft, text: PmonColors.onlineText);
case 'suspect':
return (label: '疑似离线', color: PmonColors.suspect, soft: PmonColors.suspectSoft);
return (label: '疑似离线', color: PmonColors.suspect,
soft: PmonColors.suspectSoft, text: PmonColors.suspectText);
case 'offline':
return (label: '离线', color: PmonColors.offline, soft: PmonColors.surfaceMuted);
return (label: '离线', color: PmonColors.offline,
soft: PmonColors.surfaceMuted, text: PmonColors.offlineText);
case 'unknown':
// 未知**必须**是中性灰:把"不知道"染成告警色等于编造结论(诚实边界)
return (label: '状态未知', color: PmonColors.offline,
soft: PmonColors.surfaceMuted, text: PmonColors.unknownText);
case 'info':
return (label: '提示', color: PmonColors.primary,
soft: PmonColors.primarySoft, text: PmonColors.infoText);
default:
return (label: '状态未知', color: PmonColors.offline, soft: PmonColors.surfaceMuted);
return (label: '状态未知', color: PmonColors.offline,
soft: PmonColors.surfaceMuted, text: PmonColors.unknownText);
}
}
@@ -301,7 +342,7 @@ ThemeData buildPmonTheme() {
///
/// | 浅色主题的写法 | 在墨水屏上的后果 |
/// |---|---|
/// | `textTertiary = #8A93A3` 浅灰字 | 与白底对比不足 → 300PPI 下也发虚,且**黑色残影**明显 |
/// | 浅色辅助文字色(`textTertiary` 浅灰字) | 300PPI 墨水屏下也发虚,且**黑色残影**明显 |
/// | `page = #F6F7F9` 浅灰底 | 大面积灰底要全屏重绘(慢),且灰底上再写灰字几乎看不清 |
/// | `line = #E5E7EB` 发丝线 | 灰阶下与底几乎同色 → 等于没有分隔 |
/// | `online` 绿 / `suspect` 橙 | **亮度接近**,灰阶化后两个状态长得一样 |
+5 -9
View File
@@ -79,9 +79,9 @@ class ViewerChart extends StatelessWidget {
getTooltipItems: (spots) => spots
.map((s) => LineTooltipItem(
'${_fmt(s.y)}\n${_clockOf(s.x)}',
const TextStyle(
// 浮层是深底 → 字号走 micro 档,只覆写字色/字重
PmonText.micro.copyWith(
color: Colors.white,
fontSize: 11.5,
fontWeight: FontWeight.w600),
))
.toList(),
@@ -112,9 +112,7 @@ class ViewerChart extends StatelessWidget {
if (v == meta.min || v == meta.max) {
return const SizedBox.shrink();
}
return Text(_fmt(v),
style: const TextStyle(
fontSize: 10.5, color: PmonColors.textTertiary));
return Text(_fmt(v), style: PmonText.micro);
},
),
),
@@ -125,9 +123,7 @@ class ViewerChart extends StatelessWidget {
interval: _xInterval(pts),
getTitlesWidget: (v, meta) => Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(_clockOf(v),
style:
const TextStyle(fontSize: 10, color: PmonColors.textTertiary)),
child: Text(_clockOf(v), style: PmonText.micro),
),
),
),
@@ -166,7 +162,7 @@ class ViewerChart extends StatelessWidget {
Icon(Icons.show_chart, size: 28, color: PmonColors.textTertiary),
const SizedBox(height: 8),
Text('该指标暂无数值(平台不可得或设备未上报)',
style: TextStyle(fontSize: 12, color: PmonColors.textTertiary)),
style: PmonText.caption),
],
),
);
+69
View File
@@ -14,6 +14,14 @@
# bash scripts/test.sh test/viewer_page_test.dart
#
# 可用 FLUTTER_BIN 指定 flutter:FLUTTER_BIN=/path/to/flutter bash scripts/test.sh
#
# ── 已登记测试清单(Phase 3b 补)────────────────────────────────────
# `flutter test` 无参时会自动发现 test/*_test.dart;下面这份清单不是为了
# 替代自动发现,而是为了守住「新增测试文件必须登记」这条纪律 ——
# theme_guard_test.dart 的 _uiFiles 已两次因漏登记让几百行界面代码
# 不受护栏保护。默认全量跑之前做一次反向检查:出现清单外的 *_test.dart
# 就报错,逼迫显式登记后再跑。
# 本轮新增登记:token_parity_test.dart(CSS ↔ Dart 令牌双向护栏,05 §3.5 ④)。
set -euo pipefail
cd "$(dirname "$0")/.."
@@ -25,6 +33,67 @@ if [ ! -x "$FLUTTER" ]; then
exit 1
fi
REGISTERED_TESTS=(
account_security_test.dart
account_test.dart
adaptive_unit_test.dart
agent_lock_test.dart
agent_probe_test.dart
app_paths_test.dart
autostart_runner_test.dart
boot_report_test.dart
clock_test.dart
config_change_test.dart
config_migration_test.dart
device_merge_notice_test.dart
device_model_test.dart
eink_board_test.dart
handover_test.dart
history_test.dart
host_ffi_test.dart
idle_sampling_test.dart
install_paths_test.dart
installer_launch_note_test.dart
installer_status_json_test.dart
lan_service_test.dart
link_hub_test.dart
link_mode_test.dart
metric_format_test.dart
p2p_driver_test.dart
p2p_session_test.dart
pairing_card_test.dart
relay_driver_test.dart
resident_card_test.dart
resident_service_test.dart
scaled_unit_test.dart
skeleton_test.dart
startup_log_test.dart
startup_path_test.dart
stun_test.dart
theme_guard_test.dart
token_parity_test.dart
transfer_card_test.dart
viewer_page_test.dart
viewer_test.dart
)
# 只在「默认全量跑」时校验:显式传文件参数时保持原样透传
if [ "$#" -eq 0 ]; then
shopt -s nullglob
for f in test/*_test.dart; do
base="$(basename "$f")"
found=0
for r in "${REGISTERED_TESTS[@]}"; do
if [ "$r" = "$base" ]; then found=1; break; fi
done
if [ "$found" -eq 0 ]; then
echo "未登记的测试文件:$f" >&2
echo "请把 $base 加进 scripts/test.sh 的 REGISTERED_TESTS 清单后再跑" >&2
exit 1
fi
done
fi
exec env \
-u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY \
-u http_proxy -u https_proxy -u all_proxy \
+103 -1
View File
@@ -46,6 +46,20 @@ const List<String> _uiFiles = [
'lib/ui/device_members_card.dart',
];
/// 本轮按 `PmonText` 档位收敛过的界面文件(05 §3.4)。
/// 这些文件里**一个** `fontSize: 数字` 都不许有 —— 与 E-Ink 分支
/// 「界面里不得写就地字号」同一纪律。新增已收敛文件务必登记到这里。
const List<String> _fontSizeCleanFiles = [
'lib/ui/viewer_chart.dart',
];
/// 历史就地字号基线(Phase 3b 收敛后取证值)。
/// 本轮 scope 只收敛 `viewer_chart.dart` 的 10 / 10.5 / 11.5;其余界面文件
/// 仍有历史就地字号,全量收敛会改动大量视觉尺寸,属独立阶段。
/// 故用「已收敛文件严格 0 + 全清单**不得增长**」双层增量护栏:
/// 既拦住本轮刻度回归,也保证新代码不再新增就地字号。
const int _fontSizeBaseline = 113;
void main() {
group('① 源码结构:色值只许定义在 theme.dart', () {
test('界面文件里不得再出现 Color(0x…) 字面量', () {
@@ -106,16 +120,104 @@ void main() {
reason: '不要在页面里就地 new ThemeData —— 主题只在 buildPmonTheme() 里装配',
);
});
test('已收敛界面文件不得出现就地 fontSize: 数字(与 E-Ink 同纪律)', () {
final re = RegExp(r'fontSize:\s*\d');
final offenders = <String>[];
for (final path in _fontSizeCleanFiles) {
final src = File(path).readAsStringSync();
// 自检:路径写错不能静默通过(照抄 :56 的纪律)
expect(src.length, greaterThan(200), reason: '$path 读不到内容?');
final lines = src.split('\n');
for (var i = 0; i < lines.length; i++) {
if (re.hasMatch(lines[i])) {
offenders.add('$path:${i + 1} ${lines[i].trim()}');
}
}
}
expect(
offenders,
isEmpty,
reason: '10 / 10.5 / 11.5 已收敛到 PmonText.micro / caption,'
'要新尺寸请在 theme.dart 加语义档位:\n${offenders.join('\n')}',
);
});
test('界面文件就地 fontSize 总量不得增长(增量纪律)', () {
final re = RegExp(r'fontSize:\s*\d');
var total = 0;
for (final path in _uiFiles) {
total += re.allMatches(File(path).readAsStringSync()).length;
}
expect(
total,
lessThanOrEqualTo(_fontSizeBaseline),
reason: '现有 $_fontSizeBaseline 处是历史存量;新增字号一律走 '
'PmonText 语义档位,不许再写就地数字(当前 $total 处)',
);
});
});
group('② 令牌本身可用且自洽', () {
test('状态语义色映射齐全(在线/疑似离线/离线/未知)', () {
test('状态语义色映射齐全(6 行:online/suspect/offline/unknown/info/default,含文字色)', () {
expect(statusStyle('online').color, PmonColors.online);
expect(statusStyle('online').soft, PmonColors.onlineSoft);
expect(statusStyle('online').text, PmonColors.onlineText);
expect(statusStyle('suspect').color, PmonColors.suspect);
expect(statusStyle('suspect').soft, PmonColors.suspectSoft);
expect(statusStyle('suspect').text, PmonColors.suspectText);
expect(statusStyle('offline').color, PmonColors.offline);
expect(statusStyle('offline').soft, PmonColors.surfaceMuted);
expect(statusStyle('offline').text, PmonColors.offlineText);
// 未知**必须**是中性灰:把"不知道"染成告警色等于编造结论(诚实边界)
expect(statusStyle('unknown').color, PmonColors.offline);
expect(statusStyle('unknown').label, '状态未知');
expect(statusStyle('unknown').soft, PmonColors.surfaceMuted);
expect(statusStyle('unknown').text, PmonColors.unknownText);
// info:提示态走主色系,文字色独立(primarySoft 底 6.73:1)
expect(statusStyle('info').label, '提示');
expect(statusStyle('info').color, PmonColors.primary);
expect(statusStyle('info').soft, PmonColors.primarySoft);
expect(statusStyle('info').text, PmonColors.infoText);
// default 与 unknown 同值,不能漏掉这条兜底分支
expect(statusStyle('anything-else').label, '状态未知');
expect(statusStyle('anything-else').color, PmonColors.offline);
expect(statusStyle('anything-else').soft, PmonColors.surfaceMuted);
expect(statusStyle('anything-else').text, PmonColors.unknownText);
});
test('offlineText / unknownText 语义独立命名(不借道 textSecondary)', () {
final src = File('lib/ui/theme.dart').readAsStringSync();
expect(
RegExp(r'static const offlineText = Color\(0xFF4B5563\)').hasMatch(src),
isTrue,
reason: '与 textSecondary 同值也必须独立命名:语义优先于色值',
);
expect(
RegExp(r'static const unknownText = Color\(0xFF4B5563\)').hasMatch(src),
isTrue,
reason: 'unknown 是独立语义,不能写成 unknownText = offlineText',
);
});
test('C-15:本轮不引入 Flutter 深色皮肤装配', () {
final src = File('lib/ui/theme.dart').readAsStringSync();
for (final forbidden in [
'buildPmonDarkTheme',
'PmonDarkColors',
'Brightness.dark',
]) {
expect(
src.contains(forbidden),
isFalse,
reason: '本轮只在浅色皮肤内修缺陷;深色装配是独立阶段(05 §3.6)',
);
}
});
test('卡片装饰来自同一处(标准卡不用阴影,靠发丝线分层)', () {
+446
View File
@@ -0,0 +1,446 @@
/// 令牌双向护栏 —— CSS `tokens.css` ↔ Dart `theme.dart`(Phase 3b)
///
/// 运行:cd client && bash scripts/test.sh test/token_parity_test.dart
///
/// ── 为什么需要「双向」─────────────────────────────────────────────
/// 过去这两处是「各自维护的第二份」,改一处忘另一处就是同一个产品两种颜色
/// (`admin.css` 文件头那句"刻意的第二份,两处都要改"就是病根)。本测试把
/// 01 §8.3 的映射固化成一张表,对**两个方向**都判红:
/// · CSS 改值 / 删键 → 拦住(不只是「Dart 改了要跟 CSS」);
/// · Dart 改值 / 删常量 / 擅自补一套用不到的常量 → 同样拦住。
///
/// ── 三条纪律(05 §3.5 / C-16)────────────────────────────────────
/// 1. 值比对**只读 `tokens.css` 第一个 `:root`(浅色 Core)**。深色 58 键是
/// `html[data-iw-theme="dark"]` 的**同名覆写**,Dart 本轮不实现深色,
/// 故其覆写值全部按 `webOnly` 处理、不进 parity(见最后一组断言)。
/// 2. Web 专属键必须登记在 `kWebOnlyTokens` 白名单,且 Dart 侧**确实缺席**;
/// 若有人「好心」补一套用不到的常量,判红(防止重演两处漂移)。
/// 3. 文件读不到内容直接 fail(`expect(src.length, greaterThan(200))`)——
/// 照抄 `theme_guard_test.dart:56` 的自检,避免路径写错静默通过。
library;
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
typedef ParityEntry = ({String cssKey, String dartExpr, String kind});
/// 唯一映射表:01 §8.3 的映射 + 05 §3.1(6 个 `*-text`)/ §3.3(Web 专属)。
///
/// `kind` 语义:
/// · `both` —— 两侧都必须存在,归一化值必须相等(缺任一侧即判红)
/// · `derived` —— CSS 是 `calc(Npx * …)` 派生,按公式取基准 N 后与 Dart 比
/// · `webOnly` —— 只 CSS 有;`dartExpr` 写「若被误补会叫这个名字」,必须缺席
/// · `dartOnly`—— 只 Dart 有;`cssKey` 是「不该存在的键名」,必须缺席
const List<ParityEntry> kTokenParity = [
// ── 颜色:两侧都有常量(01 §8.3「继」+ 05 §3.1 新增 6 个 *-text)──
(cssKey: '--iw-page', dartExpr: 'PmonColors.page', kind: 'both'),
(cssKey: '--iw-surface', dartExpr: 'PmonColors.surface', kind: 'both'),
(cssKey: '--iw-surface-muted', dartExpr: 'PmonColors.surfaceMuted', kind: 'both'),
(cssKey: '--iw-line', dartExpr: 'PmonColors.line', kind: 'both'),
(cssKey: '--iw-text-primary', dartExpr: 'PmonColors.textPrimary', kind: 'both'),
(cssKey: '--iw-text-secondary', dartExpr: 'PmonColors.textSecondary', kind: 'both'),
(cssKey: '--iw-text-tertiary', dartExpr: 'PmonColors.textTertiary', kind: 'both'),
(cssKey: '--iw-primary', dartExpr: 'PmonColors.primary', kind: 'both'),
(cssKey: '--iw-primary-soft', dartExpr: 'PmonColors.primarySoft', kind: 'both'),
(cssKey: '--iw-online', dartExpr: 'PmonColors.online', kind: 'both'),
(cssKey: '--iw-online-soft', dartExpr: 'PmonColors.onlineSoft', kind: 'both'),
(cssKey: '--iw-online-text', dartExpr: 'PmonColors.onlineText', kind: 'both'),
(cssKey: '--iw-suspect', dartExpr: 'PmonColors.suspect', kind: 'both'),
(cssKey: '--iw-suspect-soft', dartExpr: 'PmonColors.suspectSoft', kind: 'both'),
(cssKey: '--iw-suspect-text', dartExpr: 'PmonColors.suspectText', kind: 'both'),
(cssKey: '--iw-offline', dartExpr: 'PmonColors.offline', kind: 'both'),
// statusStyle('offline'/'unknown').soft 就是 surfaceMuted(01 §8.3 明列)
(cssKey: '--iw-offline-soft', dartExpr: 'PmonColors.surfaceMuted', kind: 'both'),
(cssKey: '--iw-offline-text', dartExpr: 'PmonColors.offlineText', kind: 'both'),
// --iw-unknown 是 offline 的同值别名(01 §8.3 括号里的说明)
(cssKey: '--iw-unknown', dartExpr: 'PmonColors.offline', kind: 'both'),
(cssKey: '--iw-unknown-soft', dartExpr: 'PmonColors.surfaceMuted', kind: 'both'),
(cssKey: '--iw-unknown-text', dartExpr: 'PmonColors.unknownText', kind: 'both'),
(cssKey: '--iw-danger', dartExpr: 'PmonColors.danger', kind: 'both'),
(cssKey: '--iw-danger-soft', dartExpr: 'PmonColors.dangerSoft', kind: 'both'),
(cssKey: '--iw-danger-text', dartExpr: 'PmonColors.dangerText', kind: 'both'),
(cssKey: '--iw-info-text', dartExpr: 'PmonColors.infoText', kind: 'both'),
// chartLine = primary(Dart 语义别名,解析器会解引用)
(cssKey: '--iw-chart-line', dartExpr: 'PmonColors.chartLine', kind: 'both'),
(cssKey: '--iw-series-1', dartExpr: 'PmonColors.chartLine', kind: 'both'),
(cssKey: '--iw-gap', dartExpr: 'PmonColors.gap', kind: 'both'),
// E-Ink 六色:01 §8.3「PmonEink.* ↔ --iw-eink-*」
(cssKey: '--iw-eink-page', dartExpr: 'PmonEink.page', kind: 'both'),
(cssKey: '--iw-eink-ink', dartExpr: 'PmonEink.ink', kind: 'both'),
(cssKey: '--iw-eink-ink-soft', dartExpr: 'PmonEink.inkSoft', kind: 'both'),
(cssKey: '--iw-eink-rule', dartExpr: 'PmonEink.rule', kind: 'both'),
(cssKey: '--iw-eink-box', dartExpr: 'PmonEink.box', kind: 'both'),
(cssKey: '--iw-eink-fill', dartExpr: 'PmonEink.fill', kind: 'both'),
// ── 间距 / 圆角:两侧刻度相等(01 §8.3「继」)──
(cssKey: '--iw-space-xs', dartExpr: 'PmonSpace.xs', kind: 'both'),
(cssKey: '--iw-space-sm', dartExpr: 'PmonSpace.sm', kind: 'both'),
(cssKey: '--iw-space-md', dartExpr: 'PmonSpace.md', kind: 'both'),
(cssKey: '--iw-space-lg', dartExpr: 'PmonSpace.lg', kind: 'both'),
(cssKey: '--iw-space-xl', dartExpr: 'PmonSpace.xl', kind: 'both'),
(cssKey: '--iw-space-xxl', dartExpr: 'PmonSpace.xxl', kind: 'both'),
(cssKey: '--iw-radius-sm', dartExpr: 'PmonRadius.sm', kind: 'both'),
(cssKey: '--iw-radius-md', dartExpr: 'PmonRadius.md', kind: 'both'),
(cssKey: '--iw-radius-lg', dartExpr: 'PmonRadius.lg', kind: 'both'),
(cssKey: '--iw-radius-pill', dartExpr: 'PmonRadius.pill', kind: 'both'),
// ── 字阶:CSS 是 calc(基准 × scale),按基准值比(05 §3.4)──
(cssKey: '--iw-fs-metric', dartExpr: 'PmonText.metric', kind: 'derived'),
(cssKey: '--iw-fs-title', dartExpr: 'PmonText.title', kind: 'derived'),
(cssKey: '--iw-fs-stat', dartExpr: 'PmonText.stat', kind: 'derived'),
(cssKey: '--iw-fs-section', dartExpr: 'PmonText.section', kind: 'derived'),
(cssKey: '--iw-fs-body', dartExpr: 'PmonText.body', kind: 'derived'),
(cssKey: '--iw-fs-caption', dartExpr: 'PmonText.caption', kind: 'derived'),
(cssKey: '--iw-fs-micro', dartExpr: 'PmonText.micro', kind: 'derived'),
// ── Web 专属(05 §3.3):CSS 有、Dart 不补;dartExpr 是「应缺席」的名字 ──
(cssKey: '--iw-surface-hover', dartExpr: 'PmonColors.surfaceHover', kind: 'webOnly'),
(cssKey: '--iw-line-strong', dartExpr: 'PmonColors.lineStrong', kind: 'webOnly'),
(cssKey: '--iw-text-on-accent', dartExpr: 'PmonColors.textOnAccent', kind: 'webOnly'),
(cssKey: '--iw-primary-hover', dartExpr: 'PmonColors.primaryHover', kind: 'webOnly'),
(cssKey: '--iw-primary-active', dartExpr: 'PmonColors.primaryActive', kind: 'webOnly'),
(cssKey: '--iw-info-border', dartExpr: 'PmonColors.infoBorder', kind: 'webOnly'),
(cssKey: '--iw-online-border', dartExpr: 'PmonColors.onlineBorder', kind: 'webOnly'),
(cssKey: '--iw-suspect-border', dartExpr: 'PmonColors.suspectBorder', kind: 'webOnly'),
(cssKey: '--iw-offline-border', dartExpr: 'PmonColors.offlineBorder', kind: 'webOnly'),
(cssKey: '--iw-unknown-border', dartExpr: 'PmonColors.unknownBorder', kind: 'webOnly'),
(cssKey: '--iw-danger-hover', dartExpr: 'PmonColors.dangerHover', kind: 'webOnly'),
(cssKey: '--iw-danger-active', dartExpr: 'PmonColors.dangerActive', kind: 'webOnly'),
(cssKey: '--iw-danger-border', dartExpr: 'PmonColors.dangerBorder', kind: 'webOnly'),
(cssKey: '--iw-chart-area', dartExpr: 'PmonColors.chartArea', kind: 'webOnly'),
(cssKey: '--iw-chart-grid', dartExpr: 'PmonColors.chartGrid', kind: 'webOnly'),
(cssKey: '--iw-chart-axis', dartExpr: 'PmonColors.chartAxis', kind: 'webOnly'),
(cssKey: '--iw-chart-gap-band', dartExpr: 'PmonColors.chartGapBand', kind: 'webOnly'),
(cssKey: '--iw-chart-threshold-band', dartExpr: 'PmonColors.chartThresholdBand', kind: 'webOnly'),
(cssKey: '--iw-chart-threshold-line', dartExpr: 'PmonColors.chartThresholdLine', kind: 'webOnly'),
(cssKey: '--iw-series-2', dartExpr: 'PmonColors.series2', kind: 'webOnly'),
(cssKey: '--iw-series-3', dartExpr: 'PmonColors.series3', kind: 'webOnly'),
(cssKey: '--iw-series-4', dartExpr: 'PmonColors.series4', kind: 'webOnly'),
(cssKey: '--iw-logo-bg', dartExpr: 'PmonColors.logoBg', kind: 'webOnly'),
(cssKey: '--iw-logo-wave', dartExpr: 'PmonColors.logoWave', kind: 'webOnly'),
(cssKey: '--iw-logo-accent', dartExpr: 'PmonColors.logoAccent', kind: 'webOnly'),
(cssKey: '--iw-logo-mono', dartExpr: 'PmonColors.logoMono', kind: 'webOnly'),
(cssKey: '--iw-mask', dartExpr: 'PmonColors.mask', kind: 'webOnly'),
(cssKey: '--iw-snackbar-bg', dartExpr: 'PmonColors.snackbarBg', kind: 'webOnly'),
// 大屏专用字号,Flutter 端无大屏(05 §3.3)
(cssKey: '--iw-fs-display', dartExpr: 'PmonText.display', kind: 'webOnly'),
// ── Dart 专属:Flutter 有语义档位、CSS 刻意不设同名键 ──
(cssKey: '--iw-fs-body-muted', dartExpr: 'PmonText.bodyMuted', kind: 'dartOnly'),
(cssKey: '--iw-fs-metric-unit', dartExpr: 'PmonText.metricUnit', kind: 'dartOnly'),
];
/// Web 专属键白名单(05 §3.3 / C-16)。
/// 注:深色 58 键是**同名覆写**(键名与本表 / `both` 项重合),其覆写值按
/// C-16 全部视为 webOnly、不进 parity 值比对 —— 见「深色块」那组断言。
const Set<String> kWebOnlyTokens = {
'--iw-surface-hover',
'--iw-line-strong',
'--iw-text-on-accent',
'--iw-primary-hover',
'--iw-primary-active',
'--iw-info-border',
'--iw-online-border',
'--iw-suspect-border',
'--iw-offline-border',
'--iw-unknown-border',
'--iw-danger-hover',
'--iw-danger-active',
'--iw-danger-border',
'--iw-chart-area',
'--iw-chart-grid',
'--iw-chart-axis',
'--iw-chart-gap-band',
'--iw-chart-threshold-band',
'--iw-chart-threshold-line',
'--iw-series-2',
'--iw-series-3',
'--iw-series-4',
'--iw-logo-bg',
'--iw-logo-wave',
'--iw-logo-accent',
'--iw-logo-mono',
'--iw-mask',
'--iw-snackbar-bg',
'--iw-fs-display',
};
/// E-Ink 六档字阶:CSS 是 `PmonEinkText` 的镜像,纳入双向比对(05 §3.3)
const List<({String cssKey, String dartName})> kEinkFontParity = [
(cssKey: '--iw-eink-fs-value', dartName: 'value'),
(cssKey: '--iw-eink-fs-glyph', dartName: 'glyph'),
(cssKey: '--iw-eink-fs-title', dartName: 'title'),
(cssKey: '--iw-eink-fs-label', dartName: 'label'),
(cssKey: '--iw-eink-fs-action', dartName: 'action'),
(cssKey: '--iw-eink-fs-caption', dartName: 'caption'),
];
// ── 解析结果(main 里一次性装载)──────────────────────────────────
late Map<String, String> cssCore; // 第一个 :root(浅色 Core)
late Map<String, String> cssEngineering; // 第二个 :root(工程化补充)
late String cssClean;
late Map<String, String> dartColors; // PmonColors: name → FFRRGGBB
late Map<String, String> einkColors; // PmonEink: name → FFRRGGBB
// ⚠ PmonColors.page 与 PmonEink.page 同名,必须按 class 分段解析,
// 否则后者会覆盖前者,`--iw-page` 会被误判成 #FFFFFF 漂移。
late Map<String, String> dartAliases; // name → 另一个 name(如 chartLine → primary)
late Map<String, String> dartNumbers; // PmonSpace / PmonRadius
late Map<String, String> dartFontSizes; // PmonText
late Map<String, String> einkFontSizes; // PmonEinkText
late String dartClean;
Map<String, String> _parseKv(String body) {
final out = <String, String>{};
for (final m in RegExp(r'(--iw-[a-z0-9-]+)\s*:\s*([^;]+);').allMatches(body)) {
out[m.group(1)!] = m.group(2)!.trim();
}
return out;
}
String _classBody(String src, String name) =>
RegExp('class ' + name + r'\s*\{([\s\S]*?)\n\}').firstMatch(src)?.group(1) ?? '';
Map<String, String> _parseColors(String body) {
final out = <String, String>{};
for (final m in RegExp(
r'static const (\w+)\s*=\s*Color\(0xFF([0-9A-Fa-f]{6})\)')
.allMatches(body)) {
out[m.group(1)!] = 'FF${m.group(2)!.toUpperCase()}';
}
return out;
}
Map<String, String> _parseFontSizes(String body) {
final out = <String, String>{};
for (final m in RegExp(r'static const (\w+)\s*=\s*TextStyle\(([\s\S]*?)\);')
.allMatches(body)) {
final fs = RegExp(r'fontSize:\s*(\d+(?:\.\d+)?)').firstMatch(m.group(2)!);
if (fs != null) out[m.group(1)!] = fs.group(1)!;
}
return out;
}
/// 数值归一化:4.0 / 4px / 4 都记为 "4"
String _num(String s) {
final d = double.tryParse(s);
if (d == null) return s;
return d == d.roundToDouble() ? d.toInt().toString() : d.toString();
}
/// CSS 值归一化:hex 补 FF 前缀、calc 取基准、var() 解引用、px 去单位
String normalizeCss(String raw) {
var v = raw.trim();
final calc = RegExp(r'calc\(\s*(\d+(?:\.\d+)?)\s*px').firstMatch(v);
if (calc != null) return _num(calc.group(1)!);
final ref = RegExp(r'var\((--iw-[a-z0-9-]+)\)').firstMatch(v);
if (ref != null && cssCore.containsKey(ref.group(1))) {
return normalizeCss(cssCore[ref.group(1)!]!);
}
final hex6 = RegExp(r'^#([0-9A-Fa-f]{6})$').firstMatch(v);
if (hex6 != null) return 'FF${hex6.group(1)!.toUpperCase()}';
final px = RegExp(r'^(\d+(?:\.\d+)?)px$').firstMatch(v);
if (px != null) return _num(px.group(1)!);
final plain = RegExp(r'^(\d+(?:\.\d+)?)$').firstMatch(v);
if (plain != null) return _num(plain.group(1)!);
return v.toUpperCase();
}
/// 把 `dartExpr` 解析成可比值;返回 null 表示 Dart 侧**缺席**
String? resolveDart(String expr) {
if (expr.isEmpty) return null;
final name = expr.split('.').last;
final color = expr.startsWith('PmonEink.')
? einkColors[name]
: (dartColors[name] ?? dartColors[dartAliases[name]]);
if (color != null) return color;
if (expr.startsWith('PmonEinkText.')) return einkFontSizes[name];
final n = dartNumbers[name];
if (n != null) return _num(n);
final fs = dartFontSizes[name];
if (fs != null) return _num(fs);
return null;
}
void main() {
setUpAll(() {
// 唯一可编辑真相源在管理台 assets 下。
// 05 §3.5 ④ 给的 '../../server/…' 对应 cwd = client/test/;但 flutter test
// 实测 cwd = client/(theme_guard_test.dart 用 File('lib/main.dart') 成功可证),
// 此时应为 '../server/…'。两个候选都试,找到即做长度自检 ——
// 这样既不擅改规范指定路径,也不会因 cwd 差异静默失败。
const cssCandidates = [
'../../server/business/admin/assets/tokens.css',
'../server/business/admin/assets/tokens.css',
];
File? cssFile;
for (final p in cssCandidates) {
final f = File(p);
if (f.existsSync()) {
cssFile = f;
break;
}
}
expect(cssFile, isNotNull,
reason: '找不到 tokens.css(cwd=${Directory.current.path});'
'唯一可编辑真相源应在 server/business/admin/assets/ 下');
final cssSrc = cssFile!.readAsStringSync();
expect(cssSrc.length, greaterThan(200),
reason: 'tokens.css 读不到内容?路径写错不能静默通过');
cssClean = cssSrc.replaceAll(RegExp(r'/\*[\s\S]*?\*/'), '');
final roots = RegExp(r':root\s*\{([\s\S]*?)\}').allMatches(cssClean).toList();
expect(roots.length, greaterThanOrEqualTo(2),
reason: 'tokens.css 应有浅色 Core + 工程化补充两个 :root 块');
cssCore = _parseKv(roots[0].group(1)!);
cssEngineering = _parseKv(roots[1].group(1)!);
const dartPath = 'lib/ui/theme.dart';
final dartSrc = File(dartPath).readAsStringSync();
expect(dartSrc.length, greaterThan(200), reason: '$dartPath 读不到内容?');
dartClean = dartSrc.replaceAll(RegExp(r'//[^\n]*'), '');
dartColors = _parseColors(_classBody(dartClean, 'PmonColors'));
einkColors = _parseColors(_classBody(dartClean, 'PmonEink'));
dartAliases = <String, String>{};
for (final m in RegExp(r'static const (\w+)\s*=\s*(\w+);')
.allMatches(dartClean)) {
dartAliases[m.group(1)!] = m.group(2)!;
}
dartNumbers = <String, String>{};
for (final m in RegExp(r'static const (\w+)\s*=\s*(\d+\.\d+);')
.allMatches(dartClean)) {
dartNumbers[m.group(1)!] = m.group(2)!;
}
dartFontSizes = _parseFontSizes(_classBody(dartClean, 'PmonText'));
einkFontSizes = _parseFontSizes(_classBody(dartClean, 'PmonEinkText'));
});
group('① 双向值比对(只比浅色 Core)', () {
test('both / derived:两侧都存在且归一化值相等', () {
final offenders = <String>[];
var checked = 0;
for (final e in kTokenParity) {
if (e.kind != 'both' && e.kind != 'derived') continue;
checked++;
final cssVal = cssCore.containsKey(e.cssKey)
? normalizeCss(cssCore[e.cssKey]!)
: null;
final dartVal = resolveDart(e.dartExpr);
if (cssVal == null || dartVal == null || cssVal != dartVal) {
offenders.add('${e.cssKey.padRight(28)} css=${cssVal ?? "<缺>"}'
' ${e.dartExpr.padRight(26)} dart=${dartVal ?? "<缺>"}');
}
}
expect(checked, greaterThan(30), reason: '映射表不能缩水');
expect(offenders, isEmpty,
reason: '两侧令牌漂移(cssKey / cssValue / dartExpr / dartValue):\n'
'${offenders.join('\n')}');
});
test('反向缺项:CSS 侧每个颜色键都必须已登记,否则判红', () {
final registered = kTokenParity.map((e) => e.cssKey).toSet();
final colorKeys = cssCore.entries
.where((e) => RegExp(r'^#|^rgba?\(').hasMatch(e.value))
.map((e) => e.key)
.toList();
final missing = colorKeys.where((k) => !registered.contains(k)).toList();
expect(missing, isEmpty,
reason: 'tokens.css 新增了颜色键却没登记进 kTokenParity / '
'kWebOnlyTokens:\n${missing.join('\n')}');
});
test('反向缺项:Dart 侧每个颜色常量都必须已登记,否则判红', () {
final used = kTokenParity.map((e) => e.dartExpr.split('.').last).toSet();
final missing = [...dartColors.keys, ...einkColors.keys]
.where((k) => !used.contains(k))
.toList();
expect(missing, isEmpty,
reason: 'theme.dart 新增了色值常量却没同步 CSS 侧映射:\n'
'${missing.join('\n')}');
});
});
group('② Web 专属 / Dart 专属(缺席也要判红)', () {
test('webOnly:在白名单中、CSS 侧存在、Dart 侧确实缺席', () {
final offenders = <String>[];
var checked = 0;
for (final e in kTokenParity.where((x) => x.kind == 'webOnly')) {
checked++;
if (!kWebOnlyTokens.contains(e.cssKey)) {
offenders.add('${e.cssKey} 未登记进 kWebOnlyTokens');
}
if (!cssCore.containsKey(e.cssKey) &&
!cssEngineering.containsKey(e.cssKey)) {
offenders.add('${e.cssKey} 在 CSS 侧不存在(白名单条目写错?)');
}
final dartVal = resolveDart(e.dartExpr);
if (dartVal != null) {
offenders.add('${e.cssKey}: Dart 侧擅自补了 ${e.dartExpr}=$dartVal');
}
}
expect(checked, greaterThan(20), reason: 'Web 专属白名单不能缩水');
expect(offenders, isEmpty,
reason: 'Web 专属项必须「CSS 有 / Dart 无」:\n${offenders.join('\n')}');
});
test('kWebOnlyTokens 白名单本身没有悬空条目', () {
final dangling = kWebOnlyTokens
.where((k) => !cssCore.containsKey(k) && !cssEngineering.containsKey(k))
.toList();
expect(dangling, isEmpty,
reason: '白名单里有 CSS 侧不存在的键:\n${dangling.join('\n')}');
});
test('dartOnly:CSS 侧确实没有该键,Dart 侧确实存在', () {
for (final e in kTokenParity.where((x) => x.kind == 'dartOnly')) {
expect(cssCore.containsKey(e.cssKey), isFalse,
reason: '${e.cssKey} 不该在 CSS 侧出现(Dart 专属语义档位)');
expect(resolveDart(e.dartExpr), isNotNull,
reason: '${e.dartExpr} 在 Dart 侧应存在');
}
});
});
group('③ E-Ink 字阶与深色边界', () {
test('--iw-eink-fs-* 六档与 PmonEinkText 逐档相等', () {
final offenders = <String>[];
for (final e in kEinkFontParity) {
final cssVal = cssEngineering.containsKey(e.cssKey)
? normalizeCss(cssEngineering[e.cssKey]!)
: null;
final dartVal = einkFontSizes[e.dartName];
if (cssVal == null || dartVal == null || cssVal != _num(dartVal)) {
offenders.add('${e.cssKey} css=$cssVal '
'PmonEinkText.${e.dartName} dart=$dartVal');
}
}
expect(offenders, isEmpty,
reason: 'E-Ink 字阶是 PmonEinkText 的镜像,必须逐档相等:\n'
'${offenders.join('\n')}');
expect(einkFontSizes.length, 6, reason: 'E-Ink 字阶必须是完整的 6 档');
});
test('深色 58 键是同名覆写:零新增键名、不进 parity 值比对', () {
final darkBody = RegExp(r'html\[data-iw-theme="dark"\]\s*\{([\s\S]*?)\}')
.firstMatch(cssClean)
?.group(1) ??
'';
final darkKeys = RegExp(r'(--iw-[a-z0-9-]+)\s*:')
.allMatches(darkBody)
.map((m) => m.group(1)!)
.toList();
expect(darkKeys.length, 58, reason: '05 §1.4 的 58 个同名键覆写');
expect(darkKeys.toSet().length, 58, reason: '深色块不得有重复键');
for (final k in darkKeys) {
expect(cssCore.containsKey(k), isTrue,
reason: '$k 只在深色块出现 —— 新增键名会漏进 E-Ink 分支(B2)');
}
// 不进 parity:值比对的唯一数据源是首个 :root;Dart 侧本轮不实现深色
expect(dartClean.contains('PmonDarkColors'), isFalse);
expect(dartClean.contains('buildPmonDarkTheme'), isFalse);
expect(dartClean.contains('Brightness.dark'), isFalse);
// dark 块必须排在 E-Ink 块之前(G2 层叠顺序)
expect(cssClean.indexOf('html[data-iw-theme="dark"]'),
lessThan(cssClean.indexOf('html[data-iw-surface="eink"]')));
});
});
}
+2
View File
@@ -7,6 +7,8 @@
<meta name="robots" content="noindex, nofollow">
<meta name="color-scheme" content="light">
<title>账号 · 墨观管理台</title>
<script>(function(){try{var t=localStorage.getItem('inkwatch.theme');document.documentElement.setAttribute('data-iw-theme',(t==='dark'||t==='light')?t:'light');}catch(e){document.documentElement.setAttribute('data-iw-theme','light');}})();</script>
<link rel="stylesheet" href="assets/tokens.css">
<link rel="stylesheet" href="assets/admin.css">
</head>
<body data-page="accounts">
+132 -103
View File
@@ -4,44 +4,24 @@
* 理由不是洁癖 —— 管理台经常要在**内网/隔离网**里打开,一次外链失败就是整页白屏;
* 而且外链脚本本身就是一个能被劫持的执行面(管理台持有一个能封号的口令)。
*
* 色值取自客户端 `client/lib/ui/theme.dart` 的浅色令牌,保证"同一个产品"的观感。
* Web 端不可能直接复用 Dart 常量,故此处是**刻意的第二份**;
* 若日后主色调整,两处都要改(管理台不是终端用户界面,不做跨栈一致性测试)。
* 唯一真相源为 `assets/tokens.css`,与 Flutter `theme.dart` 由
* `token_parity_test.dart` 双向护栏锁定。本文件只消费 `var(--iw-*)`:
* 深色由 tokens.css 的 `html[data-iw-theme="dark"]` 同名键覆写自动跟随,
* 此处零深色专属 CSS、零 @media 皮肤分支、零 JS 皮肤分支。
*/
:root {
--page: #f6f7f9;
--surface: #ffffff;
--line: #e5e7eb;
--muted-bg: #f3f4f6;
--text: #111827;
--text-2: #4b5563;
--text-3: #8a93a3;
--primary: #2563eb;
--primary-soft: #eff4ff;
--online: #16a34a;
--online-soft: #eaf7ef;
--warn: #d97706;
--warn-soft: #fef6e7;
--offline: #9ca3af;
--danger: #dc2626;
--danger-soft: #fdecec;
--radius: 10px;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: var(--page);
color: var(--text);
background: var(--iw-page);
color: var(--iw-text-primary);
font: 14px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
}
a { color: var(--primary); text-decoration: none; }
a { color: var(--iw-primary); text-decoration: none; }
a:hover { text-decoration: underline; }
/* ── 顶栏 ───────────────────────────────────────── */
@@ -49,23 +29,55 @@ a:hover { text-decoration: underline; }
position: sticky; top: 0; z-index: 20;
display: flex; align-items: center; gap: 20px;
padding: 0 24px; height: 52px;
background: var(--surface);
border-bottom: 1px solid var(--line);
background: var(--iw-surface);
border-bottom: 1px solid var(--iw-line);
}
.brand { font-weight: 600; letter-spacing: .2px; white-space: nowrap; }
.brand .tag {
margin-left: 8px; padding: 1px 7px; border-radius: 999px;
background: var(--primary-soft); color: var(--primary);
margin-left: 8px; padding: 1px 7px; border-radius: var(--iw-radius-pill);
background: var(--iw-primary-soft); color: var(--iw-primary);
font-size: 11px; font-weight: 500; vertical-align: 2px;
}
.nav { display: flex; gap: 2px; flex: 1; overflow-x: auto; }
.nav a {
padding: 6px 12px; border-radius: 8px; color: var(--text-2);
padding: 6px 12px; border-radius: var(--iw-radius-sm); color: var(--iw-text-secondary);
font-size: 13px; white-space: nowrap;
}
.nav a:hover { background: var(--muted-bg); text-decoration: none; }
.nav a.active { background: var(--primary-soft); color: var(--primary); font-weight: 500; }
.topbar .who { color: var(--text-3); font-size: 12px; white-space: nowrap; }
.nav a:hover { background: var(--iw-surface-muted); text-decoration: none; }
.nav a.active { background: var(--iw-primary-soft); color: var(--iw-primary); font-weight: 500; }
.topbar .who { color: var(--iw-text-tertiary); font-size: 12px; white-space: nowrap; }
/* ── 主题切换按钮 ───────────────────────────────────
* 图标按钮形态沿用 06 §1.9 的命名;尺寸全部取自 tokens.css 既有
* 端侧派生令牌(桌面 32 / 移动 44 / 大屏 40),零新增刻度。
* 选中态只表达「深色已启用」这一组件状态,不含任何深色专属色值。 */
.iw-btn.iw-btn--icon {
display: inline-flex; align-items: center; justify-content: center;
width: var(--iw-control-h); height: var(--iw-control-h);
min-width: var(--iw-target-min); min-height: var(--iw-target-min);
padding: 0; flex: 0 0 auto;
border: 1px solid var(--iw-line); border-radius: var(--iw-radius-sm);
background: var(--iw-surface); color: var(--iw-text-secondary);
cursor: pointer;
}
.iw-btn.iw-btn--icon:hover { background: var(--iw-surface-muted); color: var(--iw-text-primary); }
.iw-btn.iw-btn--icon:active { background: var(--iw-surface-hover); }
.iw-btn.iw-btn--icon.is-selected {
background: var(--iw-primary-soft); color: var(--iw-info-text); border-color: var(--iw-primary);
}
.iw-btn.iw-btn--icon:focus-visible {
outline: var(--iw-focus-ring); outline-offset: var(--iw-focus-offset);
}
.iw-btn.iw-btn--icon .iw-icon { width: var(--iw-icon-md); height: var(--iw-icon-md); display: block; }
/* 单一 live region:视觉隐藏,仅承载主题切换播报(polite) */
.iw-sr-only {
position: absolute; width: var(--iw-sr-unit); height: var(--iw-sr-unit);
padding: 0; margin: calc(-1 * var(--iw-sr-unit)); overflow: hidden;
clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.iw-appearance { display: flex; align-items: center; gap: var(--iw-space-md); flex-wrap: wrap; }
.iw-appearance .hint { color: var(--iw-text-secondary); font-size: 13px; line-height: 1.5; }
/* ── 布局 ───────────────────────────────────────── */
.wrap { max-width: 1400px; margin: 0 auto; padding: 20px 24px 64px; }
@@ -75,72 +87,75 @@ a:hover { text-decoration: underline; }
grid-template-columns: repeat(auto-fit, minmax(166px, 1fr));
}
.card {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 14px 16px;
background: var(--iw-surface); border: 1px solid var(--iw-line);
border-radius: var(--iw-radius-md); padding: 14px 16px;
}
.card .k { color: var(--text-3); font-size: 12px; }
.card .k { color: var(--iw-text-tertiary); font-size: 12px; }
.card .v { font-size: 24px; font-weight: 600; margin-top: 2px; font-variant-numeric: tabular-nums; }
.card .v small { font-size: 12px; font-weight: 400; color: var(--text-3); margin-left: 4px; }
.card.alert .v { color: var(--warn); }
.card.bad .v { color: var(--danger); }
.card .v small { font-size: 12px; font-weight: 400; color: var(--iw-text-tertiary); margin-left: 4px; }
.card.alert .v { color: var(--iw-suspect); }
.card.bad .v { color: var(--iw-danger); }
.panel {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); margin-bottom: 20px; overflow: hidden;
background: var(--iw-surface); border: 1px solid var(--iw-line);
border-radius: var(--iw-radius-md); margin-bottom: 20px; overflow: hidden;
}
.panel > h2 {
margin: 0; padding: 14px 16px; font-size: 14px; font-weight: 600;
border-bottom: 1px solid var(--line);
border-bottom: 1px solid var(--iw-line);
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
}
.panel > h2 .sub { font-weight: 400; color: var(--text-3); font-size: 12px; }
.panel > h2 .sub { font-weight: 400; color: var(--iw-text-tertiary); font-size: 12px; }
/* 卡片标题的副标题:必须与标题有间距且更轻,否则读起来是同一句话
(截图里"实例配置保存在数据库,改完立刻生效"黏成一串,看不出层级)。 */
.card > h2 .sub,
.card h2 .sub { margin-left: 8px; font-weight: 400; color: var(--text-3); font-size: 12px; }
.card h2 .sub { margin-left: 8px; font-weight: 400; color: var(--iw-text-tertiary); font-size: 12px; }
.panel > .body { padding: 16px; }
.panel > .body.flush { padding: 0; }
/* ── 工具条 ─────────────────────────────────────── */
.toolbar {
display: flex; gap: 8px; flex-wrap: wrap; align-items: center;
padding: 12px 16px; border-bottom: 1px solid var(--line); background: #fcfcfd;
padding: 12px 16px; border-bottom: 1px solid var(--iw-line); background: var(--iw-surface);
}
input[type="text"], input[type="search"], input[type="number"],
input[type="datetime-local"], select {
padding: 6px 10px; border: 1px solid var(--line); border-radius: 8px;
background: var(--surface); color: var(--text); font: inherit; font-size: 13px;
padding: 6px 10px; border: 1px solid var(--iw-line); border-radius: var(--iw-radius-sm);
background: var(--iw-surface); color: var(--iw-text-primary); font: inherit; font-size: 13px;
min-width: 0;
}
input:focus, select:focus { outline: 2px solid var(--primary-soft); border-color: var(--primary); }
input:focus, select:focus {
outline: var(--iw-focus-ring); outline-offset: var(--iw-focus-offset);
border-color: var(--iw-primary);
}
input[type="search"] { min-width: 220px; }
.btn {
padding: 6px 12px; border: 1px solid var(--line); border-radius: 8px;
background: var(--surface); color: var(--text); font: inherit; font-size: 13px;
padding: 6px 12px; border: 1px solid var(--iw-line); border-radius: var(--iw-radius-sm);
background: var(--iw-surface); color: var(--iw-text-primary); font: inherit; font-size: 13px;
cursor: pointer; white-space: nowrap;
}
.btn:hover { background: var(--muted-bg); }
.btn.primary { background: var(--primary); border-color: var(--primary); color: #fff; }
.btn.primary:hover { filter: brightness(1.06); }
.btn.danger { background: var(--surface); border-color: #f3c9c9; color: var(--danger); }
.btn.danger:hover { background: var(--danger-soft); }
.btn:hover { background: var(--iw-surface-muted); }
.btn.primary { background: var(--iw-primary); border-color: var(--iw-primary); color: var(--iw-text-on-accent); }
.btn.primary:hover { background: var(--iw-primary-hover); border-color: var(--iw-primary-hover); }
.btn.danger { background: var(--iw-surface); border-color: var(--iw-danger-border); color: var(--iw-danger); }
.btn.danger:hover { background: var(--iw-danger-soft); border-color: var(--iw-danger-hover); }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn.sm { padding: 3px 9px; font-size: 12px; }
/* ── 表格 ───────────────────────────────────────── */
table.tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
table.tbl th, table.tbl td {
text-align: left; padding: 9px 12px; border-bottom: 1px solid var(--line);
text-align: left; padding: 9px 12px; border-bottom: 1px solid var(--iw-line);
white-space: nowrap; vertical-align: middle;
}
table.tbl th {
color: var(--text-3); font-weight: 500; font-size: 12px;
background: #fcfcfd; position: sticky; top: 52px; z-index: 5;
color: var(--iw-text-tertiary); font-weight: 500; font-size: 12px;
background: var(--iw-page); position: sticky; top: 52px; z-index: 5;
}
table.tbl th.sortable { cursor: pointer; user-select: none; }
table.tbl th.sortable:hover { color: var(--text); }
table.tbl tbody tr:hover { background: #fafbfc; }
table.tbl th.sortable:hover { color: var(--iw-text-primary); }
table.tbl tbody tr:hover { background: var(--iw-surface-hover); }
table.tbl td.num { text-align: right; font-variant-numeric: tabular-nums; }
table.tbl td.wrap-ok { white-space: normal; min-width: 220px; word-break: break-all; }
/* 长 JSON 字段(审计 detail):定宽 + 省略号 + 悬停看全文。
@@ -154,115 +169,129 @@ table.tbl td.clipcell > div {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
table.tbl tr.clickable { cursor: pointer; }
.empty { padding: 40px; text-align: center; color: var(--text-3); }
.empty { padding: 40px; text-align: center; color: var(--iw-text-tertiary); }
/* ── 徽标 ───────────────────────────────────────── */
.pill {
display: inline-block; padding: 1px 8px; border-radius: 999px;
display: inline-block; padding: 1px 8px; border-radius: var(--iw-radius-pill);
font-size: 11px; font-weight: 500; white-space: nowrap;
}
.pill.ok { background: var(--online-soft); color: var(--online); }
.pill.warn { background: var(--warn-soft); color: var(--warn); }
.pill.bad { background: var(--danger-soft); color: var(--danger); }
.pill.mute { background: var(--muted-bg); color: var(--text-3); }
.pill.info { background: var(--primary-soft); color: var(--primary); }
.pill.ok { background: var(--iw-online-soft); color: var(--iw-online); }
.pill.warn { background: var(--iw-suspect-soft); color: var(--iw-suspect); }
.pill.bad { background: var(--iw-danger-soft); color: var(--iw-danger); }
.pill.mute { background: var(--iw-surface-muted); color: var(--iw-text-tertiary); }
.pill.info { background: var(--iw-primary-soft); color: var(--iw-primary); }
.pill + .pill { margin-left: 4px; }
.mono { font-family: var(--mono); font-size: 12px; }
.muted { color: var(--text-3); }
.text2 { color: var(--text-2); }
.mono { font-family: var(--iw-font-mono); font-size: 12px; }
.muted { color: var(--iw-text-tertiary); }
.text2 { color: var(--iw-text-secondary); }
.num { font-variant-numeric: tabular-nums; }
.nowrap { white-space: nowrap; }
/* ── 分页 ───────────────────────────────────────── */
.pager {
display: flex; align-items: center; gap: 10px; justify-content: flex-end;
padding: 10px 16px; border-top: 1px solid var(--line); color: var(--text-3); font-size: 12px;
padding: 10px 16px; border-top: 1px solid var(--iw-line); color: var(--iw-text-tertiary); font-size: 12px;
}
/* ── 图表 ───────────────────────────────────────── */
.chart { width: 100%; display: block; overflow: visible; }
.chart .grid { stroke: var(--line); stroke-width: 1; }
.chart .axis { fill: var(--text-3); font-size: 11px; }
.chart .line { fill: none; stroke: var(--primary); stroke-width: 2; stroke-linejoin: round; }
.chart .area { fill: var(--primary-soft); }
.chart .dot { fill: var(--primary); }
.legend { display: flex; gap: 16px; font-size: 12px; color: var(--text-2); padding: 0 16px 12px; }
.legend i { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 5px; }
.chart .grid { stroke: var(--iw-chart-grid); stroke-width: 1; }
.chart .axis { fill: var(--iw-chart-axis); font-size: 11px; }
.chart .line { fill: none; stroke: var(--iw-chart-line); stroke-width: 2; stroke-linejoin: round; }
.chart .area { fill: var(--iw-chart-area); }
.chart .dot { fill: var(--iw-chart-line); }
/* 总览双序列:颜色也走令牌,深色自动跟随(不再用内联 hex) */
.chart .line.s-a { stroke: var(--iw-series-1); }
.chart .line.s-b { stroke: var(--iw-suspect); }
.legend { display: flex; gap: 16px; font-size: 12px; color: var(--iw-text-secondary); padding: 0 16px 12px; }
.legend i {
display: inline-block; width: 10px; height: 10px;
border-radius: var(--iw-radius-sm); margin-right: 5px;
}
.legend i.sw-a { background: var(--iw-series-1); }
.legend i.sw-b { background: var(--iw-suspect); }
/* ── 抽屉(详情) ───────────────────────────────── */
.drawer-mask {
position: fixed; inset: 0; background: rgba(17, 24, 39, .28);
position: fixed; inset: 0; background: var(--iw-mask);
opacity: 0; pointer-events: none; transition: opacity .16s; z-index: 40;
}
.drawer-mask.open { opacity: 1; pointer-events: auto; }
.drawer {
position: fixed; top: 0; right: 0; bottom: 0; width: min(720px, 92vw);
background: var(--surface); border-left: 1px solid var(--line);
background: var(--iw-surface); border-left: 1px solid var(--iw-line);
transform: translateX(100%); transition: transform .2s ease;
z-index: 41; display: flex; flex-direction: column;
}
.drawer.open { transform: translateX(0); }
.drawer header {
display: flex; align-items: center; gap: 12px;
padding: 14px 18px; border-bottom: 1px solid var(--line);
padding: 14px 18px; border-bottom: 1px solid var(--iw-line);
}
.drawer header h3 { margin: 0; font-size: 15px; flex: 1; overflow: hidden; text-overflow: ellipsis; }
.drawer .scroll { overflow-y: auto; padding: 16px 18px 40px; flex: 1; }
.drawer h4 {
margin: 20px 0 8px; font-size: 13px; color: var(--text-2);
margin: 20px 0 8px; font-size: 13px; color: var(--iw-text-secondary);
display: flex; align-items: center; gap: 8px;
}
.drawer h4:first-child { margin-top: 0; }
.drawer .acts { display: flex; gap: 8px; flex-wrap: wrap; margin: 10px 0; }
.kv { display: grid; grid-template-columns: 132px 1fr; gap: 4px 12px; font-size: 13px; }
.kv dt { color: var(--text-3); }
.kv dt { color: var(--iw-text-tertiary); }
.kv dd { margin: 0; word-break: break-all; }
/* ── 登录遮罩 ───────────────────────────────────── */
#auth-overlay {
position: fixed; inset: 0; z-index: 60; display: flex;
align-items: center; justify-content: center; background: var(--page);
align-items: center; justify-content: center; background: var(--iw-page);
}
#auth-overlay[hidden] { display: none; }
.auth-box {
width: min(420px, 92vw); background: var(--surface); border: 1px solid var(--line);
border-radius: 14px; padding: 26px;
width: min(420px, 92vw); background: var(--iw-surface); border: 1px solid var(--iw-line);
border-radius: var(--iw-radius-lg); padding: 26px;
}
.auth-box h1 { margin: 0 0 6px; font-size: 17px; }
.auth-box p { margin: 0 0 16px; color: var(--text-3); font-size: 13px; }
.auth-box p { margin: 0 0 16px; color: var(--iw-text-tertiary); font-size: 13px; }
.auth-box input {
width: 100%; padding: 9px 12px; font-family: var(--mono); margin-bottom: 12px;
width: 100%; padding: 9px 12px; font-family: var(--iw-font-mono); margin-bottom: 12px;
}
.auth-box .btn { width: 100%; justify-content: center; padding: 9px; }
.auth-box .err { color: var(--danger); font-size: 12px; margin-bottom: 10px; min-height: 18px; }
.auth-box .err { color: var(--iw-danger); font-size: 12px; margin-bottom: 10px; min-height: 18px; }
/* 登录方式切换:管理员账号 / 应急令牌。
两种身份的存在理由不同 —— 日常用人账号(可审计到人、可改口令),
应急令牌用于账号丢失或自动化脚本,所以不能藏在"高级选项"里让人找不到。 */
.auth-box .tabs { display: flex; gap: 6px; margin-bottom: 16px; }
.auth-box .tab {
flex: 1; padding: 7px 10px; font-size: 13px; border: 1px solid var(--line);
background: var(--surface); color: var(--text-3); border-radius: 8px; cursor: pointer;
flex: 1; padding: 7px 10px; font-size: 13px; border: 1px solid var(--iw-line);
background: var(--iw-surface); color: var(--iw-text-tertiary);
border-radius: var(--iw-radius-sm); cursor: pointer;
font-family: inherit;
}
.auth-box .tab.on { background: var(--primary-soft); color: var(--primary); border-color: var(--primary); font-weight: 500; }
.auth-box .hint { font-size: 12px; color: var(--text-3); margin: 12px 0 0; line-height: 1.5; }
.auth-box .tab.on { background: var(--iw-primary-soft); color: var(--iw-primary); border-color: var(--iw-primary); font-weight: 500; }
.auth-box .hint { font-size: 12px; color: var(--iw-text-tertiary); margin: 12px 0 0; line-height: 1.5; }
/* ── 提示条 ─────────────────────────────────────── */
.notice {
padding: 10px 14px; border-radius: 8px; font-size: 13px; margin-bottom: 14px;
padding: 10px 14px; border-radius: var(--iw-radius-sm); font-size: 13px; margin-bottom: 14px;
}
.notice.info { background: var(--primary-soft); color: #1e40af; }
.notice.warn { background: var(--warn-soft); color: #92400e; }
.notice.bad { background: var(--danger-soft); color: #991b1b; }
.notice.ok { background: var(--online-soft); color: #166534; }
.notice.info { background: var(--iw-primary-soft); color: var(--iw-info-text); }
.notice.warn { background: var(--iw-suspect-soft); color: var(--iw-suspect-text); }
.notice.bad { background: var(--iw-danger-soft); color: var(--iw-danger-text); }
.notice.ok { background: var(--iw-online-soft); color: var(--iw-online-text); }
/* Toast:深色下 --iw-snackbar-bg 反相为浅底,故文字色必须是深色的
--iw-surface,而不是 --iw-text-on-accent / 白字(唯一显式处理)。
transition 只保留位移与透明度,避免整肤时背景色产生过渡动画。 */
#toast {
position: fixed; left: 50%; bottom: 32px; transform: translateX(-50%) translateY(12px);
background: #111827; color: #fff; padding: 9px 18px; border-radius: 999px;
font-size: 13px; opacity: 0; pointer-events: none; transition: all .2s; z-index: 70;
background: var(--iw-snackbar-bg); color: var(--iw-surface);
padding: 9px 18px; border-radius: var(--iw-radius-pill);
font-size: 13px; opacity: 0; pointer-events: none;
transition: opacity .2s, transform .2s; z-index: 70;
}
#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
#toast.bad { background: var(--danger); }
#toast.bad { background: var(--iw-danger); }
+206 -6
View File
@@ -204,6 +204,193 @@
});
}
// ───────────────────── 外观 / 主题控制器 ─────────────────────
/*
* 6 个静态页共用的唯一主题控制器(06 §1.5 / §8.1 C-3)。
* - 存储键只以 THEME_KEY 常量出现一次,其余一律引用该变量。
* - 首屏防闪脚本已在各页 <head> 读过一次偏好;这里负责「控制器」部分:
* 读取回退、写入回退、两处控件同步、storage 跨标签、E-Ink 互斥、
* 单一 live region 播报。皮肤本身零 JS —— 只翻 data-iw-theme 属性,
* 颜色由 tokens.css 的同名键覆写自动跟随。
* - 不发遥测、不进 URL、不写 cookie、不读系统 prefers-color-scheme。
*/
var IwTheme = (function () {
var THEME_KEY = 'inkwatch.theme'; // 运行时源码中该字面量的唯一命中处
var LIGHT = 'light', DARK = 'dark';
var LABEL = {
light: '外观:当前浅色,切换到深色',
dark: '外观:当前深色,切换到浅色'
};
var LABEL_FALLBACK = '外观:当前浅色(偏好读取失败已回退),切换到深色';
// 图标表示「目标动作」:浅色 → 月亮(去深色);深色 → 太阳(去浅色)
var ICON_MOON = '<svg class="iw-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"'
+ ' stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
+ '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>';
var ICON_SUN = '<svg class="iw-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"'
+ ' stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
+ '<circle cx="12" cy="12" r="4"/>'
+ '<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41'
+ 'M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/></svg>';
var liveRegion = null;
var readFailed = false; // 读取异常 / 非法值 → 回退态(不假装偏好正常)
var guard = false; // MutationObserver 递归保护
function root() { return document.documentElement; }
/** 读偏好:无键默认 light 且不预写;异常 / 非法值 → light 并标记回退(静默、不写回) */
function readStored() {
try {
var v = window.localStorage.getItem(THEME_KEY);
if (v === null) return LIGHT;
if (v === LIGHT || v === DARK) return v;
readFailed = true;
return LIGHT;
} catch (e) {
readFailed = true;
return LIGHT;
}
}
/** 当前生效主题:E-Ink 下强制 light,但持久化偏好绝不清(二层模型) */
function effective(stored) {
return root().getAttribute('data-iw-surface') === 'eink' ? LIGHT : stored;
}
/** 单一 live region(polite):一次切换只写一次 */
function announce(msg) {
if (!liveRegion) return;
liveRegion.textContent = msg;
}
function labelFor(theme) {
if (theme === LIGHT && readFailed) return LABEL_FALLBACK;
return LABEL[theme] || LABEL[LIGHT];
}
/** 同步全部控件:aria-pressed / aria-label / .is-selected / 图标;按钮节点不重建 */
function syncButtons(theme) {
var nodes = document.querySelectorAll('[data-iw-theme-toggle]');
var pressed = theme === DARK ? 'true' : 'false';
var icon = theme === DARK ? ICON_SUN : ICON_MOON;
for (var i = 0; i < nodes.length; i++) {
var b = nodes[i];
b.setAttribute('aria-pressed', pressed);
b.setAttribute('aria-label', labelFor(theme));
if (b.getAttribute('data-icon') !== theme) {
b.innerHTML = icon; // 只换内部图标,<button> 本身保持同一 DOM 节点
b.setAttribute('data-icon', theme);
}
if (theme === DARK) b.classList.add('is-selected');
else b.classList.remove('is-selected');
}
}
/** 把偏好落到 DOM:一次属性翻转,零动画;返回实际生效主题 */
function apply(stored) {
var theme = effective(stored);
if (root().getAttribute('data-iw-theme') !== theme) {
guard = true;
root().setAttribute('data-iw-theme', theme);
guard = false;
}
syncButtons(theme);
return theme;
}
/** 两处控件的统一 HTML(唯一定义) */
function buttonHtml() {
return '<button type="button" class="iw-btn iw-btn--icon" data-iw-theme-toggle'
+ ' aria-pressed="false" aria-label="' + LABEL[LIGHT] + '"></button>';
}
function toggle() {
// E-Ink 是 256 灰阶物理设备,无主题概念:不切换、不播报
if (root().getAttribute('data-iw-surface') === 'eink') return;
var current = effective(readStored());
var next = current === DARK ? LIGHT : DARK;
var saved = true;
try {
window.localStorage.setItem(THEME_KEY, next);
} catch (e) {
saved = false; // 视觉仍即时切换,但如实播报
}
readFailed = false; // 用户已显式设定,解除回退态
apply(next);
if (!saved) announce('外观已切换,但偏好未能保存,本次仅在当前页面生效');
else announce(next === DARK ? '已切换到深色模式' : '已切换到浅色模式');
}
/** 宿主切换 E-Ink / 普通端:切 E-Ink 不清偏好、不播报;切回播报一次 */
function setSurface(surface) {
var before = root().getAttribute('data-iw-surface');
if (surface) root().setAttribute('data-iw-surface', surface);
else root().removeAttribute('data-iw-surface');
var theme = apply(readStored());
if (before === 'eink' && surface !== 'eink') {
announce(theme === DARK ? '已恢复深色模式' : '已恢复浅色模式');
}
}
/** 事件委托:控件何时创建 / 重建都能命中,无需逐处绑定 */
function onClick(e) {
var t = e.target;
while (t && t !== document) {
if (t.getAttribute && t.getAttribute('data-iw-theme-toggle') !== null) {
toggle();
return;
}
t = t.parentNode;
}
}
/** 同源跨标签同步:只重设属性与控件,不播报 */
function onStorage(e) {
if (e.key !== THEME_KEY && e.key !== null) return;
readFailed = false;
apply(readStored());
}
/** 硬护栏:eink 与 dark 绝不共存(外部直接改属性也拦得住) */
function onMutation() {
if (guard) return;
if (root().getAttribute('data-iw-surface') === 'eink'
&& root().getAttribute('data-iw-theme') === DARK) {
guard = true;
root().setAttribute('data-iw-theme', LIGHT);
guard = false;
syncButtons(LIGHT);
}
}
function init() {
liveRegion = el('<div id="iw-live" class="iw-sr-only" role="status"'
+ ' aria-live="polite" aria-atomic="true"></div>');
document.body.appendChild(liveRegion);
readFailed = false;
apply(readStored());
if (readFailed) announce('外观偏好读取失败,已回退到浅色模式');
document.addEventListener('click', onClick);
window.addEventListener('storage', onStorage);
if (window.MutationObserver) {
var mo = new MutationObserver(onMutation);
mo.observe(root(), { attributes: true, attributeFilter: ['data-iw-surface', 'data-iw-theme'] });
}
}
return {
init: init,
sync: function () { syncButtons(effective(readStored())); },
buttonHtml: buttonHtml,
setSurface: setSurface,
current: function () { return effective(readStored()); }
};
})();
// 宿主 / 自动化断言的受控出口(管理台自身不调用,仅暴露一个命名空间)
window.IwTheme = IwTheme;
// ───────────────────────── 提示 ─────────────────────────
var toastTimer = null;
@@ -227,6 +414,8 @@
+ esc(n.label) + '</a>';
}).join('')
+ '</nav>'
// 位置①:顶栏 who 之前(showWho 把 .who 插在 #btn-reauth 前,故本按钮恒在其前)
+ IwTheme.buttonHtml()
+ '<button class="btn sm" id="btn-reauth">换身份</button>'
+ '<button class="btn sm" id="btn-logout">退出</button>';
$('#btn-logout').addEventListener('click', function () {
@@ -410,8 +599,8 @@
: '')
+ '<div class="panel"><h2>近 14 天 <span class="sub">新增账号 / 新增设备 · 区间内共 '
+ esc(fmtNum(totalFrames)) + ' 帧</span></h2>'
+ '<div class="legend"><span><i style="background:#2563eb"></i>新增账号</span>'
+ '<span><i style="background:#d97706"></i>新增设备</span></div>'
+ '<div class="legend"><span><i class="sw-a"></i>新增账号</span>'
+ '<span><i class="sw-b"></i>新增设备</span></div>'
+ '<div class="body">'
+ '<svg class="chart" viewBox="0 0 880 210" preserveAspectRatio="none">'
+ dualSeriesSvg(accPts, devPts, 880, 210) + '</svg>'
@@ -442,12 +631,13 @@
var spanX = (x1 - x0) || 1;
var X = function (v) { return padL + (v - x0) / spanX * (w - padL - padR); };
var Y = function (v) { return padT + (1 - v / yMax) * (h - padT - padB); };
var path = function (pts, color) {
// 颜色交给 admin.css 的 .chart .line.s-a / .s-b(令牌),深色自动跟随
var path = function (pts, cls) {
if (!pts.length) return '';
var d = pts.map(function (p, i) {
return (i === 0 ? 'M' : 'L') + X(p.ts).toFixed(1) + ' ' + Y(p.value).toFixed(1);
}).join(' ');
return '<path d="' + d + '" fill="none" stroke="' + color + '" stroke-width="2" stroke-linejoin="round"/>';
return '<path class="line ' + cls + '" d="' + d + '"/>';
};
var grid = '';
for (var i = 0; i <= 3; i++) {
@@ -457,7 +647,7 @@
+ '<text class="axis" x="' + (padL - 6) + '" y="' + (y + 3.5).toFixed(1)
+ '" text-anchor="end">' + Math.round(v) + '</text>';
}
return grid + path(a, '#2563eb') + path(b, '#d97706');
return grid + path(a, 's-a') + path(b, 's-b');
}
// ───────────────────────── 页面:账号 ─────────────────────────
@@ -1011,7 +1201,7 @@
+ '<div class="card"><div class="k">采样点</div><div class="v">' + esc(fmtNum(pts.length))
+ '<small>共 ' + esc(fmtNum(samples)) + ' 帧</small></div></div>'
+ '</div>'
+ '<div class="legend"><span><i style="background:#2563eb"></i>' + esc(col) + '</span></div>'
+ '<div class="legend"><span><i class="sw-a"></i>' + esc(col) + '</span></div>'
+ lineChart({ points: pts, width: 900, height: 240, xMin: from, xMax: to });
// 只列前 200 行明细:这是一个"看一眼原始值"的窗口,不是数据导出工具
@@ -1124,6 +1314,12 @@
+ '功能不受影响;建议到 <a href="/setup">/setup</a> 补建管理员账号'
+ '(需要 .env 里设置 INSTANCE_SETUP_TOKEN)。</div>'
: '')
// 位置②:设置页「外观」分区,与顶栏同一状态源、同一存储键
+ '<div class="card"><h2>外观<span class="sub">深色 / 浅色</span></h2>'
+ '<div class="body"><div class="iw-appearance">'
+ IwTheme.buttonHtml()
+ '<span class="hint">外观由本应用显式控制,不跟随系统深色;设置一次后在本实例所有页面生效。</span>'
+ '</div></div></div>'
+ '<div class="card"><h2>实例配置<span class="sub">保存在数据库,改完立刻生效</span></h2>'
+ '<div class="body"><table class="tbl"><tbody>' + rows + '</tbody></table>'
+ '<div class="acts"><button class="btn primary" id="s-save">保存</button>'
@@ -1140,6 +1336,9 @@
+ (locked.length ? ' · 被环境变量锁定的字段:' + esc(locked.join('、')) : '')
+ '</p></div></div>';
// 本页控件是异步渲染出来的,渲染后统一同步两处 aria-pressed / 图标
IwTheme.sync();
$('#s-save').addEventListener('click', function () {
var patch = {};
SETTING_FIELDS.forEach(function (f) {
@@ -1173,6 +1372,7 @@
function boot() {
var page = document.body.getAttribute('data-page') || 'overview';
renderTopbar(page);
IwTheme.init();
document.body.appendChild(el('<div id="toast"></div>'));
var root = $('#app');
+455
View File
@@ -0,0 +1,455 @@
/* ============================================================================
* 墨观 InkWatch V4.0 · 统一令牌层 tokens.css
* Phase 3 前端落地 · 唯一可编辑的颜色 / 字阶 / 间距 / 圆角真相源
*
* 来源:01-visual-direction.md §8.1(:root 全量)/ §8.2(四端派生覆盖)
* —— 第一、二段为「逐字复制」,Core 令牌一份不改,禁止在此文件之外
* 出现任何 #RRGGBB / rgb() / hsl() 字面量。
*
* 第三段为「工程化补充」:把 01 §4 / §5 已经写死的固定规格尺寸(190px 图表、
* 84px 指标卡、480px Dialog …)命名化,供组件以 var() 消费,满足「组件内
* 禁止硬编码间距 / 圆角 / 字号」;**未新增任何色值 / 字阶 / 间距 / 圆角 /
* 字重刻度**,所有取值均可回溯到 01 文档。
*
* 约束:color-scheme: light 锁定;不写任何系统深色媒体查询规则;
* html[data-iw-theme="dark"] 为 05 §1.4 的 58 个【同名键覆写】(Phase 3a),
* 浅色 Core 45 项与 18 个品牌原色一个不改、不新增任何键名;
* dark 块置于 E-Ink 块之前,E-Ink 独立分支不受深色影响;
* 减弱动效媒体查询仅归零动效(无障碍,非深色)。
* ========================================================================== */
/* ──────────────────────────────────────────────────────────────────────────
* 一、§8.1 `:root` 完整清单(逐字复制)
* ──────────────────────────────────────────────────────────────────────── */
:root {
color-scheme: light; /* 锁定浅色,禁止系统深色自动翻转 */
/* ── 中性面 ───────────────────────────────────────────── */
--iw-page: #F6F7F9;
--iw-surface: #FFFFFF;
--iw-surface-muted: #F3F4F6;
--iw-surface-hover: #F6F7F9; /* = page,大面积 hover */
--iw-line: #E5E7EB;
--iw-line-strong: #C8CEDA; /* 仅 hover 描边 */
/* ── 文字 ─────────────────────────────────────────────── */
--iw-text-primary: #111827;
--iw-text-secondary: #4B5563;
--iw-text-tertiary: #646D7D;
--iw-text-on-accent: #FFFFFF;
/* ── 主色 ─────────────────────────────────────────────── */
--iw-primary: #2563EB;
--iw-primary-hover: #1450D2;
--iw-primary-active: #1043B1;
--iw-primary-soft: #EFF4FF;
--iw-info-text: #124BC4;
--iw-info-border: #B6CBF9;
/* ── 状态语义(base / soft / text / border)───────────── */
--iw-online: #16A34A;
--iw-online-soft: #EAF7EF;
--iw-online-text: #117E39;
--iw-online-border: #AFDFC1;
--iw-suspect: #D97706;
--iw-suspect-soft: #FEF6E7;
--iw-suspect-text: #A45904;
--iw-suspect-border: #F4D2A8;
--iw-offline: #9CA3AF;
--iw-offline-soft: #F3F4F6;
--iw-offline-text: #4B5563;
--iw-offline-border: #DBDDE2;
--iw-unknown: #9CA3AF;
--iw-unknown-soft: #F3F4F6;
--iw-unknown-text: #4B5563;
--iw-unknown-border: #DBDDE2;
--iw-danger: #DC2626;
--iw-danger-hover: #AF1D1D;
--iw-danger-active: #911818;
--iw-danger-soft: #FDECEC;
--iw-danger-text: #B81E1E;
--iw-danger-border: #F4B5B5;
/* ── 图表 ─────────────────────────────────────────────── */
--iw-chart-line: #2563EB;
--iw-chart-line-w: 2px;
--iw-chart-area: #F2F6FE; /* primary @6% over surface */
--iw-chart-grid: #E5E7EB;
--iw-chart-axis: #646D7D;
--iw-gap: #B6BCC8;
--iw-gap-dash: 4 4;
--iw-chart-gap-band: #F3F4F6;
--iw-chart-threshold-band: #FDECEC;
--iw-chart-threshold-line: #EF9D9D; /* danger @45% over surface */
--iw-series-1: #2563EB; /* 实线 2px / 圆点 */
--iw-series-2: #0B748E; /* 实线 2px / 方点 */
--iw-series-3: #6D2CDD; /* 虚线 6 4 / 三角 */
--iw-series-4: #4B5768; /* 点线 2 4 1.5px / 基线 */
/* ── 品牌 ─────────────────────────────────────────────── */
--iw-logo-bg: #0B0B0C;
--iw-logo-wave: #FFFFFF;
--iw-logo-accent: #2563EB;
--iw-logo-mono: #111827;
--iw-logo-clearspace: 0.25; /* × logo 宽度,且 ≥8px */
/* ── 遮罩 / 阴影 / 反馈 ───────────────────────────────── */
--iw-mask: rgba(17, 24, 39, 0.28);
--iw-overlay-shadow: 0 8px 24px rgba(17, 24, 39, 0.08); /* 全系统唯一阴影 */
--iw-snackbar-bg: #111827;
--iw-focus-ring: 2px solid #2563EB;
--iw-focus-offset: 2px;
/* ── 间距 ─────────────────────────────────────────────── */
--iw-space-xs: 4px;
--iw-space-sm: 8px;
--iw-space-md: 12px;
--iw-space-lg: 16px;
--iw-space-xl: 20px;
--iw-space-xxl: 24px;
/* ── 圆角 ─────────────────────────────────────────────── */
--iw-radius-sm: 8px;
--iw-radius-md: 12px;
--iw-radius-lg: 16px;
--iw-radius-pill: 999px;
/* ── 描边 ─────────────────────────────────────────────── */
--iw-border-w: 1px;
--iw-border-w-strong: 1.5px;
--iw-border-w-accent: 2px;
--iw-border: 1px solid var(--iw-line);
/* ── 字阶(经 --iw-type-scale 统一缩放)──────────────── */
--iw-type-scale: 1;
--iw-fs-display: calc(56px * var(--iw-type-scale));
--iw-fs-metric: calc(22px * var(--iw-type-scale));
--iw-fs-title: calc(17px * var(--iw-type-scale));
--iw-fs-stat: calc(15px * var(--iw-type-scale));
--iw-fs-section: calc(14px * var(--iw-type-scale));
--iw-fs-body: calc(13px * var(--iw-type-scale));
--iw-fs-caption: calc(12px * var(--iw-type-scale));
--iw-fs-micro: calc(11px * var(--iw-type-scale));
--iw-lh-display: 1.1;
--iw-lh-metric: 1.15;
--iw-lh-title: 1.3;
--iw-lh-stat: 1.3;
--iw-lh-section: 1.35;
--iw-lh-body: 1.5;
--iw-lh-caption: 1.5;
--iw-lh-micro: 1.4;
--iw-fw-regular: 400;
--iw-fw-medium: 500;
--iw-fw-semibold: 600;
--iw-fw-bold: 700;
--iw-font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI",
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
"Noto Sans CJK SC", sans-serif;
--iw-font-mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Monaco,
Consolas, "Liberation Mono", monospace;
--iw-font-numeric: var(--iw-font-sans);
/* ── 动效 ─────────────────────────────────────────────── */
--iw-motion-fast: 120ms;
--iw-motion: 160ms;
--iw-easing: cubic-bezier(0.2, 0, 0, 1);
/* ── 端侧派生默认值(= Web 桌面;各端覆盖见 §8.2)────── */
--iw-control-h: 32px;
--iw-target-min: 32px;
--iw-layout-max: 1400px;
--iw-grid-cols: 12;
--iw-grid-gutter: 24px;
--iw-page-pad-x: 24px;
--iw-topbar-h: 52px;
--iw-sidebar-w: 220px;
--iw-safe-top: 0px;
--iw-safe-bottom: 0px;
--iw-safe-left: 0px;
--iw-safe-right: 0px;
/* ── E-Ink 独立分支(不消费任何彩色令牌)────────────── */
--iw-eink-page: #FFFFFF;
--iw-eink-ink: #000000;
--iw-eink-ink-soft: #444444;
--iw-eink-rule: #000000;
--iw-eink-box: #FFFFFF;
--iw-eink-fill: #000000;
}
/* ──────────────────────────────────────────────────────────────────────────
* 二、工程化补充令牌(Phase 3)
* 全部来自 01 §2.6 / §2.7 / §3.2 / §4 / §5 / §6 已写死的固定规格;
* 目的:让组件 CSS 里不出现任何裸 px 间距 / 圆角 / 字号。
* 未新增刻度:以下尺寸在 01 文档中均为确定值,此处仅命名化。
* ──────────────────────────────────────────────────────────────────────── */
:root {
/* 组件固定尺寸(§4.1 / §4.3 / §4.6 / §4.8 / §4.10 / §4.11 / §4.12) */
--iw-card-min-h: 84px;
--iw-card-min-w: 166px;
--iw-card-max-w-lg: 640px; /* §6.4 大屏单卡 ≤640px */
--iw-badge-h: 22px;
--iw-cell-pad-y: 9px;
--iw-notice-pad-y: 10px;
--iw-notice-pad-x: 14px;
--iw-notice-gap: 14px;
--iw-dialog-w: 480px;
--iw-dialog-vw-gap: 32px;
--iw-drawer-w: 720px;
--iw-drawer-vw: 92vw;
--iw-drawer-head-y: 14px;
--iw-drawer-head-x: 18px;
--iw-sidebar-collapsed-w: 64px; /* §4.9 icon rail */
--iw-nav-item-h: 36px;
--iw-tabbar-h: 56px;
--iw-page-pad-y-top: 20px;
--iw-page-pad-y-bottom: 64px;
--iw-mac-title-clearance: 76px; /* §6.3 避开 traffic light */
--iw-empty-pad: 40px;
/* 图标尺寸(§2.7 18×18 基准;§4.x 各处给定值) */
--iw-icon-sm: 16px;
--iw-icon-md: 18px;
--iw-icon-lg: 22px;
--iw-icon-xl: 24px;
--iw-icon-empty: 28px;
--iw-logo-size: 26px;
--iw-wave-size: 96px; /* §3.3 空状态波形 ≤96px */
/* 图表(§4.8 / §5.1 / §5.3) */
--iw-chart-h: 190px;
--iw-chart-h-lg: 280px; /* §6.4 大屏 */
--iw-chart-axis-left: 46px;
--iw-chart-axis-bottom: 22px;
--iw-chart-dot: 4px;
--iw-legend-swatch: 10px;
--iw-series-3-dash: 6 4;
--iw-series-4-dash: 2 4;
--iw-series-4-w: 1.5px;
/* 骨架(§4.12) */
--iw-skeleton-h: 12px;
--iw-skeleton-value-h: 18px;
--iw-skeleton-label-h: 11px;
--iw-skeleton-row-h: 66px;
/* 字距(§2.6 / §3.2 / §4.4) */
--iw-tracking-tight: -0.01em;
--iw-tracking-caps: 0.02em;
--iw-brand-tracking: 0.2px;
--iw-pairing-tracking: .12em;
--iw-fs-pairing: 24px; /* §4.4 配对码展示档 24px/600 */
/* E-Ink 字阶(§2.8 PmonEinkText,全分支最低 w600) */
--iw-eink-fs-value: 40px;
--iw-eink-fs-glyph: 22px;
--iw-eink-fs-title: 20px;
--iw-eink-fs-label: 14px;
--iw-eink-fs-action: 14px;
--iw-eink-fs-caption: 12px;
/* 语义墨色别名:C1 的默认落点(白卡片内)。
大屏 / E-Ink 由宿主在容器作用域重指向,Core 颜色令牌一份不改。 */
--iw-ink-muted: var(--iw-text-tertiary);
--iw-ink-axis: var(--iw-chart-axis);
/* a11y 视觉隐藏工具类的最小单位(clip 技巧,非间距刻度) */
--iw-sr-unit: 1px;
}
/* ──────────────────────────────────────────────────────────────────────────
* 三、§8.2 四端派生覆盖(只允许覆写 10 类派生变量)
* ──────────────────────────────────────────────────────────────────────── */
/* 移动 / PWA */
@media (max-width: 767px) {
:root {
--iw-control-h: 44px; --iw-target-min: 44px;
--iw-layout-max: none; --iw-grid-cols: 4;
--iw-grid-gutter: 12px; --iw-page-pad-x: 16px;
--iw-topbar-h: 56px; --iw-sidebar-w: 0px;
--iw-safe-top: env(safe-area-inset-top, 0px);
--iw-safe-bottom: env(safe-area-inset-bottom, 0px);
--iw-safe-left: env(safe-area-inset-left, 0px);
--iw-safe-right: env(safe-area-inset-right, 0px);
--iw-card-min-w: 150px; /* §6.2 minmax 下限降到 150px */
}
}
/* macOS 桌面应用(宿主注入 data 属性) */
html[data-iw-host="macos"] {
--iw-grid-gutter: 16px;
--iw-safe-top: 28px; /* 系统标题栏;品牌位左侧另留 ≥76px */
}
/* 数据大屏 / 看板 */
@media (min-width: 1920px) {
:root {
--iw-type-scale: 1.25;
--iw-control-h: 40px; --iw-target-min: 40px;
--iw-layout-max: none; --iw-grid-cols: 24;
--iw-grid-gutter: 24px; --iw-page-pad-x: 32px;
--iw-topbar-h: 64px; --iw-sidebar-w: 0px;
--iw-chart-h: var(--iw-chart-h-lg); /* §6.4 图表 280px */
--iw-ink-muted: var(--iw-text-secondary); /* §6.4 禁用 textTertiary */
--iw-ink-axis: var(--iw-text-secondary); /* §6.4 轴标签升 AAA */
}
}
/* ── 深色皮肤:05 §1.4 的 58 个【同名键覆写】,逐字转录 ──────────────
* 只覆写颜色 / 语义 / 反馈键;几何 / 排印 / 描边 / 动效 / 端侧派生 /
* E-Ink / 工程化尺寸一律保持缺省。不新增任何键名。
* 层叠顺序:本块必须在下方 E-Ink 块之前(同为 html[attr] 特异性,后者胜)。 */
html[data-iw-theme="dark"] {
/* 中性面 6 */
--iw-page: #0B0E14;
--iw-surface: #151A23;
--iw-surface-muted: #1E2430;
--iw-surface-hover: #1E2430;
--iw-line: #333B4C;
--iw-line-strong: #4A5468;
/* 文字 4 */
--iw-text-primary: #F2F4F9;
--iw-text-secondary: #B9C2D0;
--iw-text-tertiary: #8A93A3;
--iw-text-on-accent: #0B0E14;
/* 主色 6 */
--iw-primary: #6E9CFF;
--iw-primary-hover: #8FB4FF;
--iw-primary-active: #ACC8FF;
--iw-primary-soft: #18243D;
--iw-info-text: #A8C5FF;
--iw-info-border: #2E4473;
/* 状态 22 */
--iw-online: #5CE0A0;
--iw-online-soft: #0D2117;
--iw-online-text: #9AEAC3;
--iw-online-border: #246043;
--iw-suspect: #D8790E;
--iw-suspect-soft: #261B0D;
--iw-suspect-text: #F2A32C;
--iw-suspect-border: #60421A;
--iw-offline: #9CA3AF;
--iw-offline-soft: #1E2430;
--iw-offline-text: #B9C2D0;
--iw-offline-border: #414A5C;
--iw-unknown: #9CA3AF;
--iw-unknown-soft: #1E2430;
--iw-unknown-text: #B9C2D0;
--iw-unknown-border: #414A5C;
--iw-danger: #F87171;
--iw-danger-hover: #FA9A9A;
--iw-danger-active: #FCBABA;
--iw-danger-soft: #2E1518;
--iw-danger-text: #FCA5A5;
--iw-danger-border: #6E2C31;
/* 图表 12 */
--iw-chart-line: #6E9CFF;
--iw-chart-area: #1A2230;
--iw-chart-grid: #333B4C;
--iw-chart-axis: #8A93A3;
--iw-gap: #6B7488;
--iw-chart-gap-band: #1E2430;
--iw-chart-threshold-band: #2E1518;
--iw-chart-threshold-line: #7B4146;
--iw-series-1: #6E9CFF;
--iw-series-2: #55C7E7;
--iw-series-3: #815AED;
--iw-series-4: #9FA9BC;
/* 品牌 4 */
--iw-logo-bg: #0B0B0C;
--iw-logo-wave: #FFFFFF;
--iw-logo-accent: #2563EB;
--iw-logo-mono: #F2F4F9;
/* 反馈 4 */
--iw-mask: rgba(0, 0, 0, 0.62);
--iw-overlay-shadow: 0 8px 24px rgba(0, 0, 0, 0.48);
--iw-snackbar-bg: #F2F4F9;
--iw-focus-ring: 2px solid #6E9CFF;
}
/* E-Ink:所有动效归零,且不消费彩色令牌 */
html[data-iw-surface="eink"] {
color-scheme: light;
--iw-motion-fast: 0ms; --iw-motion: 0ms;
--iw-radius-sm: 0px; --iw-radius-md: 0px;
--iw-radius-lg: 0px; --iw-radius-pill: 0px;
/* 独立分支:把通用语义令牌在作用域内重指向 --iw-eink-* 六色,
使任何组件在此作用域都不会落到彩色令牌上(01 §2.8 / §8.4-4)。 */
--iw-page: var(--iw-eink-page);
--iw-surface: var(--iw-eink-box);
--iw-surface-muted: var(--iw-eink-page);
--iw-surface-hover: var(--iw-eink-page);
--iw-line: var(--iw-eink-rule);
--iw-line-strong: var(--iw-eink-rule);
--iw-text-primary: var(--iw-eink-ink);
--iw-text-secondary: var(--iw-eink-ink);
--iw-text-tertiary: var(--iw-eink-ink-soft);
--iw-text-on-accent: var(--iw-eink-page);
--iw-primary: var(--iw-eink-ink);
--iw-primary-hover: var(--iw-eink-ink);
--iw-primary-active: var(--iw-eink-ink);
--iw-primary-soft: var(--iw-eink-page);
--iw-info-text: var(--iw-eink-ink);
--iw-info-border: var(--iw-eink-rule);
--iw-online: var(--iw-eink-ink);
--iw-online-soft: var(--iw-eink-page);
--iw-online-text: var(--iw-eink-ink);
--iw-online-border: var(--iw-eink-rule);
--iw-suspect: var(--iw-eink-ink);
--iw-suspect-soft: var(--iw-eink-page);
--iw-suspect-text: var(--iw-eink-ink);
--iw-suspect-border: var(--iw-eink-rule);
--iw-offline: var(--iw-eink-ink);
--iw-offline-soft: var(--iw-eink-page);
--iw-offline-text: var(--iw-eink-ink);
--iw-offline-border: var(--iw-eink-rule);
--iw-unknown: var(--iw-eink-ink);
--iw-unknown-soft: var(--iw-eink-page);
--iw-unknown-text: var(--iw-eink-ink);
--iw-unknown-border: var(--iw-eink-rule);
--iw-danger: var(--iw-eink-ink);
--iw-danger-hover: var(--iw-eink-ink);
--iw-danger-active: var(--iw-eink-ink);
--iw-danger-soft: var(--iw-eink-page);
--iw-danger-text: var(--iw-eink-ink);
--iw-danger-border: var(--iw-eink-rule);
--iw-chart-line: var(--iw-eink-ink);
--iw-chart-area: var(--iw-eink-page);
--iw-chart-grid: var(--iw-eink-rule);
--iw-chart-axis: var(--iw-eink-ink-soft);
--iw-gap: var(--iw-eink-ink-soft);
--iw-chart-gap-band: var(--iw-eink-page);
--iw-chart-threshold-band: var(--iw-eink-page);
--iw-chart-threshold-line: var(--iw-eink-ink);
--iw-series-1: var(--iw-eink-ink);
--iw-series-2: var(--iw-eink-ink);
--iw-series-3: var(--iw-eink-ink);
--iw-series-4: var(--iw-eink-ink);
--iw-logo-wave: var(--iw-eink-page);
--iw-logo-bg: var(--iw-eink-ink);
--iw-logo-mono: var(--iw-eink-ink);
--iw-logo-accent: var(--iw-eink-ink);
--iw-snackbar-bg: var(--iw-eink-ink);
--iw-mask: var(--iw-eink-ink);
--iw-overlay-shadow: none;
--iw-focus-ring: var(--iw-border-w-accent) solid var(--iw-eink-ink);
--iw-ink-muted: var(--iw-eink-ink-soft);
--iw-ink-axis: var(--iw-eink-ink-soft);
}
/* 无障碍:减弱动效(归零动效,非深色模式,不新增任何色值) */
@media (prefers-reduced-motion: reduce) {
:root {
--iw-motion-fast: 0ms;
--iw-motion: 0ms;
}
}
+2
View File
@@ -7,6 +7,8 @@
<meta name="robots" content="noindex, nofollow">
<meta name="color-scheme" content="light">
<title>审计 · 墨观管理台</title>
<script>(function(){try{var t=localStorage.getItem('inkwatch.theme');document.documentElement.setAttribute('data-iw-theme',(t==='dark'||t==='light')?t:'light');}catch(e){document.documentElement.setAttribute('data-iw-theme','light');}})();</script>
<link rel="stylesheet" href="assets/tokens.css">
<link rel="stylesheet" href="assets/admin.css">
</head>
<body data-page="audit">
+2
View File
@@ -7,6 +7,8 @@
<meta name="robots" content="noindex, nofollow">
<meta name="color-scheme" content="light">
<title>设备 · 墨观管理台</title>
<script>(function(){try{var t=localStorage.getItem('inkwatch.theme');document.documentElement.setAttribute('data-iw-theme',(t==='dark'||t==='light')?t:'light');}catch(e){document.documentElement.setAttribute('data-iw-theme','light');}})();</script>
<link rel="stylesheet" href="assets/tokens.css">
<link rel="stylesheet" href="assets/admin.css">
</head>
<body data-page="devices">
+2
View File
@@ -7,6 +7,8 @@
<meta name="robots" content="noindex, nofollow">
<meta name="color-scheme" content="light">
<title>总览 · 墨观管理台</title>
<script>(function(){try{var t=localStorage.getItem('inkwatch.theme');document.documentElement.setAttribute('data-iw-theme',(t==='dark'||t==='light')?t:'light');}catch(e){document.documentElement.setAttribute('data-iw-theme','light');}})();</script>
<link rel="stylesheet" href="assets/tokens.css">
<link rel="stylesheet" href="assets/admin.css">
</head>
<body data-page="overview">
+2
View File
@@ -7,6 +7,8 @@
<meta name="robots" content="noindex, nofollow">
<meta name="color-scheme" content="light">
<title>指标数据 · 墨观管理台</title>
<script>(function(){try{var t=localStorage.getItem('inkwatch.theme');document.documentElement.setAttribute('data-iw-theme',(t==='dark'||t==='light')?t:'light');}catch(e){document.documentElement.setAttribute('data-iw-theme','light');}})();</script>
<link rel="stylesheet" href="assets/tokens.css">
<link rel="stylesheet" href="assets/admin.css">
</head>
<body data-page="metrics">
+2
View File
@@ -7,6 +7,8 @@
<meta name="robots" content="noindex, nofollow">
<meta name="color-scheme" content="light">
<title>实例设置 · 墨观管理台</title>
<script>(function(){try{var t=localStorage.getItem('inkwatch.theme');document.documentElement.setAttribute('data-iw-theme',(t==='dark'||t==='light')?t:'light');}catch(e){document.documentElement.setAttribute('data-iw-theme','light');}})();</script>
<link rel="stylesheet" href="assets/tokens.css">
<link rel="stylesheet" href="assets/admin.css">
</head>
<body data-page="settings">
+233
View File
@@ -0,0 +1,233 @@
#!/usr/bin/env node
/**
* Phase 3b · 管理台深色落地的一次性静态护栏
*
* 运行:
* node tests/phase3b-static-guard.js
* PHASE3B_PREVIEW=/abs/path/to/preview.html node tests/phase3b-static-guard.js
*
* 只做静态源码核对,不启服务、不依赖任何 npm 包。覆盖:
* A. admin.css 令牌化(色值 / 圆角 / focus / 旧变量 / 深色专属 / Toast)
* B. 6 个 HTML 的首屏脚本、顺序、零外链;/setup 排除
* C. admin.js 单一命名空间与隐私硬约束
* D. tokens.css 深色 58 键、层叠顺序、E-Ink 互斥选择器
* E. Dockerfile / nginx 静态资源覆盖
* F. 可选:与 preview.html 做 7 处首屏脚本 hash 比对
*/
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const ROOT = path.resolve(__dirname, '..');
const ADMIN = path.join(ROOT, 'server/business/admin');
const ASSETS = path.join(ADMIN, 'assets');
const PAGES = ['index', 'devices', 'metrics', 'accounts', 'audit', 'settings'];
const BOOTSTRAP = `<script>(function(){try{var t=localStorage.getItem('inkwatch.theme');document.documentElement.setAttribute('data-iw-theme',(t==='dark'||t==='light')?t:'light');}catch(e){document.documentElement.setAttribute('data-iw-theme','light');}})();</script>`;
const BOOTSTRAP_SHA = crypto.createHash('sha256').update(BOOTSTRAP).digest('hex');
let pass = 0, fail = 0;
function ok(name, cond, detail) {
if (cond) { pass++; console.log(' PASS ' + name); }
else { fail++; console.log(' FAIL ' + name + (detail ? '\n ' + detail : '')); }
}
function read(p) {
try { return fs.readFileSync(p, 'utf8'); }
catch (e) { return null; }
}
function count(s, needle) {
if (!s) return 0;
return s.split(needle).length - 1;
}
function section(t) { console.log('\n── ' + t + ' ──'); }
/** 结构 / 隐私类断言只看代码,不看注释(注释里会正当提到这些词) */
function stripBlock(s) { return s.replace(/\/\*[\s\S]*?\*\//g, ''); }
function stripJsCode(s) {
return stripBlock(s).split('\n')
.map(l => (/^\s*\/\//.test(l) ? '' : l)).join('\n');
}
const css = read(path.join(ASSETS, 'admin.css'));
const tokens = read(path.join(ASSETS, 'tokens.css'));
const js = read(path.join(ASSETS, 'admin.js'));
// ─────────────────────────────────────────────────────────────
section('0. 文件可读');
ok('admin.css 存在且非空', !!css && css.length > 200);
ok('tokens.css 存在且非空', !!tokens && tokens.length > 200);
ok('admin.js 存在且非空', !!js && js.length > 200);
if (!css || !tokens || !js) { console.log('\n致命:核心文件读不到'); process.exit(1); }
// ─────────────────────────────────────────────────────────────
section('A. admin.css 令牌化');
// 8/6/3 位 hex;负向先行断言排除 $('#acc-table') 这类 id 选择器误命中
const hexRe = /#[0-9a-fA-F]{8}(?![0-9a-zA-Z_-])|#[0-9a-fA-F]{6}(?![0-9a-zA-Z_-])|#[0-9a-fA-F]{3}(?![0-9a-zA-Z_-])/g;
const funcRe = /\b(?:rgba?|hsla?)\(/g;
const cssCode = stripBlock(css);
const jsCode = stripJsCode(js);
const hexHits = css.match(hexRe) || [];
const funcHits = css.match(funcRe) || [];
ok('#RRGGBB 字面量 0 命中(含 #fff)', hexHits.length === 0, '命中:' + hexHits.join(', '));
ok('rgb()/rgba()/hsl()/hsla() 字面量 0 命中', funcHits.length === 0, '命中:' + funcHits.join(', '));
const radiusDecls = [];
const reRadius = /border-radius\s*:\s*([^;]+);/g;
let m;
while ((m = reRadius.exec(css)) !== null) radiusDecls.push(m[1].trim());
const badRadius = radiusDecls.filter(v => !/^var\(--iw-radius-(sm|md|lg|pill)\)$/.test(v));
ok('border-radius 全部走 4 档令牌(共 ' + radiusDecls.length + ' 处)',
radiusDecls.length > 0 && badRadius.length === 0, '越界值:' + badRadius.join(' | '));
ok('无 10px / 14px / 6px / 3px 裸圆角',
!/border-radius\s*:\s*(?:10|14|6|3)px/.test(css));
const OLD_VARS = ['page', 'surface', 'line', 'muted-bg', 'text', 'text-2', 'text-3',
'primary', 'primary-soft', 'online', 'online-soft', 'warn', 'warn-soft',
'offline', 'danger', 'danger-soft', 'mono', 'radius'];
const oldVarHits = OLD_VARS.filter(v => css.indexOf('var(--' + v + ')') >= 0);
ok('旧 var(--old) 全部 0 命中', oldVarHits.length === 0, '残留:' + oldVarHits.join(', '));
ok('admin.css 不再定义 :root 变量', !/:root\s*\{/.test(css));
ok('注释契约已改为「唯一真相源为 assets/tokens.css」',
css.indexOf('唯一真相源为 `assets/tokens.css`') >= 0 && css.indexOf('token_parity_test.dart') >= 0);
ok('无 outline: none 漏网', !/outline\s*:\s*none/i.test(css));
const outlines = [];
const reOutline = /(^|[;{\s])outline\s*:\s*([^;]+);/g;
while ((m = reOutline.exec(css)) !== null) outlines.push(m[2].trim());
ok('所有 outline 走 --iw-focus-ring(共 ' + outlines.length + ' 处)',
outlines.length > 0 && outlines.every(v => v.indexOf('var(--iw-focus-ring)') >= 0),
'实际:' + outlines.join(' | '));
ok('focus 含 outline-offset: var(--iw-focus-offset)',
css.indexOf('outline-offset: var(--iw-focus-offset)') >= 0);
ok('filter: brightness(...) 已删除', !/filter\s*:\s*brightness/i.test(css));
ok('hover 走 --iw-primary-hover', css.indexOf('background: var(--iw-primary-hover)') >= 0);
const toastBlock = (css.match(/#toast\s*\{([\s\S]*?)\}/) || [])[1] || '';
ok('#toast background: var(--iw-snackbar-bg)', toastBlock.indexOf('background: var(--iw-snackbar-bg)') >= 0);
ok('#toast color: var(--iw-surface)(深色反相浅底深字)', toastBlock.indexOf('color: var(--iw-surface)') >= 0);
ok('#toast 无 transition: all(避免整肤动画)', !/transition\s*:\s*all/.test(toastBlock));
ok('提示条文字色走 4 个 *-text 令牌',
['--iw-info-text', '--iw-suspect-text', '--iw-danger-text', '--iw-online-text']
.every(v => css.indexOf('color: var(' + v + ')') >= 0));
ok('图表色走 --iw-chart-* / --iw-series-1',
['--iw-chart-line', '--iw-chart-area', '--iw-chart-grid', '--iw-chart-axis', '--iw-series-1']
.every(v => css.indexOf('var(' + v + ')') >= 0));
ok('遮罩走 --iw-mask', css.indexOf('background: var(--iw-mask)') >= 0);
ok('管理台零深色专属 CSS(admin.css 代码中无 data-iw-theme)', !/data-iw-theme/.test(cssCode));
ok('管理台零 @media 皮肤分支(admin.css 代码中无 @media)', !/@media/.test(cssCode));
ok('无 prefers-color-scheme 自动跟随', !/prefers-color-scheme/.test(cssCode));
ok('语法自检:花括号平衡',
(css.match(/\{/g) || []).length === (css.match(/\}/g) || []).length);
// ─────────────────────────────────────────────────────────────
section('B. 6 个 HTML 首屏脚本 / 顺序 / 零外链');
const TOKEN_LINK = '<link rel="stylesheet" href="assets/tokens.css">';
const ADMIN_LINK = '<link rel="stylesheet" href="assets/admin.css">';
const htmls = {};
PAGES.forEach(p => { htmls[p] = read(path.join(ADMIN, p + '.html')); });
PAGES.forEach(p => {
const h = htmls[p] || '';
const n = count(h, BOOTSTRAP);
const iB = h.indexOf(BOOTSTRAP), iT = h.indexOf(TOKEN_LINK), iA = h.indexOf(ADMIN_LINK);
ok(p + '.html:首屏脚本逐字一致 ×1', n === 1, '实际 ' + n + ' 处');
ok(p + '.html:tokens.css ×1 且 admin.css ×1',
count(h, TOKEN_LINK) === 1 && count(h, ADMIN_LINK) === 1);
ok(p + '.html:脚本 < tokens.css < admin.css', iB >= 0 && iT > iB && iA > iT);
ok(p + '.html:零外链(无外部 script src / @import / CDN)',
!/<script[^>]+src=["']https?:/i.test(h) && !/@import/.test(h)
&& !/(?:href|src)=["']https?:\/\//i.test(h));
const localScripts = h.match(/<script[^>]+src="([^"]+)"/g) || [];
ok(p + '.html:脚本仅本地 assets/admin.js(共 ' + localScripts.length + ' 处)',
localScripts.length > 0 && localScripts.every(t => t.indexOf('assets/admin.js') >= 0));
});
const allSameScript = PAGES.every(p => (htmls[p] || '').indexOf(BOOTSTRAP) >= 0);
ok('7 处脚本同源:6 页均含同一段(SHA256 ' + BOOTSTRAP_SHA.slice(0, 12) + '…)', allSameScript);
const setup = read(path.join(ROOT, 'server/business/setup/index.html'));
ok('/setup 不参与主题(无 inkwatch.theme / tokens.css / data-iw-theme)',
!!setup && !/inkwatch\.theme|assets\/tokens\.css|data-iw-theme/.test(setup));
const preview = process.env.PHASE3B_PREVIEW ? read(process.env.PHASE3B_PREVIEW) : null;
if (preview) {
const pvHits = count(preview, BOOTSTRAP);
ok('preview.html 含同一段首屏脚本逐字一致 ×1', pvHits === 1, '实际 ' + pvHits + ' 处');
ok('7 处脚本 hash 全等(6 页 + preview)', pvHits === 1 && allSameScript,
'SHA256 = ' + BOOTSTRAP_SHA);
} else {
console.log(' SKIP preview.html hash 比对(设置 PHASE3B_PREVIEW=<path> 可启用)');
}
// ─────────────────────────────────────────────────────────────
section('C. admin.js 单一命名空间与隐私');
ok('inkwatch.theme 字面量在运行时源码中命中 1 处',
count(js, 'inkwatch.theme') === 1, '实际 ' + count(js, 'inkwatch.theme') + ' 处');
ok('admin.js 中 #RRGGBB 字面量 0 命中', (js.match(hexRe) || []).length === 0,
'命中:' + (js.match(hexRe) || []).join(', '));
ok('不读系统深色 / 不写 cookie / 不读 matchMedia(代码层)',
!/matchMedia\s*\(|prefers-color-scheme|document\.cookie/.test(jsCode));
ok('不发遥测(无 theme 进 URL query)', !/[?&]theme=/.test(js));
ok('storage 事件跨标签同步存在', /addEventListener\('storage'/.test(js));
ok('单一 live region:role=status + aria-live=polite 各 1 处',
count(js, 'aria-live="polite"') === 1 && count(js, 'role="status"') === 1);
ok('两处控件由同一 buttonHtml() 生成(命中 2 处调用)', count(js, 'IwTheme.buttonHtml()') === 2);
ok('按钮无 title / role="switch" / aria-live 挂载',
!/data-iw-theme-toggle[\s\S]{0,200}(?:title=|role="switch"|aria-live=)/.test(js));
ok('aria-pressed 字符串写入', /aria-pressed', pressed/.test(js) || /setAttribute\('aria-pressed'/.test(js));
ok('E-Ink 互斥:控制器含 data-iw-surface 判定', /data-iw-surface/.test(js));
ok('播报文案齐全(成功 / 读取失败 / 写入失败 / 恢复)',
['已切换到深色模式', '已切换到浅色模式', '外观偏好读取失败,已回退到浅色模式',
'外观已切换,但偏好未能保存,本次仅在当前页面生效', '已恢复深色模式', '已恢复浅色模式']
.every(s => js.indexOf(s) >= 0));
ok('aria-label 三态文案齐全',
['外观:当前浅色,切换到深色', '外观:当前深色,切换到浅色',
'外观:当前浅色(偏好读取失败已回退),切换到深色'].every(s => js.indexOf(s) >= 0));
// ─────────────────────────────────────────────────────────────
section('D. tokens.css 深色 58 键 / 层叠 / E-Ink 互斥');
const firstRoot = (tokens.match(/:root\s*\{([\s\S]*?)\}/) || [])[1] || '';
const darkBlock = (tokens.match(/html\[data-iw-theme="dark"\]\s*\{([\s\S]*?)\}/) || [])[1] || '';
const rootKeys = new Set((firstRoot.match(/--iw-[a-z0-9-]+\s*:/g) || []).map(s => s.replace(/\s*:$/, '')));
const darkKeys = (darkBlock.match(/--iw-[a-z0-9-]+\s*:/g) || []).map(s => s.replace(/\s*:$/, ''));
ok('深色块含 58 个同名键', darkKeys.length === 58, '实际 ' + darkKeys.length + ' 个');
ok('深色块键名全部来自浅色 Core(零新增键名)',
darkKeys.every(k => rootKeys.has(k)),
'越界:' + darkKeys.filter(k => !rootKeys.has(k)).join(', '));
ok('深色块键名无重复', new Set(darkKeys).size === darkKeys.length);
ok('dark 块位于 E-Ink 块之前',
tokens.indexOf('html[data-iw-theme="dark"]') >= 0
&& tokens.indexOf('html[data-iw-surface="eink"]') > tokens.indexOf('html[data-iw-theme="dark"]'));
ok('color-scheme: light 仍锁定', /color-scheme:\s*light/.test(tokens));
ok('无 prefers-color-scheme 系统深色媒体查询', !/@media\s*\(prefers-color-scheme/.test(tokens));
const MUTEX = 'html[data-iw-surface="eink"][data-iw-theme="dark"]';
ok('E-Ink 互斥选择器在 tokens.css 0 命中', count(tokens, MUTEX) === 0);
ok('E-Ink 互斥选择器在 admin.css / admin.js 0 命中',
count(css, MUTEX) === 0 && count(js, MUTEX) === 0);
ok('E-Ink 块圆角 0 / 动效 0', /--iw-radius-sm:\s*0px/.test(tokens) && /--iw-motion-fast:\s*0ms/.test(tokens));
ok('tokens.css 零外链(无 @import / script src / CDN)',
!/@import/.test(tokens) && !/<script/.test(tokens) && !/https?:\/\//.test(tokens));
ok('tokens.css 花括号平衡',
(tokens.match(/\{/g) || []).length === (tokens.match(/\}/g) || []).length);
// ─────────────────────────────────────────────────────────────
section('E. Dockerfile / nginx 静态资源覆盖');
['deploy/Dockerfile.business', 'deploy/Dockerfile.all'].forEach(f => {
const s = read(path.join(ROOT, f)) || '';
ok(f + ' 整目录 COPY server/business/admin',
/COPY[^\n]*server\/business\/admin[^\n]*\.\/server\/business\/admin/.test(s));
});
['deploy/nginx.conf', 'deploy/nginx-all.conf'].forEach(f => {
const s = read(path.join(ROOT, f)) || '';
ok(f + ' 把 /admin/ 代理给 business', /location\s*\^~\s*\/admin\//.test(s) && /proxy_pass/.test(s));
});
const adminSrv = read(path.join(ROOT, 'server/business/admin.js')) || '';
ok('business 用 express.static 提供 admin 目录', /express\.static\(adminDir/.test(adminSrv));
// ─────────────────────────────────────────────────────────────
console.log('\n════════════════════════════════════════');
console.log(' PASS ' + pass + ' FAIL ' + fail);
console.log(' 首屏脚本 SHA256 = ' + BOOTSTRAP_SHA);
console.log('════════════════════════════════════════');
process.exit(fail ? 1 : 0);