Files
CloudHome-Ops/docs/system_design.md

14 KiB
Raw Blame History

云端管理端 — 增量架构设计(4 项功能:前端占位实现)

版本:v1.0|架构师:高见远|基于 PRD + 现有代码事实 关键决策:4 项功能全部「前端先占位」实现——本地模型 + 预留接口 TODO 占位,不强制依赖真实后台(后端当前缺 themeId/splashUrl/logoUrl 字段、无图片上传/扫码绑定/门店 save 接口)。


1. 实现方案 + 框架选型

1.1 技术难点分析

功能 难点 解决思路
门店主题/皮肤 后台缺主题字段,需纯前端内置主题库 + 本地预览 主题枚举 + 内置占位资源(纯色块/Assets 图),本地模型存 themeId,预留 setStoreTheme
省市级联 34 省三级联动数据加载与回显映射 复用 province.json,封装 RegionPicker 组件,结构化存 code + 拼接文本,既有文本地址尽力映射
扫码绑定 跨平台扫码(Android/iOS/鸿蒙),替代老 ScanKit 引入 mobile_scanner(通用、维护活跃),解析后本地查找设备并跳转
启动图/Logo 图片选择 + 手机外框预览,后台无上传接口 image_picker 选图本地预览,branding 字段存本地路径,预留 uploadAsset/setStoreBranding

1.2 框架 / 包选型

沿用现有栈 Flutter 3.27.4 + riverpod + go_router + Dio,不引入新状态框架。新增 2 个能力包:

包 版本(建议) 用途
mobile_scanner ^5.2.0 跨平台相机扫码 + 相册识别二维码(替代华为 ScanKit)
image_picker ^1.1.2 启动图/Logo 图片选择(相册/拍照)

注:mobile_scanner 需在 AndroidManifest.xml/Info.plist/ohos 配置相机权限,本设计只到「接口与组件占位」层级,权限字符串由工程师按平台补。

1.3 pubspec.yaml 改动

dependencies:
  # 现有:flutter / go_router / flutter_riverpod / dio / intl
  mobile_scanner: ^5.2.0
  image_picker: ^1.1.2

flutter:
  assets:
    - assets/data/MCityPiker/province.json
    - assets/theme/          # 新增:内置主题占位图(如 theme_1.png ...)

1.4 架构模式

保持现有 Widget + Service + Provider 分层,无新增抽象层:

  • 模型(lib/models/):StoreInfo 扩展字段
  • 服务(lib/services/):OrgService/DeviceService 新增预留 TODO 方法
  • 组件(lib/widgets/):RegionPicker、StoreThemeGrid、BrandingEditor
  • 页面(lib/screens/):门店表单接入、设备扫码入口

2. 文件列表(新增 / 修改,相对路径)

新增文件

  • lib/data/region_data.dart — 加载 province.json 并解析为 RegionNode 树;提供 provinceName(code)/cityName/districtName 反查工具
  • lib/models/store_theme.dart — StoreTheme 枚举 + 内置主题清单(StoreThemePresets)
  • lib/widgets/region_picker.dart — 省→市→区三级联动底部弹起选择器
  • lib/widgets/store_theme_grid.dart — 主题皮肤网格卡片 + 选中态
  • lib/widgets/store_theme_preview.dart — 模拟主页头部预览(依据 themeId)
  • lib/widgets/branding_editor.dart — 启动图/Logo 卡片:选图 + 手机外框预览
  • lib/screens/device/device_scan_page.dart — 扫码页(mobile_scanner 封装,含相册识别)
  • lib/utils/store_local_cache.dart — 门店扩展字段本地持久化(SharedPreferences 封装,存 themeId/branding 等)

