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:
1 parent
4cca14d1b9
commit
e7481a43d9
16 files changed
+1712
-128
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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` 橙 | **亮度接近**,灰阶化后两个状态长得一样 |
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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('卡片装饰来自同一处(标准卡不用阴影,靠发丝线分层)', () {
|
||||
|
||||
@@ -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"]')));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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); }
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user