# ops-console(爱视云管理)手机端 UI 优化方案 > # ⛔ 本文档已作废,请勿据此实施 > > **唯一有效版本:[`手机端UI优化方案-v2.md`](./手机端UI优化方案-v2.md)** > > 本文件仅作历史存档保留。v2 相对本文的变更: > 1. 基调从「重新定义品牌视觉语言」改为「**回归旧版橙白 + 合理化改进**」; > 2. 新增「新旧版延续性对照」章节; > 3. **图标章节按冻结区重写** —— 用户要求「首页应用图标及与应用显示相关的图标维持原有设计不做任何修改」,本文 §5「图标风格」**已作废,请勿执行**(尤其不要把 `Icons.dns` 改成 `Icons.cloud_*`); > 4. 新增「导航栏 / 设置页 / 详情页 / 弹窗」四类代码级落地指引。 > > 本文的**配色体系(§3)、圆角与阴影(§4)、布局与触控(§6)、`theme.dart` 改造代码(§7.4)仍然有效**,v2 已完整沿用。 --- | 项 | 内容 | | --- | --- | | 项目 | ops-console(包名 `com.ainetv.aishicloud`) | | 技术栈 | Flutter 3.27.4 / Dart 3.6.2 / Material 3 / go_router / Riverpod | | 目标设备 | Android 手机;**实测横屏 1600×900(rotation=1)**,同时兼容竖屏 | | 用户画像 | 门店店员 / 现场运维人员,**常在现场单手操作** | | 文档作者 | 许清楚(产品经理) | | 对标基准 | WCAG 2.1 AA;Material 3 规范;Android 无障碍触控建议 | | **状态** | **历史存档(v1)** —— 有效版本见文首链接 | --- ## 1. 一句话结论 **当前主题主色 `#2E7DFF`(科技蓝)与 logo 主色 `#FF914D`(暖橙)直接冲突,这是"视觉不一致"的根源。** 本方案以 logo 实测色 `#FF914D` / `#FFBE58` / `#FEFEFE` 为唯一取色来源,重建一套**暖橙 + 近白云感的品牌设计语言**,并同步解决三个被源码证实的硬伤: 1. **层次缺失**:`Card` elevation 仅 1、圆角无梯度 → 整屏扁平,与"云朵漂浮"意象无关; 2. **触控与横屏未适配**:全项目 **0 处** `OrientationBuilder`、0 处响应式断点,GridView 写死 `crossAxisCount: 2` → 1600px 横屏下卡片被拉伸到 780px,信息密度崩溃; 3. **30 处底部弹窗全部裸奔**:`showModalBottomSheet` 未设 `useSafeArea`、未限高、未加拖拽把手,门店新增/编辑长表单的**保存按钮埋在滚动区末尾**。 --- ## 2. 设计语言定义(由 logo 实测数据推导) logo `assets/images/logo.png`(192×192,RGBA)实测数据: | 维度 | 实测值 | 推导出的设计语言 | | --- | --- | --- | | 容器形状 | 圆角矩形,圆角 ≈ 6px / 192px = **3%** | **克制的小圆角**,非胶囊、非圆形 | | 主色 `#FF914D` | 占非透明像素 4.4%,**图形中心像素** | **品牌主色(暖橙)** | | 金黄 `#FFBE58` | 1.9% | **品牌辅助色(暖金)** | | 渐变带 | `#FFA36A` / `#FFB487` / `#FFC56C` / `#FCE2CB` | **橙→金渐变**,是"云朵受光"的表达 | | 中性 `#DBBB88` | 3.5% | 暖调中间色 → 中性色阶需**偏暖,不可用纯灰** | | 近白 `#FEFEFE` | 85.0% | **大面积留白**,界面是"亮、轻、通透"的 | | 明度 | 均值 245/255,**最暗仅 163** | 无深色成分 → **浅色模式为品牌主场** | | 橙色行分布 | `[0,0,0,18,34,28,13,85,103,71,45,11,35,0,0,0]` | **中部双峰起伏** → 云朵多圆弧叠加轮廓 | ### 视觉关键词(后续所有决策的判据) > **云 · 轻盈 · 暖橙 · 明亮 · 圆润弧线 · 大留白** 派生出 4 条可执行的判据: 1. 中性色一律带**暖色偏移**(R>G>B),禁止使用纯灰 `#9E9E9E` / 纯黑 `#000000`; 2. 阴影用**暖棕低透明**而非纯黑,模糊半径大、偏移量小 → "托起"而非"压住"; 3. 圆角**中等偏大但不成胶囊**,且**顶部圆角 ≥ 底部圆角**(呼应云朵上缘多弧、下缘平缓); 4. 品牌亮橙 `#FF914D` **只做大色块/渐变/插画/图表**,不做文字与图标色(原因见 §3.2 实测)。 --- ## 3. 配色体系(重点) ### 3.1 关键约束:logo 亮橙不能配白字(实测) 用 WCAG 2.1 相对亮度公式对 logo 原色实测(脚本核算,非估算): | 组合 | 对比度 | 结论 | | --- | --- | --- | | 白字 `#FFFFFF` on `#FF914D` | **2.23** | ❌ 不达标(正文需 4.5,大字需 3.0) | | 白字 `#FFFFFF` on `#FFBE58` | **1.65** | ❌ 严重不达标 | | 白字 `#FFFFFF` on `#FFB487` | **1.73** | ❌ 不达标 | | 黑字 `#000000` on `#FF914D` | **9.41** | ✅ 达标 | | 深棕 `#3D1A00` on `#FF914D` | **6.99** | ✅ 达标(推荐,比纯黑更柔和、更品牌) | **结论(必须写进设计约束):** > **在 `#FF914D` 系亮橙上,永远不要用白字;统一用深棕 `#3D1A00`。** > 反过来,亮橙在浅色底上作为**图标/文字色**也不可用(`#FFB487` on `#FCFAF8` 仅 1.66)。 因此采用 **"双橙策略"**,这是本方案配色部分的核心决策: | 角色 | 色值 | 用途 | 上/下字色 | | --- | --- | --- | --- | | **品牌色(Brand)** | `#FF914D` | 插画、渐变、图表、Banner 大色块、Splash、进度条填充 | 深棕 `#3D1A00` | | **行动色(Primary)** | `#C2410C` | 主按钮、导航选中、可点击文字、图标(需承载白字) | 白 `#FFFFFF` | | **辅金色(Accent)** | `#FFBE58` | 高亮强调、到期提醒、星级、推荐位 | 深棕 `#3D1A00` | `#C2410C` = `#FF914D` 沿同色相(H≈23°)压暗到 tone-45,既保住 logo 的橙的**色相识别**,又把亮度压到能承载白字(vs 白 = **5.18** ✅)。 > **一句话记忆**:**"看"用亮橙 `#FF914D`,"点"用深橙 `#C2410C`。** ### 3.2 品牌色板(Brand Palette) ```dart // lib/app/theme.dart — 品牌原色(直接取自 logo,不参与 ColorScheme 角色分工) class AppBrand { // —— logo 原色(仅用于大色块 / 渐变 / 插画 / 图表,禁止作文字色 —— static const Color cloud = Color(0xFFFF914D); // logo 主色(图形中心像素) static const Color cloudGold = Color(0xFFFFBE58); // logo 金黄 static const Color cloudLight = Color(0xFFFFB487); // logo 渐变带 static const Color cloudMid = Color(0xFFFFA36A); // logo 渐变带 static const Color cloudWarm = Color(0xFFFFC56C); // logo 渐变带 static const Color cloudCream = Color(0xFFFCE2CB); // logo 渐变带(最浅) static const Color cloudPaper = Color(0xFFFEFEFE); // logo 近白底 // —— 亮橙上的唯一合法文字色 —— static const Color onCloud = Color(0xFF3D1A00); // vs #FF914D = 6.99 ✅ // —— 品牌渐变(搬运 logo 的橙→金渐变)—— static const LinearGradient cloudGradient = LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFFF914D), Color(0xFFFFBE58)], ); // —— 暖调阴影基色(关键:阴影不用纯黑)—— static const Color shadowTint = Color(0xFF5C2E12); } ``` ### 3.3 浅色 ColorScheme(Light)—— 完整角色映射 ```dart static const ColorScheme _lightScheme = ColorScheme( brightness: Brightness.light, // 主色(行动色) primary: Color(0xFFC2410C), onPrimary: Color(0xFFFFFFFF), // 5.18 ✅ primaryContainer: Color(0xFFFFD3B3), onPrimaryContainer: Color(0xFF4A1D00), // 10.38 ✅ // 辅助色(暖赭,来自 logo #DBBB88 的深调) secondary: Color(0xFF8A5A3A), onSecondary: Color(0xFFFFFFFF), // 5.83 ✅ secondaryContainer: Color(0xFFF5DFCB), onSecondaryContainer:Color(0xFF5A3A22), // 7.90 ✅ // 第三色(琥珀金,来自 logo #FFBE58 的深调) tertiary: Color(0xFF9A6000), onTertiary: Color(0xFFFFFFFF), // 5.19 ✅ tertiaryContainer: Color(0xFFFFE0A3), onTertiaryContainer: Color(0xFF5C3D00), // 7.75 ✅ // 错误 error: Color(0xFFB02A20), onError: Color(0xFFFFFFFF), // 6.57 ✅ errorContainer: Color(0xFFFFDDD8), onErrorContainer: Color(0xFF7A2A22), // 7.60 ✅ // 表面(暖中性,全部 R>G>B) surface: Color(0xFFFCFAF8), onSurface: Color(0xFF1A1614), // 17.26 ✅ onSurfaceVariant: Color(0xFF6E655C), // 5.48 ✅ surfaceContainerLowest: Color(0xFFFFFFFF), surfaceContainerLow: Color(0xFFF7F3EF), surfaceContainer: Color(0xFFF2EDE8), surfaceContainerHigh: Color(0xFFECE5DF), surfaceContainerHighest: Color(0xFFE5DED7), outline: Color(0xFF8A8178), // 3.67 ✅(非文本元素需 ≥3.0) outlineVariant: Color(0xFFDCCFC2), // 装饰性分隔线,无强制对比要求 surfaceTint: Color(0xFFC2410C), // M3 高度染色 → 卡片自带暖调 shadow: Color(0xFF5C2E12), scrim: Color(0xFF1A1614), inverseSurface: Color(0xFF2E2823), onInverseSurface: Color(0xFFF7EFE9), inversePrimary: Color(0xFFFFB487), ); ``` ### 3.4 深色 ColorScheme(Dark)—— 完整角色映射 深色模式下 M3 规则是**主色提亮到 tone-80、文字改深色**,`#FFB487` 恰好就是 logo 渐变带的原色,直接复用,品牌一致性最高。 ```dart static const ColorScheme _darkScheme = ColorScheme( brightness: Brightness.dark, primary: Color(0xFFFFB487), // ← logo 渐变带原色 onPrimary: Color(0xFF542000), // 7.66 ✅(白字仅 1.73 ❌,必须用深棕) primaryContainer: Color(0xFF63300D), onPrimaryContainer: Color(0xFFFFDCC2), // 8.30 ✅ secondary: Color(0xFFE8C39B), onSecondary: Color(0xFF542000), // 8.03 ✅ secondaryContainer: Color(0xFF5A3A22), onSecondaryContainer:Color(0xFFFFDCC2), // 7.89 ✅ tertiary: Color(0xFFF2BE62), onTertiary: Color(0xFF542000), // 7.78 ✅ tertiaryContainer: Color(0xFF5C3D00), onTertiaryContainer: Color(0xFFFFDCC2), // 7.66 ✅ error: Color(0xFFFF8474), onError: Color(0xFF5A1610), // 5.65 ✅ errorContainer: Color(0xFF7A2A22), onErrorContainer: Color(0xFFFFC9C2), // 6.59 ✅ surface: Color(0xFF16130F), // 暖黑,非 #121212 onSurface: Color(0xFFF3E9E2), // 15.49 ✅ onSurfaceVariant: Color(0xFFC4B8AE), // 9.54 ✅ surfaceContainerLowest: Color(0xFF0F0D0A), surfaceContainerLow: Color(0xFF1E1A16), surfaceContainer: Color(0xFF23201B), surfaceContainerHigh: Color(0xFF2D2924), surfaceContainerHighest: Color(0xFF38332D), outline: Color(0xFF7A7068), // 3.83 ✅ outlineVariant: Color(0xFF453F39), surfaceTint: Color(0xFFFFB487), shadow: Color(0xFF000000), // 深色下改用纯黑,暖色阴影不可见 scrim: Color(0xFF0F0D0A), inverseSurface: Color(0xFFF3E9E2), onInverseSurface: Color(0xFF2E2823), inversePrimary: Color(0xFFC2410C), ); ``` ### 3.5 语义色(成功 / 警告 / 错误 / 信息) **设计决策:警告色不用橙。** 因为品牌主色本身就是橙,若警告也用橙会与"可点击/品牌"语义混淆。警告改用**琥珀黄**(`#8A5C00` / `#F0BE4E`),与橙拉开色相差(约 20°),仍在同一暖色家族内。 | 语义 | Light 主色 | Light 容器 / 文字 | Dark 主色 | Dark 容器 / 文字 | | --- | --- | --- | --- | --- | | 成功 | `#1E7A4E` (5.11 ✅) | `#D3EDDD` / `#1E5138` (7.39 ✅) | `#5FD398` (9.93 ✅) | `#1E5138` / `#A8E6C4` (6.46 ✅) | | 警告 | `#8A5C00` (5.58 ✅) | `#FFE9B8` / `#5C4100` (7.96 ✅) | `#F0BE4E` (10.74 ✅) | `#5C4100` / `#FFDCA8` (7.26 ✅) | | 错误 | `#B02A20` (6.31 ✅) | `#FFDDD8` / `#7A2A22` (7.60 ✅) | `#FF8474` (7.76 ✅) | `#7A2A22` / `#FFC9C2` (6.59 ✅) | | 信息 | `#0B6397` (6.21 ✅) | `#D7E9FA` / `#1A4763` (7.96 ✅) | `#7FC4F0` (9.75 ✅) | `#1A4763` / `#B8DCF5` (6.87 ✅) | > 括号中为对比度实测值(主色 vs surface;容器内文字 vs 容器)。 通过 `ThemeExtension` 暴露,避免散落硬编码: ```dart class AppSemanticColors extends ThemeExtension { const AppSemanticColors({ required this.success, required this.onSuccessContainer, required this.successContainer, required this.warning, required this.warningContainer, required this.onWarningContainer, required this.info, required this.infoContainer, required this.onInfoContainer, }); final Color success, successContainer, onSuccessContainer; final Color warning, warningContainer, onWarningContainer; final Color info, infoContainer, onInfoContainer; static const light = AppSemanticColors( success: Color(0xFF1E7A4E), successContainer: Color(0xFFD3EDDD), onSuccessContainer: Color(0xFF1E5138), warning: Color(0xFF8A5C00), warningContainer: Color(0xFFFFE9B8), onWarningContainer: Color(0xFF5C4100), info: Color(0xFF0B6397), infoContainer: Color(0xFFD7E9FA), onInfoContainer: Color(0xFF1A4763), ); static const dark = AppSemanticColors( success: Color(0xFF5FD398), successContainer: Color(0xFF1E5138), onSuccessContainer: Color(0xFFA8E6C4), warning: Color(0xFFF0BE4E), warningContainer: Color(0xFF5C4100), onWarningContainer: Color(0xFFFFDCA8), info: Color(0xFF7FC4F0), infoContainer: Color(0xFF1A4763), onInfoContainer: Color(0xFFB8DCF5), ); @override AppSemanticColors copyWith({/* … */}) => this; @override AppSemanticColors lerp(covariant AppSemanticColors? o, double t) => o ?? this; } /// 品牌色扩展:把「只能看不能点」的亮橙单独隔离,防止被误用为文字色。 class AppBrandExtension extends ThemeExtension { const AppBrandExtension({ this.cloud = AppBrand.cloud, this.cloudGold = AppBrand.cloudGold, this.onCloud = AppBrand.onCloud, this.gradient = AppBrand.cloudGradient, }); final Color cloud, cloudGold, onCloud; final LinearGradient gradient; @override ThemeExtension copyWith() => this; @override ThemeExtension lerp( covariant ThemeExtension? other, double t) => this; } /// 使用方式: /// final brand = Theme.of(context).extension()!; /// final semantic = Theme.of(context).extension()!; ``` ### 3.6 暖中性色阶(Neutral Ramp) 全部带暖偏移(R>G>B),与 logo 的 `#DBBB88` 同源。实测亮度严格单调: | Token | Light | 相对亮度 | 用途 | | --- | --- | --- | --- | | n0 | `#FFFFFF` | 1.000 | 弹窗/输入填充 | | n10 | `#FCFAF8` | 0.958 | **surface** | | n20 | `#F7F3EF` | 0.901 | surfaceContainerLow | | n30 | `#F2EDE8` | 0.853 | surfaceContainer | | n40 | `#ECE5DF` | 0.792 | surfaceContainerHigh | | n50 | `#E5DED7` | 0.738 | surfaceContainerHighest | | n60 | `#DCCFC2` | 0.637 | outlineVariant / 分隔线 | | n70 | `#C8BFB4` | 0.528 | 禁用态填充 | | n80 | `#8A8178` | 0.225 | **outline** | | n90 | `#6E655C` | 0.134 | **onSurfaceVariant** | | n100 | `#1A1614` | 0.008 | **onSurface** | | n110 | `#0F0D0A` | 0.004 | surfaceContainerLowest(dark) | | Token | Dark | 相对亮度 | 用途 | | --- | --- | --- | --- | | d10 | `#F3E9E2` | 0.828 | **onSurface** | | d20 | `#C4B8AE` | 0.491 | **onSurfaceVariant** | | d30 | `#7A7068` | 0.167 | **outline** | | d40 | `#453F39` | 0.051 | outlineVariant | | d50 | `#38332D` | 0.034 | surfaceContainerHighest | | d60 | `#2D2924` | 0.023 | surfaceContainerHigh | | d70 | `#23201B` | 0.015 | surfaceContainer | | d80 | `#1E1A16` | 0.011 | surfaceContainerLow | | d90 | `#16130F` | 0.007 | **surface** | | d100 | `#0F0D0A` | 0.004 | surfaceContainerLowest | ### 3.7 如何替换 `#2E7DFF`,及对可读性的影响 **替换路径(3 步)** 1. `lib/app/theme.dart:8` `primaryBlue` → 改名 `brandPrimary`,值 `#2E7DFF` → `#C2410C`; 同时**废弃 `ColorScheme.fromSeed`** 改为**显式 `ColorScheme(...)` 全量声明**。 > 原因:`fromSeed` 会按 HCT 算法生成容器色与辅助色,得到的是**带紫/蓝偏移**的派生色(蓝色种子尤其明显),无法精确控制品牌色;显式声明才能保证 `#FFD3B3` 这类 logo 同源的容器色落地。 2. 全局 `primaryBlue` 引用点逐一替换(清单见 §8.1); 3. 亮橙 `#FF914D` 通过 `ThemeExtension` 暴露,**不进入 ColorScheme**,避免被组件误用为文字色。 **替换后对可读性的影响(实测,非估算)** | 场景 | 旧(蓝 `#2E7DFF`) | 新(深橙 `#C2410C`) | 影响 | | --- | --- | --- | --- | | 主按钮白字 | 3.68(**未达 AA**) | **5.18 ✅** | **可读性提升** | | 主色作文字/图标 | 3.68 | **4.97 ✅** | **可读性提升** | | 选中态图标 on 容器 | — | **7.90 ✅** | 达标 | | 亮橙作图标(禁止) | — | 1.66 ❌ | **新增约束,必须遵守** | > 值得强调:换成深橙后,主按钮的对比度**反而从 3.68 提升到 5.18**,旧的科技蓝 `#2E7DFF` 配白字其实一直**未达 AA**——这是一次换色顺带修复的无障碍问题。 ### 3.8 对比度实测总表(全部通过) ``` [LIGHT] ✅ 5.18 #FFFFFF / #C2410C onPrimary / primary ✅ 10.38 #4A1D00 / #FFD3B3 onPrimaryContainer / primaryContainer ✅ 5.83 #FFFFFF / #8A5A3A onSecondary / secondary ✅ 7.90 #5A3A22 / #F5DFCB onSecondaryContainer / secondaryContainer ✅ 5.19 #FFFFFF / #9A6000 onTertiary / tertiary ✅ 7.75 #5C3D00 / #FFE0A3 onTertiaryContainer / tertiaryContainer ✅ 6.57 #FFFFFF / #B02A20 onError / error ✅ 7.60 #7A2A22 / #FFDDD8 onErrorContainer / errorContainer ✅ 17.26 #1A1614 / #FCFAF8 onSurface / surface ✅ 5.48 #6E655C / #FCFAF8 onSurfaceVariant / surface ✅ 3.67 #8A8178 / #FCFAF8 outline / surface (需 ≥3.0) ✅ 4.97 #C2410C / #FCFAF8 primary 作文本/图标 ✅ 6.99 #3D1A00 / #FF914D 深棕字 on 品牌橙(Banner/大色块) ✅ 7.90 #5A3A22 / #F5DFCB 底部导航选中图标 on indicator [DARK] ✅ 7.66 #542000 / #FFB487 onPrimary / primary ✅ 8.30 #FFDCC2 / #63300D onPrimaryContainer / primaryContainer ✅ 8.03 #542000 / #E8C39B onSecondary / secondary ✅ 7.89 #FFDCC2 / #5A3A22 onSecondaryContainer / secondaryContainer ✅ 7.78 #542000 / #F2BE62 onTertiary / tertiary ✅ 7.66 #FFDCC2 / #5C3D00 onTertiaryContainer / tertiaryContainer ✅ 5.65 #5A1610 / #FF8474 onError / error ✅ 6.59 #FFC9C2 / #7A2A22 onErrorContainer / errorContainer ✅ 15.49 #F3E9E2 / #16130F onSurface / surface ✅ 9.54 #C4B8AE / #16130F onSurfaceVariant / surface ✅ 3.83 #7A7068 / #16130F outline / surface (需 ≥3.0) ✅ 10.70 #FFB487 / #16130F primary 作文本/图标 ✅ 1.73 #63300D / #16130F primaryContainer 与 surface 可区分(需 ≥1.3) ``` --- ## 4. 圆角与阴影(呼应"云"意象) ### 4.1 与 logo 小圆角(3%)的取舍:为什么不全盘照搬 logo 圆角 6px / 192px = 3%。若**等比**映射到 UI 控件: | 控件 | 尺寸 | 3% 等比结果 | 评价 | | --- | --- | --- | --- | | 主按钮 | 320×48 | 1.4dp | ❌ 生硬,几乎直角 | | 标准卡片 | 360×140 | 4.2dp | ❌ 尖锐,与"云"完全无关 | | 底部弹窗 | 360×600 | 10.8dp | ⚠️ 偏小 | **结论:3% 不能直接等比继承。** 从 logo 中真正可继承的是三条**定性**特征: 1. **非胶囊** —— logo 是圆角矩形,不是圆形/胶囊,所以 UI 主体**不采用 999 全圆**; 2. **四角一致** —— 不做"左上大、右下小"这类不规则圆角(弹窗除外,见 4.2); 3. **克制感** —— 圆角明显但不夸张,落在移动端舒适区间的**中下段**。 定量上把落点定为**"容器短边的 5%~10%"**,正好落在 12~28dp 区间。这样既保住 logo 的克制基因,又满足移动端拇指触控的视觉引导需求。 ### 4.2 圆角梯度(`AppRadius`) ```dart class AppRadius { static const double xs = 4; // 进度条、内嵌小色块、图片缩略图角 static const double sm = 8; // Chip、SnackBar、Tooltip、小标签 static const double md = 12; // 输入框、表单控件(沿用现值,保持手感连续) static const double lg = 16; // ★ 标准卡片、按钮(FilledButton/OutlinedButton) static const double xl = 20; // 大卡片、列表分组容器、AppBar 底部两角 static const double xxl = 28; // ★ 底部弹窗顶部两角(云朵弧线的核心表达) static const double full = 999; // 仅用于状态 Chip / 头像 / pill 标签 /// 云朵轮廓:上缘多圆弧、下缘平缓。 static const BorderRadius sheetTop = BorderRadius.vertical( top: Radius.circular(xxl), // 28 ); /// 卡片式弹窗:上 28 / 下 12,形成"云朵被托起"的轮廓。 static const BorderRadius cloudCard = BorderRadius.only( topLeft: Radius.circular(xxl), // 28 topRight: Radius.circular(xxl), // 28 bottomLeft: Radius.circular(md), // 12 bottomRight: Radius.circular(md), // 12 ); } ``` **适用场景速查** | 圆角 | 组件 | | --- | --- | | `xs` 4 | `LinearProgressIndicator`、`ClipRRect` 缩略图、内嵌色块 | | `sm` 8 | `Chip`、`SnackBar`、`ActionChip`、小徽标 | | `md` 12 | `InputDecoration`(现值保留)、`InputDecorator`、`TextFormField` | | `lg` 16 | `Card`(列表项、统计卡、指标卡)、`FilledButton`、`OutlinedButton`、`TextButton` | | `xl` 20 | 分组容器、`ExpansionTile`、大尺寸 Banner 卡 | | `xxl` 28 | **底部弹窗顶部两角**、全屏 `Dialog` | | `full` 999 | `StatusChip`、`CircleAvatar`、状态 pill | > **关键改动**:M3 的 `FilledButton` 默认是全圆角(20dp,实际渲染接近胶囊)。必须显式覆盖为 `lg`(16),否则按钮与卡片的圆角会脱节。 ### 4.3 云感阴影(替代 `elevation: 1`) **现状问题**(`lib/app/theme.dart:41`):`CardTheme(elevation: 1)`。 M3 中 elevation 1 只对应 5% 的 surfaceTint 染色 + 一层极淡引擎阴影,**几乎看不出层次**;且 Flutter 的 `Material` elevation 阴影由引擎按固定曲线绘制,**颜色可改(`shadowColor`)、模糊/偏移曲线不可控**。 **方案:A/B 双轨** #### 方案 A(全局生效,低风险)—— 改 `ThemeData` / `CardThemeData` ```dart // 暖调阴影色替代默认纯黑 ThemeData( shadowColor: AppBrand.shadowTint, // #5C2E12 ) CardThemeData( elevation: 0, // 交给自定义 BoxShadow surfaceTintColor: scheme.surfaceTint, // 保留暖调染色 shadowColor: AppBrand.shadowTint, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.lg)), ) ``` #### 方案 B(推荐用于核心卡片/弹窗)—— `CloudCard` 自定义组件 新建 `lib/widgets/cloud_card.dart`,用 `BoxShadow` 精确控制,**这是"云朵漂浮"的真正实现**: ```dart class AppShadows { static const Color _tint = Color(0xFF5C2E12); // 暖棕,非纯黑 /// 极轻:列表项默认态(替代 elevation:1) static const List xs = [ BoxShadow(color: Color(0x0F5C2E12), blurRadius: 8, offset: Offset(0, 2), spreadRadius: -1), // α=6% ]; /// 轻:卡片 hover/按压 static const List sm = [ BoxShadow(color: Color(0x145C2E12), blurRadius: 12, offset: Offset(0, 4), spreadRadius: -2), // α=8% ]; /// 中:★ 标准卡片、指标卡 static const List md = [ BoxShadow(color: Color(0x1A5C2E12), blurRadius: 20, offset: Offset(0, 8), spreadRadius: -4), // α=10% ]; /// 强:悬浮层、下拉菜单、SnackBar static const List lg = [ BoxShadow(color: Color(0x245C2E12), blurRadius: 32, offset: Offset(0, 12), spreadRadius: -6), // α=14% ]; /// 最强:★ 底部弹窗、FAB、Dialog static const List xl = [ BoxShadow(color: Color(0x245C2E12), blurRadius: 48, offset: Offset(0, 20), spreadRadius: -8), // α=14% ]; /// 深色模式:暖色阴影在暗底上不可见(实测 6% 暖棕在 #16130F 上对比仅 1.05), /// 改用纯黑 + 1px 暖色描边来表达边界。 static const List dark = [ BoxShadow(color: Color(0x66000000), blurRadius: 24, offset: Offset(0, 8), spreadRadius: -4), ]; } class CloudCard extends StatelessWidget { const CloudCard({ super.key, required this.child, this.padding = const EdgeInsets.all(16), this.margin, this.level = CloudShadowLevel.md, this.onTap, }); final Widget child; final EdgeInsetsGeometry padding; final EdgeInsetsGeometry? margin; final CloudShadowLevel level; final VoidCallback? onTap; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; final isDark = scheme.brightness == Brightness.dark; return Container( margin: margin, decoration: BoxDecoration( color: scheme.surface, borderRadius: BorderRadius.circular(AppRadius.lg), // 深色下加暖色描边替代阴影 border: isDark ? Border.all(color: scheme.outlineVariant, width: 1) : null, boxShadow: isDark ? AppShadows.dark : level.shadows, ), child: ClipRRect( borderRadius: BorderRadius.circular(AppRadius.lg), child: Material( color: Colors.transparent, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(AppRadius.lg), child: Padding(padding: padding, child: child), ), ), ), ); } } ``` **参数设计依据** | 参数 | 取值逻辑 | | --- | --- | | `blurRadius` 8→48 | 远大于 `offset`(2→20),**模糊 : 偏移 ≈ 4:1** → 光是"漫射"的,像云影而非投影;M2/M3 默认约 2:1,偏硬 | | `offset` 只有 Y 轴 | 光源正上方,符合"云在上方漂浮" | | `spreadRadius` 负值 | 收缩阴影,避免卡片四周出现脏边 | | `α` 6%~14% | 实测:α=6% 在 `#FCFAF8` 上混色 `#F2EEEA`(对比 1.11,可辨但不刺眼);α=14% 混色 `#E6DDD8`(对比 1.29,用于弹窗层级) | | 颜色 `#5C2E12` 而非黑 | **这是"暖"的关键**:暖棕阴影在暖白底上是"暖灰",纯黑阴影会发灰发脏 | | 深色模式换纯黑 + 描边 | 实测暖棕 α=6% 在 `#16130F` 上对比仅 1.05,**完全不可见**;必须换纯黑 α=40%(对比 1.06)+ `outlineVariant` 描边 | **M3 elevation 染色(surfaceTint)配合** M3 的 elevation→染色透明度曲线(Flutter 源码 `elevation_overlay.dart` 实测):`elevation 1 → 5%`、`3 → 8%`、`6 → 11%`、`12 → 12%`、`16 → 14%`。 建议:**保留 `surfaceTintColor = primary`**,让卡片在低 elevation 下获得 5%~8% 的暖橙染色 —— 这是一条**零成本把品牌色渗进全站卡片**的路径,且极其克制(5% 几乎只是"一点点暖意")。若嫌脏,设为 `Colors.transparent` 即可。 --- ## 5. 图标风格 ### 5.1 现状问题 - `lib/app/theme.dart` **未定义 `iconTheme`**,图标尺寸/颜色随组件默认值漂移; - 全项目混用填充态与线性态:`Icons.devices`(填充)、`Icons.dns_outlined`(线性)、`Icons.dns`(填充)共存; - 尺寸无栅格:14、16、20、28、32、40、48、56 随意出现(实测 `store_list_page.dart:365` 用 14、`device_list_tile.dart:71` 用 32、`home_page.dart:161` 用 28); - 无统一的端点/描边规范。 ### 5.2 统一图标语言 | 维度 | 规范 | 说明 | | --- | --- | --- | | **风格** | **Outlined(线性)为默认**,填充态仅用于"选中" | 线性更轻,呼应"云"的轻盈;底部导航现状已符合(`home_outlined` / `home`),予以保留并推广 | | **描边粗细** | 1.8 ~ 2.0 dp(在 24dp 栅格下) | Material Symbols Outlined 默认 ≈ 2.0 | | **端点/连接** | **圆角端点(round cap)+ 圆角连接(round join)** | **这是呼应云朵弧线最直接的手法**;比直角端点更柔和 | | **栅格** | 24dp 网格 + 2dp 内边距(有效绘制区 20dp) | 与 Material 规范一致 | | **光学尺寸** | `opticalSize = 20`(小尺寸场景) | 让小图标自动加粗描边,保持视觉重量一致 | | **禁用** | 直角矩形类图标(`Icons.square`、`Icons.stop`、`Icons.check_box` 填充态) | 与圆润弧线冲突 | ### 5.3 尺寸栅格(`AppIconSize`) ```dart class AppIconSize { static const double inline = 16; // 正文行内图标 static const double dense = 20; // 密集信息行(门店信息行 _infoLine) static const double regular = 24; // ★ 默认:列表 leading、按钮内、AppBar actions static const double medium = 28; // 统计卡强调图标 static const double large = 32; // 列表主 leading(设备列表) static const double display = 48; // 空状态 / 结果页 static const double hero = 64; // 关于页 logo、引导页 } ``` ### 5.4 来源与落地(分两步,P0 零依赖) **P0(零依赖,立即可做)**:Flutter 自带的 `MaterialIcons` 字体中,`Icons.xxx_outlined` 变体本身就是线性风格,**无需新增任何依赖**。 ```dart // 全局强制线性 + 统一尺寸与颜色 ThemeData( iconTheme: IconThemeData( size: AppIconSize.regular, // 24 color: scheme.onSurfaceVariant, // ↓ 可变字体轴:仅对支持 Variable Font 的字体生效 fill: 0.0, // 0 = 线性,1 = 填充 weight: 400, grade: 0, opticalSize: 24, ), primaryIconTheme: IconThemeData(size: 24, color: scheme.primary), ) ``` > **⚠️ 重要说明**:`IconThemeData.fill / weight / grade / opticalSize` 需要图标字体支持 OpenType 可变轴。Flutter 内置的 **`MaterialIcons` 不支持**,因此 `fill: 0` 目前是 **no-op**。 > 真正强制线性风格,P0 靠**统一改用 `Icons.xxx_outlined` 变体**(见 §7 清单);P2 再考虑引入 Material Symbols 可变字体让这四个轴生效。 **P2(可选增强)**:引入 `Material Symbols Outlined` 可变字体(`google_fonts` 或自托管 woff2,约 300KB),在 `pubspec.yaml` 声明为 `fontFamily`,之后 `fill: 0` 等轴才能真正全局生效,并可做"线性→填充"的平滑过渡动画。 ### 5.5 语义色图标映射(替换散落的 `Colors.green/orange/red`) | 语义 | 图标 | 颜色来源 | | --- | --- | --- | | 设备在线 | `Icons.cloud_done_outlined`(**呼应云**) | `AppSemanticColors.success` | | 设备离线 | `Icons.cloud_off_outlined` | `onSurfaceVariant` | | 设备告警 | `Icons.warning_amber_outlined` | `AppSemanticColors.warning` | | 门店正常 | `Icons.storefront_outlined` | success | | 门店停用 | `Icons.block_outlined` | `onSurfaceVariant` | | 删除 | `Icons.delete_outline` | `error` | | 编辑 | `Icons.edit_outlined` | `primary` | | 发送指令 | `Icons.send_outlined` | `primary` | > 把 `Icons.dns` / `Icons.dns_outlined`(机柜,与"云"无关)换成 `Icons.cloud_*` 系列 —— 这是**最低成本、最高回报**的图标层品牌化动作。 --- ## 6. 移动端布局与触控适配 ### 6.1 触控目标规范 | 项 | 规范 | 依据 | | --- | --- | --- | | **最小触控区** | **48×48 dp** | Material 3 / Android 无障碍建议(Apple 为 44) | | **相邻可点元素间距** | **≥ 8 dp** | 防误触 | | **主操作按钮高度** | **≥ 52 dp** | 单手拇指舒适区 | | **输入框高度** | **≥ 56 dp**(含 label 则为 60) | 保证点击 + 文字可读 | | **列表项高度** | 单行 **≥ 56 dp**,多行 **≥ 72 dp** | `ListTile` 默认 56,符合 | **落地方式(全局一次性生效)** ```dart ThemeData( // 让 IconButton 的点击区撑到 48(其默认 minimumSize 仅 40×40) iconButtonTheme: IconButtonThemeData( style: IconButton.styleFrom( minimumSize: const Size(48, 48), tapTargetSize: MaterialTapTargetSize.padded, padding: EdgeInsets.zero, ), ), materialTapTargetSize: MaterialTapTargetSize.padded, // 手小/戴手套场景:把默认 compact 改为 standard,控件间距 +4dp visualDensity: VisualDensity.standard, // 按钮统一高度与圆角 filledButtonTheme: FilledButtonThemeData( style: FilledButton.styleFrom( minimumSize: const Size(64, 52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.lg)), ), ), outlinedButtonTheme: OutlinedButtonThemeData( style: OutlinedButton.styleFrom( minimumSize: const Size(64, 52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.lg)), ), ), textButtonTheme: TextButtonThemeData( style: TextButton.styleFrom( minimumSize: const Size(64, 48), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.lg)), ), ), ) ``` **重点修复**:`lib/widgets/device_list_tile.dart:81` 的 `IconButton` 与 `StatusChip` 挤在同一 `trailing` 的 `Row` 内,两者间距为 0,且 `StatusChip` 高约 24dp —— **这是全项目误触风险最高的一处**。建议: - `StatusChip` 外层包 `Padding(padding: EdgeInsets.only(right: 8))`; - 或把"发送指令"下沉到左滑(Slidable)/ 长按菜单,释放 `trailing` 空间。 ### 6.2 单手操作友好 | 策略 | 具体做法 | | --- | --- | | **关键操作下沉** | 列表页 FAB 保留,但改为 `FloatingActionButton.extended` 置于 `endFloat`,并包 `SafeArea`;破坏性操作(删除)始终放在**底部弹窗的吸底栏**,不放 AppBar | | **主操作吸底** | 长表单的"保存/创建"改为**吸底按钮**(见 6.4),不再埋在滚动区末尾 | | **AppBar 精简** | `actions` 最多 2 个图标;超过 2 个收进 `PopupMenuButton`(右上角是单手最难触及区) | | **底部导航** | 5 个 tab 保持(现状 `AppBottomNav` 已符合 M3),但**横屏切换为 `NavigationRail`**(见 6.6) | | **删除二次确认** | 保留现状 `AlertDialog`,但主按钮改 `FilledButton(error)`,`isDefaultAction` 给"取消" | ### 6.3 列表 / 卡片 / 表单在窄屏下的排版规则 **竖屏(< 600dp,手机主力场景)** | 元素 | 规则 | | --- | --- | | 列表项 | 卡片式,`margin: EdgeInsets.symmetric(horizontal: 12, vertical: 6)`,圆角 `lg`(16),阴影 `xs` | | 卡片内边距 | `16`(现状 `store_list_page` 为 20,横屏才用 20) | | 标题 | `titleSmall` + `w600`,**最多 1 行** `TextOverflow.ellipsis` | | 副信息行 | 图标 `dense`(20) + 文字 `bodySmall`(12/13),**最多 4 行**,超出折叠 | | 状态 Chip | 右上角,`radius.full`,与标题同行;**不与图标按钮相邻**(见 6.1) | | 统计卡 Grid | `crossAxisCount: 2`,`childAspectRatio: 1.3`(现状 1.4 在竖屏偏扁) | | 表单 | **单列**,`labelText` 常驻显示(不用 `hintText` 占位),字段间距 `12` | **横屏(1600×900,实测主力场景)—— 见 6.6** ### 6.4 输入框与底部弹窗适配(项目大量使用 bottom sheet) #### 输入框 ```dart inputDecorationTheme: InputDecorationTheme( filled: true, fillColor: scheme.surfaceContainerHighest, // 替换 surfaceContainerHighest(蓝灰) → 暖灰 contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18), // 12 → 18,高度 ≥56 prefixIconConstraints: const BoxConstraints(minWidth: 48, minHeight: 48), suffixIconConstraints: const BoxConstraints(minWidth: 48, minHeight: 48), border: OutlineInputBorder( borderRadius: BorderRadius.circular(AppRadius.md), // 12 borderSide: BorderSide(color: scheme.outlineVariant), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(AppRadius.md), borderSide: BorderSide(color: scheme.outline), // 常驻描边,弱视力用户可辨 ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(AppRadius.md), borderSide: BorderSide(color: scheme.primary, width: 2),// 聚焦加粗,不再依赖颜色单独表意 ), errorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(AppRadius.md), borderSide: BorderSide(color: scheme.error), ), floatingLabelStyle: MaterialStateTextStyle.resolveWith((s) => TextStyle(color: s.contains(MaterialState.error) ? scheme.error : s.contains(MaterialState.focused) ? scheme.primary : scheme.onSurfaceVariant)), labelStyle: TextStyle(color: scheme.onSurfaceVariant), hintStyle: TextStyle(color: scheme.onSurfaceVariant), errorStyle: TextStyle(color: scheme.error, fontSize: 12), errorMaxLines: 2, ) ``` 其他要点: - 每个 `TextFormField` 显式声明 `textInputAction`(`next` / `done`)并用 `onFieldSubmitted` 串联焦点 —— 现状门店表单 7 个字段均未设置,键盘上全是"换行"; - 数字字段用 `keyboardType: TextInputType.number` + `inputFormatters: [FilteringTextInputFormatter.digitsOnly]`(现状门店终端数字段已设 keyboardType,但缺格式化器); - 手机号字段加 `maxLength: 11`。 #### 底部弹窗(**本项目最高优先级的结构性问题**) 现状(`lib/screens/store/store_list_page.dart:140-154` + `1237-1421`): ```dart showModalBottomSheet( context: context, isScrollControlled: true, // ← 全屏高度 builder: (ctx) => _StoreFormSheet(...), // ← 内部 SingleChildScrollView 无高度上限 ); // 表单内部:保存按钮在 SingleChildScrollView 的 children 末尾(第 1404 行) ``` **四个问题**: 1. `isScrollControlled: true` 但**未限制最大高度** → 内容多时弹窗顶到状态栏,用户失去"这是弹窗"的感知,且键盘弹起后内容被挤没; 2. **`useSafeArea` 默认 `false`**(Flutter 源码 `bottom_sheet.dart:874`)→ 全项目 **30 处** `showModalBottomSheet` 全部存在内容被系统导航栏/手势条遮挡的风险; 3. **无 `showDragHandle`** → 没有可拖拽的视觉暗示,用户不知道能下滑关闭; 4. **保存按钮埋在滚动区末尾** → 门店新增表单有 7 个字段 + 主题模板网格 + 激活码区块,实际高度远超一屏,**必须滚到底才能点"创建"**。 **改造方案** ```dart // ① 全局兜底:在 ThemeData 里一次性解决形状、宽度、把手、安全区 bottomSheetTheme: BottomSheetThemeData( showDragHandle: true, useSafeArea: true, // ★ 全局开启,30 处弹窗一次性受益 backgroundColor: scheme.surface, surfaceTintColor: scheme.surfaceTint, elevation: 0, // 交给自定义阴影 modalBackgroundColor: scheme.surface, clipBehavior: Clip.antiAlias, shape: const RoundedRectangleBorder( borderRadius: AppRadius.sheetTop, // 顶部两角 28(云朵弧线) ), // ★ 横屏不再铺满:最大宽度 640 并居中 constraints: const BoxConstraints(maxWidth: 640), ), // ② 页面级:限制高度 + 吸底操作栏 Future showAppBottomSheet({ required BuildContext context, required WidgetBuilder bodyBuilder, required Widget footer, // 吸底操作栏 String? title, }) { return showModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, useSafeArea: true, constraints: BoxConstraints( maxWidth: 640, maxHeight: MediaQuery.sizeOf(context).height * 0.92, ), builder: (ctx) => _AppSheetScaffold( title: title, body: bodyBuilder(ctx), footer: footer, ), ); } class _AppSheetScaffold extends StatelessWidget { const _AppSheetScaffold({this.title, required this.body, required this.footer}); final String? title; final Widget body; final Widget footer; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; return Column( mainAxisSize: MainAxisSize.min, children: [ if (title != null) Padding( padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), child: Align( alignment: Alignment.centerLeft, child: Text(title!, style: Theme.of(context).textTheme.titleLarge ?.copyWith(fontWeight: FontWeight.bold)), ), ), // 可滚动内容区 Flexible( child: SingleChildScrollView( padding: EdgeInsets.only( left: 20, right: 20, bottom: 20, // 键盘弹起时抬起内容 top: 0, ), child: body, ), ), // ★ 吸底操作栏:始终可见,不受滚动影响 Container( decoration: BoxDecoration( color: scheme.surface, border: Border(top: BorderSide(color: scheme.outlineVariant)), boxShadow: AppShadows.lg, ), padding: EdgeInsets.fromLTRB( 20, 12, 20, 12 + MediaQuery.viewInsetsOf(context).bottom * 0, ), child: SafeArea(top: false, child: footer), ), ], ); } } ``` > 键盘适配:`SingleChildScrollView(padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom))` 仍需在 `body` 内保留,吸底栏用 `SafeArea(top: false)` 处理底部手势条。 ### 6.5 横屏 1600×900 与竖屏适配策略(**重点**) #### 现状诊断(源码实证) | 检查项 | 结果 | | --- | --- | | `OrientationBuilder` | **0 处** | | 响应式断点 / `LayoutBuilder`(业务布局用) | **0 处**(仅 `marquee_management_page.dart:1301/1344` 的两处与布局无关) | | `GridView` 写死 `crossAxisCount: 2` | `home_page.dart:67`、`device_detail_page.dart:312` | | 内容最大宽度约束 | **仅登录页有**(`login_page.dart:158` `maxWidth: 420`),其余页面全无 | | 底部导航 | 恒为 `NavigationBar`(高度 80),横屏下吃掉 8.9% 的垂直空间 | **后果**:1600px 宽下,首页统计卡每张被拉到约 780px 宽、`GridView` 仍是 2 列 → 单屏只有 4 张巨卡;列表项文字行长超过 200 字符,眼动距离极长;底部弹窗铺满 1600px 宽,表单输入框宽到失真。而 900px 高度下 `NavigationBar`(80) + `AppBar`(56) 已占去 15%。 #### 适配方案 **① 建立断点工具 `lib/utils/responsive.dart`** ```dart enum AppWindowSize { compact, medium, expanded } class AppResponsive { AppResponsive(this.size, this.orientation); final Size size; final Orientation orientation; factory AppResponsive.of(BuildContext context) => AppResponsive( MediaQuery.sizeOf(context), MediaQuery.orientationOf(context), ); /// ★ 用 shortestSide 而非 width:横屏 1600×900 的 shortSide = 900 double get shortSide => size.shortestSide; bool get isLandscape => orientation == Orientation.landscape; AppWindowSize get windowSize { final s = shortSide; if (s < 600) return AppWindowSize.compact; // 手机竖屏 360-430 if (s < 840) return AppWindowSize.medium; // ★ 手机横屏 900 落这里 return AppWindowSize.expanded; // 平板 / 大屏 } /// 列表页是否启用「左列表 + 右详情」双栏 bool get useMasterDetail => size.width >= 840 && isLandscape; /// Grid 列数:按可用宽度自适应(1600px → 4 列;竖屏 400px → 2 列) int gridCount({double minTileWidth = 320}) => (size.width / minTileWidth).floor().clamp(2, 6); /// 内容最大宽度(表单/详情居中,避免行长失控) double get contentMaxWidth => switch (windowSize) { AppWindowSize.compact => double.infinity, AppWindowSize.medium => 560, AppWindowSize.expanded => 720, }; /// 页面水平外边距 double get gutter => switch (windowSize) { AppWindowSize.compact => 12, AppWindowSize.medium => 24, AppWindowSize.expanded => 32, }; /// 卡片内边距 double get cardPadding => switch (windowSize) { AppWindowSize.compact => 16, _ => 20, }; } extension AppResponsiveX on BuildContext { AppResponsive get responsive => AppResponsive.of(this); } ``` **② 底部导航:横屏切换为 `NavigationRail`** ```dart // lib/widgets/app_bottom_nav.dart @override Widget build(BuildContext context) { final r = context.responsive; if (r.isLandscape && r.size.width >= 840) { return Scaffold( body: Row( children: [ NavigationRail( selectedIndex: shell.currentIndex, onDestinationSelected: (i) => shell.goBranch(i, initialLocation: i == shell.currentIndex), labelType: NavigationRailLabelType.all, // 品牌色指示:indicatorColor 走 secondaryContainer destinations: _destinations(context), ), const VerticalDivider(width: 1), Expanded(child: ClipRect(child: shell)), ], ), ); } return Scaffold( body: shell, bottomNavigationBar: NavigationBar( selectedIndex: shell.currentIndex, onDestinationSelected: (i) => shell.goBranch(i, initialLocation: i == shell.currentIndex), destinations: _destinations(context), ), ); } ``` > 收益:横屏下不再有 80dp 的底部导航条,垂直空间 +80dp(900 高度的 8.9%),且 5 个 tab 移到左侧后**拇指横向可达,比底部更易点**。 **③ 列表页双栏主从布局(门店 / 设备)** 横屏 ≥840px 时,`StoreListPage` / `DeviceListPage` 改为: ``` ┌──────────────┬───────────────────────────────┐ │ 搜索框 │ 门店详情(原 bottom sheet) │ │ 门店列表 340dp│ ← 点击列表项直接在右侧展开 │ │ │ │ │ [FAB 新增] │ [编辑] [指令] [删除] │ └──────────────┴───────────────────────────────┘ ``` - 左栏固定 `width: 340`,右栏 `Expanded`; - 点击列表项**不再弹 bottom sheet**,直接更新右栏内容(大幅减少横屏下的弹窗使用); - 竖屏保持现状:点击 → 底部弹窗。 **④ GridView 列数自适应** ```dart // home_page.dart:66 与 device_detail_page.dart:311 GridView.count( crossAxisCount: context.responsive.gridCount(minTileWidth: 300), // 竖屏2 / 横屏4 shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), mainAxisSpacing: 12, crossAxisSpacing: 12, childAspectRatio: context.responsive.isLandscape ? 1.8 : 1.3, children: [...], ) ``` **⑤ 内容宽度约束** 表单、详情页正文统一包一层: ```dart Align( alignment: Alignment.topCenter, child: ConstrainedBox( constraints: BoxConstraints(maxWidth: context.responsive.contentMaxWidth), child: content, ), ) ``` **⑥ 底部弹窗横屏改版** `BottomSheetThemeData.constraints: BoxConstraints(maxWidth: 640)` 已在主题层全局生效(见 6.4),横屏下弹窗自动**居中、宽 640**、两侧留白,表单不再横向失真。 此外横屏下把 `maxHeight` 收紧到 `0.85`(横屏 900 高度下 92% = 828 太高)。 **⑦ AppBar 横屏压缩** ```dart appBarTheme: AppBarTheme( centerTitle: true, backgroundColor: scheme.surface, foregroundColor: scheme.onSurface, surfaceTintColor: scheme.surfaceTint, elevation: 0, scrolledUnderElevation: 3, // ★ 新增:滚动后浮起,解决"AppBar 与内容无分隔" toolbarHeight: 52, // 横屏时收紧(用 responsive 覆盖为 48) titleSpacing: 16, actionsIconTheme: IconThemeData(size: 24, color: scheme.onSurfaceVariant), ), ``` **⑧ 横屏专属检查清单** | 页面 | 横屏处理 | | --- | --- | | `login_page.dart` | 已有 `maxWidth: 420` ✅;补充:改为**左右分栏**(左品牌渐变区 40% + 右表单 60%),充分利用 1600px | | `home_page.dart` | Grid 4 列;快捷入口 3 个 → 6 个(横排) | | `device_list_page.dart` | 双栏主从 | | `store_list_page.dart` | 双栏主从;FAB 移入左栏右下 | | `device_detail_page.dart` | 指标 Grid 4 列;基本信息改为两列 `Wrap` | | `device_scan_page.dart` | 扫码取景框需按 `shortSide` 而非 `width` 计算尺寸,否则横屏下被压扁 | | `store_config_page.dart` / `branding_editor.dart` | 预览区左、编辑区右(分栏),现状是上下堆叠,横屏需滚很远 | --- ## 7. 落地清单 ### 7.1 P0 —— 品牌一致性 + 阻塞性问题(建议 1 个迭代内完成) | # | 文件 | 改动点 | | --- | --- | --- | | P0-1 | `lib/app/theme.dart` | 全量重写:`primaryBlue` → `brandPrimary = #C2410C`;`ColorScheme.fromSeed` → 显式 `ColorScheme(...)`(浅/深两套);新增 `AppRadius`、`AppShadows`、`AppIconSize`、`AppBrand`、`AppSemanticColors` | | P0-2 | `lib/app/theme.dart` | 新增 `iconTheme` / `primaryIconTheme`;`cardTheme` elevation 1→0 + `CloudCard` 阴影;`bottomSheetTheme`(`showDragHandle: true` + `useSafeArea: true` + `maxWidth: 640` + 顶部 28 圆角);`appBarTheme` 补 `scrolledUnderElevation: 3` / `surfaceTintColor`;`visualDensity` / `iconButtonTheme` / 三个 ButtonTheme(48/52 触控 + 16 圆角);`extensions: [AppBrand.light, AppSemanticColors.light]` | | P0-3 | `lib/screens/login/login_page.dart` | **整页硬编码深色改为跟随主题**:L152 `backgroundColor: #121212` → `scheme.surface`;L149 `cardColor: #0D2137`(**偏蓝**)→ `scheme.surfaceContainerLow`;L145-148 四个输入色 → 主题 token;L205/233/280 `#2E7DFF` → `scheme.primary`;L350 Checkbox → 主题;L370/372 按钮 `#2E7DFF`/`#1A4480` → `scheme.primary` / `onSurface.withOpacity(.12)` | | P0-4 | `lib/screens/login/login_page.dart` | 品牌化:Logo 从 80×80 → **112×112**,下方加 `AppBrand.cloudGradient` 文字标题 / 或顶部加一条橙→金渐变的"云朵"装饰带;L158 `maxWidth: 420` 保留并推广 | | P0-5 | `lib/main.dart` | L60-61 `statusBarIconBrightness: Brightness.light` **硬编码 → 跟随 `themeMode`**(当前浅色模式下状态栏图标不可见,是真 bug);方案:把 `setSystemUIOverlayStyle` 移到 `OpsConsoleApp.build` 内按 `theme.brightness` 动态设置 | | P0-6 | `lib/widgets/store_theme_grid.dart` | L77 选中边框 `Color(0xFF2E7DFF)` → `Theme.of(context).colorScheme.primary` | | P0-7 | `lib/providers/theme_provider.dart` | L7 默认 `ThemeMode.dark` → 建议改 `ThemeMode.light`(logo 明度均值 245/255,"明亮轻盈"才是品牌主场);或保留 dark 但确保深色方案已按 §3.4 落地 | | P0-8 | `lib/screens/store/store_list_page.dart` | 三处 `showModalBottomSheet`(L142/158/188)改用 `showAppBottomSheet`(限高 92% + 吸底栏 + 安全区 + 把手);L1404-1416 保存按钮**移出滚动区**改为吸底 | | P0-9 | `lib/utils/responsive.dart` | **新建**:`AppResponsive` 断点工具 + `BuildContext.responsive` 扩展 | ### 7.2 P1 —— 布局与触控(第 2 个迭代) | # | 文件 | 改动点 | | --- | --- | --- | | P1-1 | `lib/widgets/app_bottom_nav.dart` | 横屏且宽 ≥840 时切换 `NavigationRail`;统一 `destinations` 提取为方法 | | P1-2 | `lib/widgets/cloud_card.dart` | **新建** `CloudCard` 组件(§4.3 方案 B) | | P1-3 | `lib/widgets/app_bottom_sheet.dart` | **新建** `showAppBottomSheet` + 吸底栏脚手架(§6.4) | | P1-4 | `lib/screens/home/home_page.dart` | L67 `crossAxisCount: 2` → `responsive.gridCount()`;L78-96 `Colors.blue/purple/green/red` → `AppSemanticColors` / 主题色;`_StatCard` 换 `CloudCard`;`_QuickAction` 圆角 + 48 触控 | | P1-5 | `lib/screens/home/device_detail_page.dart` | L312 `crossAxisCount: 2` → `responsive.gridCount()`;L325/334/343 `Colors.red/orange` → 语义色;L535 `Colors.redAccent` → `scheme.error` | | P1-6 | `lib/screens/store/store_list_page.dart` | L298/474/479/487/528/542/1221 `Colors.grey/green/teal/red` → 主题语义色;`_StoreListTile` 换 `CloudCard`(圆角 16 + 阴影 xs);L365 图标 14→20;列表项 `margin` 用 `responsive.gutter` | | P1-7 | `lib/widgets/device_list_tile.dart` | L71 `Icons.dns/dns_outlined` → `Icons.cloud_done_outlined` / `Icons.cloud_off_outlined`;L81 `IconButton` 与 `StatusChip` 间距补 8dp;换 `CloudCard` | | P1-8 | `lib/widgets/status_chip.dart` | L16-20 `Colors.green/grey/orange` → `AppSemanticColors`(成功/中性/警告) | | P1-9 | `lib/screens/store/org_tree_page.dart` | L280-290 `_TypeInfo` 六色(`purple/blue/orange/green/grey`)→ 主题色板内取色(避免与品牌橙混淆的 `Colors.orange`);L63/351 `Colors.red` → `scheme.error` | | P1-10 | `lib/screens/device/device_activation_page.dart` | L119/293 `Colors.orange` → `scheme.primary`(**注意:这里用橙色表示"可点击",换色后必须改成 primary,否则与新品牌橙冲突**);L158 语义色 | | P1-11 | `lib/widgets/region_picker.dart` | L189 `Colors.blue` → `scheme.primary`;L154 `size.height * 0.6` 改按断点(横屏改 0.8) | | P1-12 | `lib/screens/alerts/alerts_page.dart` | L15/17/19 `Colors.red/orange/blue` → 语义色 | | P1-13 | `lib/screens/admin/admin_hub_page.dart` | L19/25/31/37/43 五色 → 语义色 / 主题色板 | | P1-14 | 其余 20+ 处 `showModalBottomSheet` | 统一加 `useSafeArea: true` + `constraints`(主题层已全局兜底 `showDragHandle`/`shape`/`maxWidth`,页面层补 `maxHeight`) | | P1-15 | 全项目 `Icons.xxx` | 非选中态统一改 `_outlined` 变体;尺寸收敛到 `AppIconSize` 栅格 | ### 7.3 P2 —— 增强与品牌渗透 | # | 文件 | 改动点 | | --- | --- | --- | | P2-1 | `lib/screens/store/store_list_page.dart`
`lib/screens/home/device_list_page.dart` | 横屏双栏主从布局(§6.5-③) | | P2-2 | `lib/screens/login/login_page.dart` | 横屏左右分栏(左品牌渐变 + 右表单) | | P2-3 | `pubspec.yaml` | 引入 Material Symbols Outlined 可变字体,让 `IconThemeData.fill/weight/grade/opticalSize` 真正生效 | | P2-4 | AppBar / 首页 | 增加"云朵"品牌元素:首页顶部加一条 `AppBrand.cloudGradient` 的渐隐云带;AppBar 标题旁加 20dp 小 logo(品牌渗透,现状 logo 仅出现于登录页与设置页) | | P2-5 | `lib/screens/device/device_scan_page.dart` | 取景框按 `shortestSide` 计算,修复横屏压扁 | | P2-6 | `lib/screens/device/store_config_page.dart`
`lib/widgets/branding_editor.dart` | 横屏分栏(左预览 / 右编辑) | | P2-7 | `lib/main.dart` | L193/255 错误页 `Colors.red.shade50` 硬编码 → 跟随主题(深色模式下刺眼) | | P2-8 | 全局 | 空状态 / 加载 / 错误三态组件统一(现状各页自行拼装,风格不一) | ### 7.4 `lib/app/theme.dart` 改造后关键代码片段 ```dart import 'package:flutter/material.dart'; /// 品牌原色(直接取自 assets/images/logo.png 实测像素,勿随意更改)。 class AppBrand { const AppBrand._(); static const Color cloud = Color(0xFFFF914D); // logo 主色 static const Color cloudGold = Color(0xFFFFBE58); // logo 金黄 static const Color cloudLight = Color(0xFFFFB487); static const Color onCloud = Color(0xFF3D1A00); // 亮橙上唯一合法文字色 static const LinearGradient cloudGradient = LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFFF914D), Color(0xFFFFBE58)], ); static const Color shadowTint = Color(0xFF5C2E12); // 暖调阴影 } /// 圆角梯度(4dp 栅格;源自 logo 圆角矩形 6/192≈3% 的"克制圆角"语义)。 class AppRadius { const AppRadius._(); static const double xs = 4, sm = 8, md = 12, lg = 16, xl = 20, xxl = 28; static const double full = 999; static const BorderRadius sheetTop = BorderRadius.vertical(top: Radius.circular(xxl)); } /// 云感阴影:大模糊、小偏移、低透明、暖色 —— 替代扁平的 elevation:1。 class AppShadows { const AppShadows._(); static const List xs = [ BoxShadow(color: Color(0x0F5C2E12), blurRadius: 8, offset: Offset(0, 2), spreadRadius: -1), ]; static const List sm = [ BoxShadow(color: Color(0x145C2E12), blurRadius: 12, offset: Offset(0, 4), spreadRadius: -2), ]; static const List md = [ BoxShadow(color: Color(0x1A5C2E12), blurRadius: 20, offset: Offset(0, 8), spreadRadius: -4), ]; static const List lg = [ BoxShadow(color: Color(0x245C2E12), blurRadius: 32, offset: Offset(0, 12), spreadRadius: -6), ]; static const List xl = [ BoxShadow(color: Color(0x245C2E12), blurRadius: 48, offset: Offset(0, 20), spreadRadius: -8), ]; /// 深色模式:暖色阴影在暗底不可见(实测对比 1.05),改用纯黑 + 描边。 static const List dark = [ BoxShadow(color: Color(0x66000000), blurRadius: 24, offset: Offset(0, 8), spreadRadius: -4), ]; } /// 图标尺寸栅格。 class AppIconSize { const AppIconSize._(); static const double inline = 16, dense = 20, regular = 24; static const double medium = 28, large = 32, display = 48, hero = 64; } /// 应用主题:暖橙云感(取自 logo),浅色 / 深色两套。 class AppTheme { AppTheme._(); /// 行动主色 = logo #FF914D 沿同色相压暗至可承载白字(vs 白 = 5.18,达 AA)。 static const Color brandPrimary = Color(0xFFC2410C); static const ColorScheme _light = ColorScheme( brightness: Brightness.light, primary: Color(0xFFC2410C), onPrimary: Color(0xFFFFFFFF), primaryContainer: Color(0xFFFFD3B3), onPrimaryContainer: Color(0xFF4A1D00), secondary: Color(0xFF8A5A3A), onSecondary: Color(0xFFFFFFFF), secondaryContainer: Color(0xFFF5DFCB), onSecondaryContainer: Color(0xFF5A3A22), tertiary: Color(0xFF9A6000), onTertiary: Color(0xFFFFFFFF), tertiaryContainer: Color(0xFFFFE0A3), onTertiaryContainer: Color(0xFF5C3D00), error: Color(0xFFB02A20), onError: Color(0xFFFFFFFF), errorContainer: Color(0xFFFFDDD8), onErrorContainer: Color(0xFF7A2A22), surface: Color(0xFFFCFAF8), onSurface: Color(0xFF1A1614), onSurfaceVariant: Color(0xFF6E655C), surfaceContainerLowest: Color(0xFFFFFFFF), surfaceContainerLow: Color(0xFFF7F3EF), surfaceContainer: Color(0xFFF2EDE8), surfaceContainerHigh: Color(0xFFECE5DF), surfaceContainerHighest: Color(0xFFE5DED7), outline: Color(0xFF8A8178), outlineVariant: Color(0xFFDCCFC2), surfaceTint: Color(0xFFC2410C), shadow: AppBrand.shadowTint, scrim: Color(0xFF1A1614), inverseSurface: Color(0xFF2E2823), onInverseSurface: Color(0xFFF7EFE9), inversePrimary: Color(0xFFFFB487), ); static const ColorScheme _dark = ColorScheme( brightness: Brightness.dark, primary: Color(0xFFFFB487), onPrimary: Color(0xFF542000), primaryContainer: Color(0xFF63300D), onPrimaryContainer: Color(0xFFFFDCC2), secondary: Color(0xFFE8C39B), onSecondary: Color(0xFF542000), secondaryContainer: Color(0xFF5A3A22), onSecondaryContainer: Color(0xFFFFDCC2), tertiary: Color(0xFFF2BE62), onTertiary: Color(0xFF542000), tertiaryContainer: Color(0xFF5C3D00), onTertiaryContainer: Color(0xFFFFDCC2), error: Color(0xFFFF8474), onError: Color(0xFF5A1610), errorContainer: Color(0xFF7A2A22), onErrorContainer: Color(0xFFFFC9C2), surface: Color(0xFF16130F), onSurface: Color(0xFFF3E9E2), onSurfaceVariant: Color(0xFFC4B8AE), surfaceContainerLowest: Color(0xFF0F0D0A), surfaceContainerLow: Color(0xFF1E1A16), surfaceContainer: Color(0xFF23201B), surfaceContainerHigh: Color(0xFF2D2924), surfaceContainerHighest: Color(0xFF38332D), outline: Color(0xFF7A7068), outlineVariant: Color(0xFF453F39), surfaceTint: Color(0xFFFFB487), shadow: Color(0xFF000000), scrim: Color(0xFF0F0D0A), inverseSurface: Color(0xFFF3E9E2), onInverseSurface: Color(0xFF2E2823), inversePrimary: Color(0xFFC2410C), ); static ThemeData light() => _base(_light); static ThemeData dark() => _base(_dark); static ThemeData _base(ColorScheme scheme) { final isDark = scheme.brightness == Brightness.dark; return ThemeData( useMaterial3: true, colorScheme: scheme, // —— 暖调阴影(替代系统默认纯黑)—— shadowColor: isDark ? Colors.black : AppBrand.shadowTint, // —— 触控:最小 48,主按钮 52 —— materialTapTargetSize: MaterialTapTargetSize.padded, visualDensity: VisualDensity.standard, // —— 图标:统一线性、24dp 栅格 —— iconTheme: IconThemeData( size: AppIconSize.regular, color: scheme.onSurfaceVariant, fill: 0.0, // 需可变字体支持;当前靠统一使用 _outlined 变体落地 weight: 400, grade: 0, opticalSize: 24, ), primaryIconTheme: IconThemeData(size: AppIconSize.regular, color: scheme.primary), // —— AppBar:滚动后浮起,解决与内容无分隔 —— appBarTheme: AppBarTheme( centerTitle: true, backgroundColor: scheme.surface, foregroundColor: scheme.onSurface, surfaceTintColor: scheme.surfaceTint, elevation: 0, scrolledUnderElevation: 3, toolbarHeight: 52, actionsIconTheme: IconThemeData(size: AppIconSize.regular, color: scheme.onSurfaceVariant), ), // —— 卡片:elevation 0 + CloudCard 自定义阴影;圆角 16 —— cardTheme: CardThemeData( elevation: 0, clipBehavior: Clip.antiAlias, surfaceTintColor: scheme.surfaceTint, shadowColor: isDark ? Colors.black : AppBrand.shadowTint, margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadius.lg)), ), // —— 底部弹窗:全局兜底 30 处调用 —— bottomSheetTheme: BottomSheetThemeData( showDragHandle: true, // 可下滑关闭的视觉暗示 useSafeArea: true, // ★ 默认 false,必须显式开启 backgroundColor: scheme.surface, surfaceTintColor: scheme.surfaceTint, modalBackgroundColor: scheme.surface, elevation: 0, clipBehavior: Clip.antiAlias, shape: const RoundedRectangleBorder(borderRadius: AppRadius.sheetTop), constraints: const BoxConstraints(maxWidth: 640), // 横屏不铺满 ), // —— 输入框:高度 ≥56,常驻描边 —— inputDecorationTheme: InputDecorationTheme( filled: true, fillColor: scheme.surfaceContainerHighest, contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18), prefixIconConstraints: const BoxConstraints(minWidth: 48, minHeight: 48), suffixIconConstraints: const BoxConstraints(minWidth: 48, minHeight: 48), border: OutlineInputBorder( borderRadius: BorderRadius.circular(AppRadius.md), borderSide: BorderSide(color: scheme.outlineVariant)), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(AppRadius.md), borderSide: BorderSide(color: scheme.outline)), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(AppRadius.md), borderSide: BorderSide(color: scheme.primary, width: 2)), errorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(AppRadius.md), borderSide: BorderSide(color: scheme.error)), focusedErrorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(AppRadius.md), borderSide: BorderSide(color: scheme.error, width: 2)), labelStyle: TextStyle(color: scheme.onSurfaceVariant), hintStyle: TextStyle(color: scheme.onSurfaceVariant), errorStyle: TextStyle(color: scheme.error, fontSize: 12), errorMaxLines: 2, ), // —— 按钮:统一 52 高 / 48 触控 / 16 圆角 —— filledButtonTheme: FilledButtonThemeData( style: FilledButton.styleFrom( minimumSize: const Size(64, 52), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadius.lg)), ), ), outlinedButtonTheme: OutlinedButtonThemeData( style: OutlinedButton.styleFrom( minimumSize: const Size(64, 52), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadius.lg)), ), ), textButtonTheme: TextButtonThemeData( style: TextButton.styleFrom( minimumSize: const Size(64, 48), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadius.lg)), ), ), iconButtonTheme: IconButtonThemeData( style: IconButton.styleFrom( minimumSize: const Size(48, 48), tapTargetSize: MaterialTapTargetSize.padded, padding: EdgeInsets.zero, ), ), // —— Chip / SnackBar / Dialog —— chipTheme: ChipThemeData( backgroundColor: scheme.surfaceContainerHighest, labelStyle: TextStyle(color: scheme.onSurface), selectedColor: scheme.secondaryContainer, checkmarkColor: scheme.onSecondaryContainer, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadius.sm)), ), snackBarTheme: SnackBarThemeData( behavior: SnackBarBehavior.floating, backgroundColor: scheme.inverseSurface, contentTextStyle: TextStyle(color: scheme.onInverseSurface), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadius.sm)), ), dialogTheme: DialogThemeData( backgroundColor: scheme.surface, surfaceTintColor: scheme.surfaceTint, elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadius.xxl)), ), // —— 品牌与语义色扩展(ThemeExtension,零依赖)—— extensions: >[ const AppBrandExtension(), isDark ? AppSemanticColors.dark : AppSemanticColors.light, ], ); } } ``` --- ## 8. 风险与权衡 ### 8.1 换色影响面:所有硬编码蓝色(已全量搜索源码) **必须改(`App` 主题色误用)** | 文件:行 | 现值 | 改法 | | --- | --- | --- | | `lib/app/theme.dart:8` | `Color(0xFF2E7DFF)` | → `#C2410C` | | `lib/screens/login/login_page.dart:205, 233, 280` | `Color(0xFF2E7DFF)`(聚焦边框) | → `scheme.primary` | | `lib/screens/login/login_page.dart:350` | `Color(0xFF2E7DFF)`(Checkbox) | → `scheme.primary` | | `lib/screens/login/login_page.dart:370` | `Color(0xFF2E7DFF)`(登录按钮) | → `scheme.primary` | | `lib/screens/login/login_page.dart:372` | `Color(0xFF1A4480)`(禁用态) | → `onSurface.withOpacity(0.12)` | | `lib/screens/login/login_page.dart:149` | `Color(0xFF0D2137)`(**卡片深蓝**) | → `scheme.surfaceContainerLow`(暖黑) | | `lib/screens/login/login_page.dart:152` | `Color(0xFF121212)` | → `scheme.surface` | | `lib/widgets/store_theme_grid.dart:77` | `Color(0xFF2E7DFF)`(选中边框) | → `scheme.primary` | | `lib/widgets/region_picker.dart:189` | `Colors.blue` | → `scheme.primary` | | `lib/screens/home/home_page.dart:78` | `Colors.blue` | → `scheme.primary` | | `lib/screens/admin/admin_hub_page.dart:19` | `Colors.blue` | → 语义色 | | `lib/screens/store/org_tree_page.dart:282` | `Colors.blue` | → 语义色 | | `lib/screens/alerts/alerts_page.dart:19` | `Colors.blue` | → `AppSemanticColors.info` | **⚠️ 绝对不能动(业务数据色,不是 App 主题色)** | 文件:行 | 值 | 原因 | | --- | --- | --- | | `lib/widgets/store_theme_preview.dart:110-115` | `#3B82F6` `#8B5CF6` `#EC4899` `#F59E0B` `#10B981` `#6366F1` | **门店主题模板的预览色板**,是下发给 TV 端的业务配置,改了会导致门店界面配置错乱 | | `lib/widgets/store_theme_grid.dart:129-134` | 同上 6 色 | 同上 | | `lib/models/store_theme.dart:46-54` | `#1565C0` `#00838F` `#2E7D32` `#E65100` `#455A64` | **门店主题色枚举**,业务数据 | > **这是本次换色最大的误伤风险**:`#3B82F6` 与 `#2E7DFF` 都是蓝色,批量替换 `Color(0xFF2E7DFF)` 时若用正则误伤 `store_theme_grid.dart:129` 的 `#3B82F6`,会破坏门店主题功能。**建议只做精确字符串 `Color(0xFF2E7DFF)` 的整词替换,并逐处人工确认。** ### 8.2 深色模式下橙色的处理(3 条硬约束) 1. **主色必须提亮,文字必须改深色** 深色下 `primary = #FFB487`(logo 渐变带原色),`onPrimary = #542000`(对比 7.66 ✅)。 **绝对不能用白字**:白字 on `#FFB487` 仅 1.73 ❌。 若沿用浅色的 `#C2410C` 作深色主色,`onPrimary` 白字虽可达标,但深橙在深底上会"沉下去",按钮看起来像禁用态 —— **必须提亮**。 2. **橙色只做小面积强调,容器级用深橙底** 深色模式下大面积亮橙会产生**光晕(halation)**,在 OLED 屏幕上尤其刺眼、且长时间注视疲劳。 规则:亮橙 `#FFB487` 仅用于**图标、进度条填充、选中指示、文字链接**(面积 < 8%);容器级一律用 `primaryContainer = #63300D`(深橙底)+ `onPrimaryContainer = #FFDCC2`。 3. **surface 必须暖化,不能是 `#121212`** 现状 `login_page.dart:152` 用 `#121212`、`:149` 用 `#0D2137`(**深蓝**)—— 后者与暖橙主题直接冲突,登录是用户看到的第一屏,观感会分裂。 统一改为暖黑 `#16130F`(R>G>B 的暖偏移),与 `#FFB487` 同源。 另外,深色下**不要**用纯黑 `#000000` 作 surface:OLED 上纯黑与滚动内容之间缺乏层次,且暖色阴影完全不可见(实测对比 1.05)→ 必须配合 `outlineVariant` 描边。 ### 8.3 其他风险与权衡 | 风险 | 影响 | 缓解 | | --- | --- | --- | | **`ColorScheme.fromSeed` → 显式声明** | 失去 M3 动态取色(Android 12+ 壁纸取色)能力 | 本项目是**强品牌识别**诉求,品牌一致性优先级 > 动态取色;且现状已用 `.copyWith(primary:)` 覆盖了 primary,动态取色本就未生效。**可接受** | | **亮橙不能作文字/图标色** | 设计上少了一个高亮手段,部分位置需改用 `primary` 深橙 | 用 `primaryContainer`(浅橙底)+ `onPrimaryContainer`(深棕字)组合替代;Banner/插画用大色块亮橙 + 深棕字(6.99 ✅) | | **App 图标 / 启动图 未同步** | 主题改了但桌面图标仍是旧色,品牌一致性打折 | 需同步更新 `android/app/src/main/res/mipmap-*/ic_launcher.png` 与 `drawable*/launch_background.xml`(本方案未覆盖,建议单独立项) | | **M3 `FilledButton` 默认胶囊圆角** | 与卡片 16 圆角脱节 | 已在 `filledButtonTheme` 显式覆盖为 `AppRadius.lg`(16),**必须检查所有未走主题的 `style: FilledButton.styleFrom(...)` 局部覆盖** | | **横屏双栏改造工作量** | 涉及 `store_list_page.dart`(1423 行)、`device_list_page.dart` | 分两步:P1 先做断点工具 + Grid + NavigationRail(低风险);P2 再做双栏主从(需重构选中态管理,用 Riverpod 持有 `selectedStoreId`) | | **`visualDensity: standard`** | 控件间距 +4dp,单屏信息量略降 | 运维现场场景,误触代价值得;若反馈过松,可回退 `VisualDensity.comfortable` | | **登录页去硬编码** | 该页此前是为规避"M3 动态取色导致文字/背景融为一体"而硬编码的(见 L144 注释) | 改成显式 `ColorScheme` 后色彩完全可控,**该规避理由已不成立**;但仍需保留完整的 try/catch 兜底逻辑,不要顺手删 | | **深色→浅色默认切换**(P0-7) | 老用户升级后界面突变 | 建议:`StoreLocalCache` 中无显式偏好时,跟随 `ThemeMode.system`,而非硬切 light | --- ## 9. 验收标准(可度量) | 项 | 验收方式 | 目标 | | --- | --- | --- | | 品牌一致性 | 取 5 个主页面截图,取样主色相 | 主色 H ∈ [20°, 26°](logo 为 22.9°) | | 对比度 | 用 §3.8 清单逐项复核 | **全部达到 WCAG AA**(正文 4.5 / 大字 3.0 / 非文本 3.0) | | 触控目标 | Android 无障碍扫描 / `flutter test` 断言 | 所有可点元素 ≥48×48,间距 ≥8 | | 横屏适配 | PGBM10 实机横屏 1600×900 走查 | 无横向拉伸失真;列表页双栏;底部导航为 NavigationRail | | 弹窗 | 实机打开全部 30 处弹窗 | 全部有拖拽把手、限高 ≤92%、内容不被手势条遮挡、长表单有吸底操作栏 | | 视觉回归 | 浅色 / 深色两套各 5 个页面截图对比 | 无文字与背景融为一体的情况;无硬编码蓝色残留(`grep -rn "0xFF2E7DFF\|Colors.blue" lib/` 除业务色外为 0) | --- **附录:本文所有对比度数值均由 WCAG 2.1 相对亮度公式脚本计算得出(源码见 §3.8 输出),非目测或估算。**