修改文件

  • lib/models/store_info.dart — 增加 themeId/splashUrl/logoUrl/provinceCode/cityCode/districtCode/addressDetail + fromJson/toJson 兼容
  • lib/screens/store/store_list_page.dart — _StoreFormSheet 接入 RegionPicker + 主题 + BrandingEditor;详情 sheet 展示主题/品牌
  • lib/screens/home/device_list_page.dart — AppBar 加扫码入口 → 跳 device_scan_page
  • lib/screens/home/device_detail_page.dart — 加「扫二维码绑定」按钮 → 跳 device_scan_page(mode=bind)
  • lib/services/org_service.dart — 加 saveStore/updateStore/setStoreTheme/setStoreBranding TODO
  • lib/services/device_service.dart — 加 bindDeviceByScan TODO
  • lib/app/router.dart — 注册 /device/scan 路由(含 mode 参数)
  • pubspec.yaml — 加依赖 + assets 注册(见 1.3)

3. 数据模型与接口(类结构)

3.1 类图(Mermaid classDiagram)

classDiagram
    class StoreInfo {
        +String orgId
        +String name
        +String address
        +String contactPhone
        +String activationCode
        +int status
        +DateTime? expireTime
        +DateTime? createTime
        <<新增>>
        +String? themeId
        +String? splashUrl
        +String? logoUrl
        +String? provinceCode
        +String? cityCode
        +String? districtCode
        +String? addressDetail
        +StoreInfo.fromJson(Map)
        +Map toJson()
        +String get regionText()
    }

    class RegionNode {
        +String code
        +String name
        +List~RegionNode~ sub
        +RegionNode.fromJson(Map)
    }

    class StoreTheme {
        <<enum>>
        classic
        ocean
        forest
        sunset
        mono
        +String get id
        +String get label
        +Color get primary
    }

    class StoreThemePresets {
        <<const>>
        +List~StoreTheme~ all
        +StoreTheme? byId(String)
    }

    class RegionData {
        -List~RegionNode~ _tree
        +Future~void~ load()
        +List~RegionNode~ get provinces
        +List~RegionNode~ citiesOf(String)
        +List~RegionNode~ districtsOf(String,String)
        +String nameOf(String)
        +String tryMapAddress(String)~List~String?~
    }

    class StoreLocalCache {
        +Future~void~ saveTheme(String orgId, String themeId)
        +Future~String?~ getTheme(String orgId)
        +Future~void~ saveBranding(String orgId, String splashUrl, String logoUrl)
        +Future~Map?~ getBranding(String orgId)
        +Future~void~ saveRegion(String orgId, String p, String c, String d, String detail)
    }

    class OrgService {
        +Future~void~ saveStore(Map)  // TODO 对接 POST /sys/store/save
        +Future~void~ updateStore(Map) // TODO 对接 POST /sys/store/update
        +Future~void~ setStoreTheme(String,String) // TODO POST /sys/store/theme
        +Future~void~ setStoreBranding(String,String,String) // TODO POST /sys/store/branding
    }

    class DeviceService {
        +Future~void~ bindDeviceByScan(String scanPayload, String storeId, String? groupId) // TODO POST /device/bind
    }

    class DeviceScanPage {
        +String mode  // 'query' | 'bind'
        +String? storeId
        +String? groupId
    }

    StoreInfo "1" *-- "1" StoreTheme : themeId 关联
    RegionData "1" *-- "0..*" RegionNode : 树
    OrgService ..> StoreInfo : 读写
    DeviceService ..> DeviceScanPage : 扫码结果

3.2 关键模型说明

StoreInfo 新增字段(全部可空,向后兼容既有后端响应):

String? themeId;        // 主题枚举 id,如 'ocean'
String? splashUrl;      // 启动图本地路径 / 远程 URL(占位)
String? logoUrl;        // Logo 本地路径 / 远程 URL(占位)
String? provinceCode;   // 省 code(6 位)
String? cityCode;       // 市 code
String? districtCode;   // 区 code
String? addressDetail;  // 街道/门牌等详情文本
// 拼接展示:regionText = 省名+市名+区名+addressDetail

预留 Service 方法签名(含 TODO):

// OrgService — 当前后端无门店 save/update/theme/branding 接口
Future<void> saveStore(Map<String, dynamic> data) async {
  // TODO(后端): POST /sys/store/save,body 含 themeId/splashUrl/logoUrl/省市区
  // 前端占位:仅写 StoreLocalCache,不发起真实请求
}
Future<void> setStoreTheme(String orgId, String themeId) async {
  // TODO(后端): POST /sys/store/setTheme {orgId, themeId}
}
Future<void> setStoreBranding(String orgId, String splashUrl, String logoUrl) async {
  // TODO(后端): POST /sys/store/setBranding {orgId, splashUrl, logoUrl}
  // 注意:真实上传需先调 uploadAsset 拿 URL
}

// DeviceService — 当前后端无扫码绑定接口
Future<void> bindDeviceByScan(String scanPayload, String storeId, String? groupId) async {
  // TODO(后端): POST /device/bind {scanPayload, storeId, groupId}
  // 前端占位:本地匹配设备后用 updateDevice 写 groupId/storeName
}

4. 程序调用流程(时序,文字描述)

4.1 门店编辑 — 省市区 + 主题 + 品牌(主流程)

  1. 用户点「新增/编辑门店」→ _StoreFormSheet 打开。
  2. 点「地址」→ RegionPicker 从 RegionData(已加载 province.json)弹起三级选择 → 存 provinceCode/cityCode/districtCode + 回填文本到 addressDetail。
  3. 点「主题皮肤」→ StoreThemeGrid 展示 StoreThemePresets.all → 选 themeId → StoreThemePreview 实时预览模拟头部。
  4. 点「启动图/Logo」→ BrandingEditor 调 image_picker 选图 → 手机外框预览 → 存本地路径到 splashUrl/logoUrl。
  5. 点「保存」→ _StoreFormSheet._save() 组装 data → 调 OrgService.saveStore(占位写 StoreLocalCache,并留 setStoreTheme/setStoreBranding TODO)→ 关闭 sheet,_loadData() 刷新。

4.2 设备扫码 — 查询模式

  1. 设备列表 AppBar 扫码图标 → context.go('/device/scan?mode=query')。
  2. DeviceScanPage 用 mobile_scanner 实时扫码(或相册识别)→ 得到 scanPayload(设备标识,如 mac/sn)。
  3. 本地用 DeviceService.fetchDevices(mac:...) 或列表缓存查找 → 命中则 context.go('/device/:id') 跳详情;未命中提示「未找到设备」。

4.3 设备扫码 — 绑定模式

  1. 设备详情「扫二维码绑定」→ context.go('/device/scan?mode=bind&deviceId=xxx')。
  2. 扫码得到目标门店/分组码 → 弹窗确认「绑定到当前门店/分组」→ DeviceService.bindDeviceByScan(占位:本地 updateDevice 写 groupId/storeName,留真实 POST TODO)。
  3. 返回详情,展示更新后的归属。

4.4 既有门店地址回显

  • 打开编辑既有门店:_StoreFormSheet.initState 若 store.provinceCode 非空 → RegionPicker 直接定位;若为空但有旧文本 address → RegionData.tryMapAddress() 尽力用关键词匹配省/市/区,匹配不到则仅填 addressDetail 文本。

5. 有序任务列表(按实现顺序,含依赖)

规则:不超过 5 个任务,每任务 ≥3 文件,首个为基础设施。

任务 名称 源文件 依赖 优先级
T01 项目基础设施(依赖 + assets + 数据/主题基座) pubspec.yaml、lib/data/region_data.dart、lib/models/store_theme.dart、lib/utils/store_local_cache.dart — P0
T02 扩展 StoreInfo 模型 + 预留 Service 接口 lib/models/store_info.dart、lib/services/org_service.dart、lib/services/device_service.dart T01 P0
T03 门店表单增强组件(省市区 + 主题 + 品牌) lib/widgets/region_picker.dart、lib/widgets/store_theme_grid.dart、lib/widgets/store_theme_preview.dart、lib/widgets/branding_editor.dart T01、T02 P1
T04 门店表单接入 + 设备扫码模块 + 路由 lib/screens/store/store_list_page.dart、lib/screens/home/device_list_page.dart、lib/screens/home/device_detail_page.dart、lib/screens/device/device_scan_page.dart、lib/app/router.dart T03、T02 P1
T05 构建验证与联调(编译/权限/占位跑通) 复用上述全部文件 + android/Info.plist/ohos 权限配置 T04 P2

任务依赖图

graph TD
    T01[T01 基础设施] --> T02[T02 模型+Service]
    T01 --> T03[T03 组件]
    T02 --> T03
    T03 --> T04[T04 接入+扫码+路由]
    T02 --> T04
    T04 --> T05[T05 构建验证]

6. 依赖包列表

- mobile_scanner@^5.2.0 : 跨平台相机/相册二维码扫码(替代华为 ScanKit)
- image_picker@^1.1.2   : 启动图/Logo 本地图片选择(相册/拍照)
# 以下为现有包(已存在,仅列示上下文)
- flutter / go_router@^14.2.0 / flutter_riverpod@^2.5.1 / dio@^5.4.3 / intl@^0.19.0

7. 共享知识(跨文件约定)

  • province.json 加载:统一由 lib/data/region_data.dart 的 RegionData.load() 懒加载并缓存为 List<RegionNode> 树;组件禁止直接 rootBundle 读取,避免重复解析(文件 284KB)。
  • 主题枚举位置:lib/models/store_theme.dart 定义 StoreTheme 枚举与 StoreThemePresets;所有主题相关 UI 仅引用枚举 id(themeId),不硬编码颜色。
  • 预留接口 TODO 规范:所有待对接后台的方法必须含 // TODO(后端): POST /sys/... 注释,写明预期路径与 body;前端占位实现只写 StoreLocalCache,并 print/debug 标注「占位未上报」。
  • 本地持久化:门店扩展字段统一经 StoreLocalCache(SharedPreferences,key 形如 store_theme_$orgId),不在内存态以外散落存储。
  • 扫码 payload:约定扫码内容优先解析为 mac 或 deviceSn;无法识别时原样透传,由 DeviceService 模糊匹配。
  • 图片路径:image_picker 返回的 XFile.path 直接存 splashUrl/logoUrl(占位);真实上传走 uploadAsset 后再替换为 URL(TODO)。
  • 路由参数:/device/scan?mode=query|bind&deviceId=xxx&storeId=xxx,由 device_scan_page 解析。

8. 待明确事项

  1. 主题占位资源形式:内置纯色块 + 名称,还是需要 assets/theme/*.png 缩略图?当前设计按「纯色块 + 名称」实现,若需图片请在 T01 前提供 4–5 张占位图。
  2. 扫码绑定语义:「绑定到当前门店/分组」中「当前门店」取自设备详情页的 storeName 还是用户手动选?本设计默认取详情页所属门店,绑定弹窗可改分组。
  3. 省市级联回显映射:既有纯文本地址(如「北京市朝阳区xx路」)能否映射到 code 取决于关键词命中,映射不到仅回填 addressDetail,是否接受?
  4. 鸿蒙权限:mobile_scanner 在 ohos 平台的相机权限声明方式需工程师按鸿蒙规范确认(本设计不含具体 ohos 权限代码)。
  5. 本地缓存生命周期:StoreLocalCache 数据在重新登录/切换组织时是否清空?默认不清,如需隔离按 orgId 已做 key 隔离,可后续细化。

交付完毕:本设计为纯架构/任务分解,不含实现代码。工程师按 T01→T05 顺序落地即可。