ops-console v1.0.2 (versionCode 8) 源码入库

This commit is contained in:
txh
2026-09-03 18:01:05 +08:00
commit 685fff27c8
170 changed files with 53049 additions and 0 deletions

30
.metadata Normal file
View File

@@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "d8a9f9a52e5af486f80d932e838ee93861ffd863"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863
base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863
- platform: android
create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863
base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

845
CHANGELOG.md Normal file
View File

@@ -0,0 +1,845 @@
# 云定制主页管理 (ops-console) — 变更清单
> 包名:`com.ainetv.aishicloud` · version:`1.0.0+1`
> 标记说明:✅ 已实现 / 已修复 | ⏳ 待后端 / 待验收 | ❌ 已知问题
> 所有 release APK 均通过 `flutter analyze --no-fatal-infos`(0 error)
---
## v6-fix68(2026-08-29)
> **修复两个功能性 Bug:① 设备列表「删除」按钮点击后返回「删除失败:未知异常,请联系管理员」;② 设备详情页修改房号后不生效,页面仍显示 Web 端原有房号。**
>
> **根因(通过静态分析 Web 前端 JS 逆向真实后端契约得出)**:
> 1. **删除失败**:后端 `POST /device/info/delete` 接收的是**裸 JSON 数组** `[id]`(Web 端 `Ao(t)` → `$.adornData(t,!1)` → `JSON.stringify([id,...])`)。旧实现误传 `{'ids':[id]}`(对象),后端无法解析请求体 → 抛出「未知异常」。
> 2. **房号不保存**:后端设备表房号字段名为 **`roomId`**(Web 端设备编辑表单 `prop:'roomId'`、列表列 `prop:'roomId'` 标签「房间号」)。旧实现发送 `roomNo`,后端忽略该未知字段 → 更新“成功”但房号未变,刷新后回显旧值。
>
> **改动**:
> 1. `lib/services/device_service.dart`:
> - `deleteDevice(id)`:body 由 `{'ids':[id]}` 改为裸数组 `[id]`,并在 id 可转为数字时按数字发送(复刻 Web 端 `Yo()` 的 `Number(id)` 行为),兼容数字/字符串两种主键;
> - `activateDeviceByScan`:扫码激活的房号字段 `roomNo` → `roomId`(update 与 save 两条路径)。
> 2. `lib/screens/home/device_detail_page.dart`:`DeviceGroupRoomSheet` 提交时房号字段 `roomNo` → `roomId`。
> 3. `lib/models/device.dart`:`toJson` 房号输出键 `roomNo` → `roomId`(`fromJson` 读取别名列表保留 `roomNo`/`roomId` 等,确保后端返回 `roomId` 时正确回显)。
> 4. 版本号 `v6-fix67` → `v6-fix68`。
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已修复 | 设备服务 | `deleteDevice(id)`:删除 body 由 `{'ids':[id]}` 改为裸数组 `[id]`,对齐 Web 端契约 |
| ✅ 已修复 | 设备/扫码 | 房号字段统一为后端 `roomId`(update/save/详情编辑/toJson) |
| ✅ 已更新 | 版本号 | 登录页版本号 `v6-fix67` → `v6-fix68` |
---
## v6-fix67(2026-08-31)
> **设备列表页「向左滑动显示删除按钮」手势。**
>
> 用户需求:在「设备 MAC 排序」列表中,为每个 MAC 项增加**向左滑动**手势,滑动后**露出删除按钮**(位于行右侧);**点击删除按钮**才弹出确认对话框,确认后执行删除、取消则保留。
>
> **实现选择**:原先用 `Dismissible` 会在滑动过阈值松手时**自动**弹确认框(红色背景仅为装饰、不可点击),不满足「先露按钮再点击」的交互。故改用 `flutter_slidable`(新增依赖 `flutter_slidable: ^3.1.0`):**向左滑**揭示一个**可点击**的红色「删除」动作(位于行右侧),点击动作才触发确认框。
>
> **改动**:
> 1. `pubspec.yaml`:新增 `flutter_slidable: ^3.1.0`(`flutter pub get` 已解析)。
> 2. `lib/services/device_service.dart`:新增 `deleteDevice(String id)`——`POST /device/info/delete`,body 为 `{ids: [id]}`,与分组/用户等模块的删除接口约定保持一致。
> 3. `lib/screens/home/device_list_page.dart`:
> - 列表 `itemBuilder` 每行用 `Slidable`(`endActionPane` + `ScrollMotion`)包裹,**向左滑动**揭示右侧红色 `SlidableAction`(删除图标 + `t.delete` 文案);`dismissible: null` 表示不自动滑出删除,仅作按钮;
> - `SlidableAction.onPressed` 调用 `_confirmDeleteDevice(device)`:弹出 `AlertDialog`(标题 `确认删除` / 内容 `是否确认删除该设备(设备名/MAC)?`,取消/删除按钮);
> - 取消 → 返回 `false`,行回弹保留;
> - 确认 → 先入 `_pendingDeleteIds` 本地隐藏该行(避免抖动),再调 `deleteDevice`,成功 `ref.invalidate(devicesByStoreProvider)` + SnackBar `已删除`,真正由列表刷新移除;异常则移出 `_pendingDeleteIds` 恢复显示并 SnackBar `删除失败:{原因}`;
> - 新增 `_rowKey(Device)`:`${id 或空}__${mac}` 作为行唯一键,避免 `device.id` 为空时 `ValueKey('')` 重复触发 Flutter 断言崩溃;`_filter` 与 `_pendingDeleteIds` 均改用该键。
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 新增 | 依赖 | `pubspec.yaml`:`flutter_slidable: ^3.1.0` |
| ✅ 新增 | 设备服务 | `lib/services/device_service.dart`:`deleteDevice(id)` 调 `POST /device/info/delete`,body `{ids:[id]}` |
| ✅ 新增 | 设备列表 | `lib/screens/home/device_list_page.dart`:每行 `Slidable`(startToEnd)露可点击红色「删除」按钮,点击才弹确认框;确认删除、取消保留;删除中本地隐藏防抖、失败回滚并提示;`_rowKey` 防空 id 重复键 |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix66` → `v6-fix67` |
---
## v6-fix66(2026-08-31)
> **修复 Web 端删除设备后,手机端设备列表仍显示已删除设备的问题。**
>
> 用户反馈:在 Web 后台删除设备后,手机端设备列表未同步,仍显示该设备 MAC(如截图中 `08:C3:ED:64:16:72`)。
>
> **根因**:设备列表页使用 `devicesByStoreProvider`(Riverpod `FutureProvider.family`)做缓存,但只在「下拉刷新」「切换门店筛选」「扫码成功」三种场景下刷新;日常从 Web 端删除设备后,手机端没有自动刷新机制,因此一直展示旧缓存。
>
> **改动**:
> 1. `lib/screens/home/device_list_page.dart`:
> - `_DeviceListPageState` 混入 `WidgetsBindingObserver`,在 `initState`/`dispose` 中注册/注销生命周期监听;
> - 新增 `didChangeAppLifecycleState` 处理:应用从后台切回前台(`AppLifecycleState.resumed`)且跳过首次启动事件后,调用 `ref.invalidate(devicesByStoreProvider)` 失效所有门店筛选参数下的设备列表缓存,并静默刷新门店列表,使手机端与 Web 端数据同步;
> - 合并原有的 `dispose` 方法,确保 `removeObserver` 与控制器释放同时执行。
> 2. `lib/screens/device/device_scan_page.dart`:
> - 将扫码查询命中后端设备、扫码绑定/激活成功后的 `ref.invalidate(devicesProvider)` 改为 `ref.invalidate(devicesByStoreProvider)`,失效门店筛选 Provider 族(而不是只失效无参数的 `devicesProvider`),确保返回列表页时看到最新数据。
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已修复 | 设备列表 | `lib/screens/home/device_list_page.dart`:App 切回前台自动刷新设备/门店列表,同步 Web 端变更 |
| ✅ 已修复 | 扫码页 | `lib/screens/device/device_scan_page.dart`:扫码成功后失效 `devicesByStoreProvider` 全族,而非仅 `devicesProvider` |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix65` → `v6-fix66` |
---
## v6-fix65(2026-08-31)
> **扫码绑定流程改造:扫码成功后改为页面内表单(门店分组下拉 + 房号输入),替代原先分步弹窗。**
>
> 新增需求:扫码成功后,在页面上直接提供「门店分组」选择控件(从分组列表选取)与「房号」输入框,并明确两者的校验规则,保证移动端布局清晰、操作便捷。
>
> **改动**:`lib/screens/device/device_scan_page.dart`
> 1. 移除原 `_confirmBind` / `_pickGroup` / `_pickStore` 三步弹窗流程。
> 2. 新增 `_enterBindForm`:扫码识别 MAC 后进入表单态,停止相机预览;
> - 激活模式(无预填门店):加载门店下拉,选择门店后级联加载其分组;
> - 绑定模式(路由已预填门店):门店固定展示,直接加载分组。
> 3. 新增 `_buildBindForm`(SingleChildScrollView + Card):已识别设备 MAC 卡片、门店分组 `DropdownButtonFormField`、房号 `TextFormField`、提交/重新扫码按钮;分组加载中显示进度。
> 4. 校验规则(`_validateForm`):
> - 门店:仅激活模式必填(未选门店 → `请先选择门店`);
> - 门店分组:必填(未选 → `请选择门店分组`;门店无分组 → `该门店暂无设备分组…`);
> - 房号:必填且格式校验(非空、长度 1~20、仅含中英文/数字/连字符/斜杠,否则 `房号格式不正确…`);错误信息以输入框 `errorText` 实时回显。
> 5. `_submitBind` 校验通过调 `DeviceService().activateDeviceByScan(mac, storeId, groupId, roomNo)`(后端 `roomNo` 字段已支持),写本地绑定缓存并 `ref.invalidate(devicesProvider)`,成功 SnackBar 后 `context.pop()`;登录失效仍兜底跳 `/login`。
> 6. i18n(`lib/utils/i10n.dart`)新增 `scanGroupLabel` / `scanStoreRequired` / `scanGroupRequired` / `scanRoomRequired` / `scanRoomInvalid` / `scanRoomHint` / `scanRescan` / `scanFormInvalid` / `scanFormDeviceTitle`。
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 新增 | 扫码页 | 页面内绑定表单:门店分组下拉 + 房号输入 + 实时校验 + 提交/重扫 |
| ✅ 已删除 | 扫码页 | 移除 `_confirmBind` / `_pickGroup` / `_pickStore` 分步弹窗 |
| ✅ 新增 | i18n | 表单相关中文/英文文案 |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix64` → `v6-fix65` |
---
## v6-fix60(2026-08-31)
> **修复设备详情页在线状态与 Web 端不一致的问题。**
>
> 用户截图:手机端设备详情页状态显示「离线」,但 Web 端同一设备显示「在线」。根因:移动端详情接口 `/device/info/info/{id}` 返回的 `status`/`lastSyncTime` 滞后于列表接口 `/device/info/list`;Web 端设备列表正是走列表接口并显示最新在线状态。修复后详情页先用 ID 取详情,再用设备 MAC 从列表接口拉一次最新状态并合并,保证手机端详情与 Web 端列表/心跳一致。
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已修复 | 设备模型 | `lib/models/device.dart`:新增 `copyWith` 方法;`lastSyncTime` 解析兼容 `heartbeatTime` / `heartbeat_time` / `lastHeartbeat` / `last_heartbeat` 等 Web 端心跳命名字段 |
| ✅ 已修复 | 设备服务 | `lib/services/device_service.dart`:`fetchDeviceById` 在拿到详情后调用 `_mergeWithFreshListStatus`:用 `detail.mac` 调 `/device/info/list?mac=...&limit=1` 取最新状态,合并 `status` 与 `lastSyncTime`;列表接口失败时不阻断详情展示 |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix59` → `v6-fix60` |
---
## v6-fix61(2026-08-31)
> **修复「设备列表页扫码时弹出『查询失败:登录已过期,请重新登录』」的体验问题。**
>
> 用户操作:设备列表页右上角二维码(`mode=query` 扫码查设备,注意:真正「激活」是底部 + 按钮 `mode=bind&activate=1`)→ 触发 `_queryDevice` 读取 `devicesProvider.future`(`GET /device/info/list`)。当 token 过期:拦截器 `ApiInterceptor.onResponse` 识别到 `code==401` 或「token失效/登录」类文案 → 调 `_refreshAndRetry` 自动刷新令牌并重试一次;若刷新令牌也失败(refreshToken 失效),则 `_notifyUnauthorized()` 清空令牌并触发 `authProvider.onUnauthorized` 回调 → 路由 `redirect` 自动跳 `/login`。问题是拦截器最终 `handler.reject(ApiException(401,'登录已过期,请重新登录'))`,该异常冒泡到扫码页 catch,被 `formatApiError` 取出后以「查询失败:登录已过期,请重新登录」SnackBar 暴露给用户,与登录页跳转形成竞态/困惑。
>
> **修复**:新增 `isUnauthorizedError()` 统一识别登录失效异常;扫码页 4 个 catch(查设备 / 激活绑定 / 加载门店 / 加载分组)在捕获到登录失效时**不再展示底层错误**,直接 `context.go('/login')` 兜底跳转(与拦截器已触发的路由重定向一致)。自动刷新令牌能力保持不变(首次 401 静默重试成功则用户无感)。
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已修复 | 网络层 | `lib/services/api_client.dart`:新增顶层 `isUnauthorizedError(Object?)`,识别 `ApiException(401)` 或含「登录已过期」文案的登录失效异常,供 UI 层复用 |
| ✅ 已修复 | 扫码页 | `lib/screens/device/device_scan_page.dart`:`_queryDevice` / `_confirmBind` / `_pickGroup` / `_pickStore` 四处 catch 增加登录失效判断——命中时跳过错误 SnackBar、改为 `context.go('/login')` 引导重新登录 |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix60` → `v6-fix61` |
---
## v6-fix64(2026-08-31)
> **设备列表页登录过期错误提示与扫码页保持一致。**
>
> 之前设备列表页加载失败时,若遇登录过期,会整屏显示「设备列表加载失败:登录已过期,请重新登录」+ 重试按钮(仍用 `error.toString()` 暴露底层异常)。拦截器已通过 `_notifyUnauthorized` 清空令牌并触发路由 `redirect` 跳 `/login`,此处应与之对齐——不再展示底层错误。
>
> **改动**:`lib/screens/home/device_list_page.dart` 的 `devicesAsync.when(error:)` 分支:
> - 命中登录失效(`isUnauthorizedError`)时不再展示错误文案与重试按钮,改为显示加载占位(路由重定向会接管跳登录页);
> - 其他错误改用 `formatApiError(error)` 输出友好文案(不再直接 `error.toString()`)。
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已修复 | 设备列表 | `lib/screens/home/device_list_page.dart`:error 分支登录失效走加载占位、其余错误用 `formatApiError`;补充 `api_client.dart` 导入 |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix63` → `v6-fix64` |
---
## v6-fix63(2026-08-31)
> **精简设置页与设备列表页:移除运行环境切换、显示运行指标开关及设备列表右上角二维码按钮。**
>
> 用户反馈(截图):设置页中「运行环境」与「显示运行指标」两项不需要继续展示,要求直接删掉;设备列表页右上角二维码按钮也直接删掉。
>
> **改动**:
> 1. `lib/screens/profile/settings_page.dart`:
> - 移除「运行环境」`_AppSettingsTile`(含测试/预发切换、`AppConfig.switchEnvironment`、`ApiClient.instance.reconfigure` 等逻辑)。
> - 移除「显示运行指标」`SwitchListTile` 及其对应的 `showRuntimeMetricsProvider` watch。
> - 删除不再使用的导入:`metrics_visibility_provider`、`app_config`、`environment`、`staging_config`、`test_config`;删除不再使用的 `_envLabel` 私有方法。
> 2. `lib/screens/home/device_list_page.dart`:移除 AppBar `actions` 中的二维码 `IconButton`(`mode=query` 扫码入口)。底部的「扫码激活」浮动按钮(`mode=bind&activate=1`)保留。
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已删除 | 设置页 | `lib/screens/profile/settings_page.dart`:删除运行环境切换与显示运行指标开关 |
| ✅ 已删除 | 设备列表 | `lib/screens/home/device_list_page.dart`:删除 AppBar 右上角二维码按钮 |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix62` → `v6-fix63` |
---
## v6-fix62(2026-08-31)
> **修复扫码查询「未找到设备。请确认二维码内容」且无法识别/连接设备的问题,并优化错误提示。**
>
> 用户反馈:设备列表页右上角扫码后弹「未找到设备。请确认二维码内容」,未能跳转到对应设备。
>
> **根因**:`device_scan_page.dart` 的 `_queryDevice`(query 模式)把扫码 **原始字符串** 直接与 `device.mac`/`deviceSn`/`id` 做小写等值比较,但 bind 模式会用 `_extractMacFromPayload` 解析 JSON(`{"mac":"..."}`)或归一化 MAC。因此当二维码是 `{"mac":"..."}` JSON,或 MAC 分隔符/大小写与设备记录不一致时,query 模式永远匹配不到 → 误报「未找到设备」。此外原提示「请确认二维码内容」过于笼统,对「二维码有效但设备尚未联网/同步」的场景没有引导。
>
> **修复**:
> 1. query 模式新增 `_parseScanPayload`:解析纯 MAC(兼容 `:`/`-`/无分隔)与 JSON(`mac`/`id`/`sn`/`deviceUid` 等字段),并用 `_normalizeMac` 归一化比较,忽略分隔符与大小写差异。
> 2. 本地列表未命中时,按 MAC/SN 调后端 `/device/info/list` 兜底查询,覆盖「设备已激活但本地缓存尚未刷新」的场景,命中即刷新列表并跳转详情。
> 3. 错误提示分级:二维码无法识别(非 MAC 也非含标识的 JSON)→ 「无法识别该二维码,请扫描设备二维码或确认二维码内容」;二维码有效但设备暂未就绪 → 「未找到对应设备(MAC)。设备可能尚未联网或同步,请确认设备已开机联网、已激活并归属当前账号后重试」。
> 4. 登录失效(401)仍按既有逻辑兜底跳登录页。
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已修复 | 扫码页 | `lib/screens/device/device_scan_page.dart`:`_queryDevice` 改为先解析 payload(MAC/JSON)、归一化 MAC 比较;本地未命中时按 MAC/SN 后端兜底查询并跳转详情 |
| ✅ 已修复 | 扫码页 | 新增静态 `_parseScanPayload`(兼容纯 MAC 与 JSON)与 `_normalizeMac`(去分隔符转大写) |
| ✅ 已优化 | 文案 | `lib/utils/i10n.dart`:新增 `scanPayloadUnrecognized`(无法识别二维码)、`scanDeviceNotReady(identifier)`(设备暂未就绪引导);原 `scanDeviceNotFound` 不再使用 |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix61` → `v6-fix62` |
---
## v6-fix59(2026-08-28)
> **修复环境切换后再操作导致的 `LateInitializationError`**。
>
> 用户截图显示报错:`LateInitializationError: Field '_dio' has already been initialized.`,调用栈指向 `ApiClient.reconfigure` → `SettingsPage` 的环境切换 SegmentedButton。根因:`ApiClient._dio` 声明为 `late final`,`reconfigure()` 重复调用时会二次赋值,触发 LateInitializationError。修复后支持重复重建,并在环境/语言切换入口增加 try-catch,避免崩溃。
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已修复 | 网络层 | `lib/services/api_client.dart`:`ApiClient._dio` 由 `late final` 改为 `late`,`reconfigure()` 改为返回 `({bool success, String? error})`,允许安全重复调用,内部捕获异常避免抛崩溃 |
| ✅ 已修复 | 设置页 | `lib/screens/profile/settings_page.dart`:环境切换 closure 加 try-catch,先切换环境、重建 Dio、提示「环境已切换,请重新登录」、再 logout;失败时 SnackBar 提示错误。语言切换也加 try-catch 防御 |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix58` → `v6-fix59` |
---
## v6-fix58(2026-08-28)
> **移除登录页副标题「设备与系统运维监控」**。
>
> 用户截图显示登录页标题「云定制主页管理」下方仍显示「设备与系统运维监控」一行副标题文字,需移除该文本及相关 UI 元素,保持页面其余布局与功能不受影响。
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已移除 | 登录页 | `lib/screens/login/login_page.dart` 删除副标题 `Text('设备与系统运维监控')` 及其上方的 `SizedBox(height: 8)`,登录页不再展示该文字 |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix57` → `v6-fix58` |
---
## v6-fix57(2026-08-28)
> **优化 App「用户管理-新增用户」交互(依据 MuMu 录屏 `云定制主页管理(5).mp4`)**。
>
> 打开新增/编辑用户弹窗时,系统默认先弹出「所属组织」选择界面,打断了先填表单(用户名/账号/密码等)的正常流程。调整为:组织选择置于表单填写完成之后,仅用户主动点击「所属组织」字段才展开。
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已修复 | 用户管理-新增/编辑用户 | `lib/screens/admin/sys/user_page.dart` 的 `_UserForm` 中 `OrgPickerFormField` 显式设置 `autoOpen: false`,打开用户表单不再自动弹出组织选择器,需先填完必填信息、再主动点击字段触发 |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix56` → `v6-fix57` |
---
## v6-fix56(2026-08-28)
> **修复 App 组织选择器交互问题(依据 MuMu 录屏 `云定制主页管理(2)(3)(4).mp4`)**。
>
> 视频2/3:门店管理 → 点击编辑门店时,「所属组织」选择器在表单打开后自动弹出,影响操作流程。视频4:用户管理 → 编辑用户 → 点击「所属组织」选择器后,列表未将当前已选组织置顶,导致需滑动查找。
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已修复 | 门店编辑页 | `lib/screens/store/store_list_page.dart` 中 `_StoreFormSheet` 的 `OrgPickerFormField` 显式设置 `autoOpen: false`,进入编辑/新增表单时不再自动弹出组织选择器,仅用户主动点击字段时才展开 |
| ✅ 已修复 | 组织选择器 | `lib/widgets/org_picker_sheet.dart` 中 `OrgPickerSheet` 在过滤后把当前 `_selectedId` 对应的选项置顶到列表最前面,保持其他项原有层级顺序;搜索关键字为空时当前选择一目了然,搜索时仍按名称过滤 |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix55` → `v6-fix56` |
---
## v6-fix55(2026-08-27)
> **用户纠正**:v6-fix54 后加的"服务器地址"自由输入设置实为一条可切生产的入口(可手填生产 IP `58.220.83.58`),违反"仅测试环境"要求。本版本移除该入口,改为"强制防生产切换"机制:默认锁定测试环境,永不可进入生产模式。
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已重构 | 环境配置 | 新增 `lib/config/` 模块:`AppEnvironment` 枚举(仅 `test` / `staging`,**刻意不含 production**)、`TestConfig` / `StagingConfig`(测试/预发敏感配置分文件独立管理,禁止交叉引用生产)、`AppConfig`(按当前环境解析 origin/前缀/验证码开关,默认 `test`)、`ProductionGuard`(启动期强制守卫) |
| ✅ 已移除 | 可切生产入口 | 删除 `lib/utils/server_config.dart` 及设置页"服务器地址"自由输入对话框(原可手填任意 URL 含生产 IP);所有网络层(`api_client` / `auth_service` / `marquee_web_source`)改为读 `AppConfig` 当前环境地址 |
| ✅ 已新增 | 启动守卫 | `main.dart` 启动即调用 `ProductionGuard.enforce()`:① 编译期 `assert(!kAllowProduction)`(kAllowProduction 恒 false);② 运行期解析当前后端 host,若命中生产拒绝名单(`58.220.83.58` / `api.ainetv.com` / `ainetv.com` 或含 `prod`)则 `print` 清晰报错并 `exit(1)` 阻断启动,确保系统无法进入生产模式 |
| ✅ 已新增 | 环境切换函数 | `AppConfig.switchEnvironment(AppEnvironment)` 仅接受预定义 `test` / `staging` 枚举值,显式排除 production,禁止解析/接收任何生产标识字符串;设置页仅提供 TEST/预发 分段切换(无生产按钮) |
| ✅ 已清理 | 常量 | `AppConstants` 移除 `apiBaseUrl` / `apiFullBaseUrl` / `enableCaptcha`(原注释含生产 IP 兜底),仅保留 `apiPrefix`;默认配置不再含任何生产默认值/兜底/隐式生产配置 |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix54` → `v6-fix55` |
| ✅ 已验证 | 构建 | `flutter analyze` 0 error;release APK `ops-console-release-v6-fix55.apk`(33.9MB)验鲜:`v6-fix55`=1、`ProductionGuard`=1、默认 origin=`ips-test.ainetv.com`(测试);prod-ip 仅出现在守卫拒绝名单中,非活动端点 |
---
## v6-fix54(2026-08-27)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已修复 | Web-公司介绍内容展示 | 修复 Web 端「公司介绍」选中大类后右侧小类列表偶发"暂无数据"的问题:`parseIntroSubcategoryList` / `parseIntroDetailList` 现在兼容后端 `{data: [...]}` 与 `{data: {list, totalCount}}` 两种返回;`loadChildren` 始终以当前 `treeData` 重新定位节点,避免 `selectedNode` 引用过期,并在 API 解析为空时使用树节点已携带的完整小类数据兜底展示 |
| ✅ 已优化 | App-公司介绍数据加载 | 门店内容页加载大类由 `getIntroCategoryAll()` + 客户端过滤改为 `getIntroCategoryList(page, limit, orgId)` 服务端过滤,与 Web 端 `store-company-intro.vue` 的查询方式完全一致,避免门店权限/分页差异导致数据不一致 |
| ✅ 已优化 | App-公司介绍保存字段 | 小类表单的 `categoryType` 预填父大类值;保存时若为空则不下发,与 Web 端保存行为对齐,避免空字符串覆盖后端默认值 |
| ✅ 已调整 | 文案 | 移除代码注释中的"门店维度"表述;管理首页入口、页面标题继续使用 `companyIntro`/`introCategoryTitle`(即"公司介绍") |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix53` → `v6-fix54` |
---
## v6-fix53(2026-08-27)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已重构 | 公司介绍-门店维度 | 将公司介绍入口从「大类→小类→详情」三级结构改为「门店维度」展示:新增 `IntroStoreListPage` 门店列表页(参考门店管理页布局,支持关键字搜索门店),点击进入 `IntroStoreContentPage` 门店介绍内容页 |
| ✅ 已新增 | 公司介绍-门店介绍内容页 | 按门店加载其下所有公司介绍大类,并按 `categoryType` 内容类型分组呈现;每个分类可展开查看小类,每个小类可展开查看详情内容;数据层级:门店 → 内容类型 → 分类 → 小类 → 详情 |
| ✅ 已保留 | 公司介绍-原管理入口 | 原 `/admin/intro/category` 三级管理页保留,可作为后台管理备用入口;管理首页公司介绍入口改为 `/admin/intro/store-list` |
| ✅ 已新增 | 公司介绍-快捷操作 | 门店内容页支持直接在分类/小类/详情层级新增、编辑、删除;新增分类时自动带入当前门店 `orgId` |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix52` → `v6-fix53` |
---
## v6-fix52(2026-08-27)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已修复 | 公司介绍-小类/详情图片上传 | 根因:原 `UploadService.uploadImage` 走 `POST /sys/upload` 通用本地上传,与公司介绍模块(Web 端 `company-intro-subcategory-form.vue`)使用的腾讯云 COS STS 直传通道不一致,导致上传失败、无法保存。现改为对齐 Web 流程:计算文件 MD5 → `GET /theme/resource/check-md5` 秒传判断 → `GET /theme/upload/credential` 取 STS 临时凭证(bucket/region/key)→ 用临时密钥直传 COS(putObject,原生 q-sign-algorithm=sha1 签名)→ `POST /theme/resource/register` 注册 `tv_resource` → 返回 `https://ipscdn.ainetv.cn/{key}` 访问地址 |
| ✅ 已新增 | 依赖 | 新增 `crypto`(文件 MD5);腾讯云 COS 直传客户端 vendoring 到 `lib/services/cos/cos_client.dart`(基于 flutter_cos_client MIT 实现,修复签名有效期硬性 `now+3600` 与后端 STS `ExpiredTime` 冲突导致的 403,去掉未维护 0.0.1 包依赖与 mime 依赖) |
| ✅ 已完善 | 错误提示 | 上传各阶段(凭证不完整 / 云存储失败 / 资源注册失败)统一抛出 `ApiException`,message 为可展示中文;前端 `catch` 已能显示 `e.message`,便于定位失败原因 |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix51` → `v6-fix52` |
---
## v6-fix51(2026-08-26)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已修复 | 公司介绍-新增/修改 | 修复「保存失败」提示不展示后端真实原因的问题:拦截器返回的是 `DioException`(其 `.error` 才是 `ApiException`),原代码 `e is ApiException ? e.message : t.saveFailed` 永远走兜底,导致只能看到「保存失败」。三个表单(大类/小类/详情)的保存与删除异常提示统一改为 `formatApiError(e)`,可正确显示后端 msg |
| ✅ 已修复 | 公司介绍-新增大类 | 与 Web 前端字段对齐:`saveIntroCategory` 请求体移除后端未使用的 `orgName`;`orgId` 在发送前 `int.tryParse` 转为数字,与 Web 一致 |
| ✅ 已新增 | 公司介绍-表单校验 | 新增大类/小类/详情保存前进行客户端校验:名称/标题/类型编码/媒体地址非空;类型编码禁止中文。减少无效请求,错误反馈更即时 |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix50` → `v6-fix51` |
---
## v6-fix50(2026-08-26)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已修复 | 网络层(写操作失败) | `api_client.dart` 的 `ApiInterceptor.onRequest` 不再给 POST/PUT 等业务 body 注入防缓存字段 `t`:此前该字段会作为未知列进入后端 insert,导致「列表能读、新增/修改 500 失败」。防缓存 `t` 仅在 GET 的 query 上保留;写入请求 body 保持纯净 |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix49` → `v6-fix50` |
---
## v6-fix49(2026-08-26)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已更新 | 后端环境切换 | `constants.dart` 的 `apiBaseUrl` 由生产环境 `http://58.220.83.58` 切回测试环境 `https://ips-test.ainetv.com`(HTTPS);生产地址保留为注释便于后续切回 |
| ✅ 已恢复 | 登录验证码 | `enableCaptcha` 由 `false` 恢复为 `true`:测试环境 `ips-test.ainetv.com` 提供 `/api/captcha.jpg`(HTTP 200),登录页重新显示验证码输入与刷新 |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix48` → `v6-fix49` |
---
## v6-fix48(2026-08-26)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已更新 | 桌面/启动图标 | 将 `com_ainetv_ainetvcloud_xxxhdpi_640dpi_云管理.png` 生成并覆盖 Android 5 个密度(mdpi/hdpi/xhdpi/xxhdpi/xxxhdpi)的 `mipmap-*/ic_launcher.png` |
| ✅ 已更新 | 登录页 logo | 登录页继续使用 `assets/images/logo.png`,已同步替换为同一源图 |
| ✅ 已更新 | 应用内图标 | 设置页「关于」弹窗新增 `applicationIcon`,统一使用 `assets/images/logo.png`(64×64 显示) |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix47` → `v6-fix48` |
---
## v6-fix47(2026-08-26)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已修复 | 登录页 logo | 将顶部蓝色 `Icons.monitor_heart` 图标替换为橙色云朵 logo 图片(`assets/images/logo.png`,文字已改为「云管理」,保留底部「ainetv」) |
| ✅ 已修复 | 验证码加载失败 | 新增 `AppConstants.enableCaptcha` 开关(默认 `false`):关闭时隐藏验证码输入区与刷新按钮,登录时 captcha/uuid 传空串,避免生产环境 `58.220.83.58` 未暴露 `/api/captcha.jpg` 导致登录页报错 |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix46` → `v6-fix47` |
---
## v6-fix46(2026-08-26)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已更新 | 后端环境切换 | `constants.dart` 的 `apiBaseUrl` 由测试环境 `https://ips-test.ainetv.com` 切换为生产环境 `http://58.220.83.58`(明文 HTTP,Android 已开启 `usesCleartextTraffic`);测试地址保留为注释便于回退 |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix45` → `v6-fix46` |
---
## v6-fix45(2026-08-26)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已修复 | 手机端全屏显示 | 修复应用在手机上未全屏、四周出现黑边的问题:`main.dart` 启动时设置 `SystemUiMode.edgeToEdge` + 透明状态栏/导航栏;`styles.xml` / `styles-night.xml` 的 `NormalTheme` 启用 `windowDrawsSystemBarBackgrounds` 并将状态栏/导航栏颜色设为透明 |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix44` → `v6-fix45` |
---
## v6-fix44(2026-08-26)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已移除 | 公司介绍门店筛选 | 移除大类列表顶部「筛选门店」FilterChip 区域(含「全部门店」多选),保留大类/小类/详情三级列表、表单、树形组织选择器、图片/视频上传等其余所有功能与布局 |
| ✅ 已清理 | 冗余代码 | 移除 `_stores`、`_selectedOrgIds`、`_loadStores`、`_applyOrgFilter`、`_buildOrgFilter` 及 `StoreInfo` import;`_storeName` 改为仅从组织树回查;`initState` 改调用 `_loadOrgNodes()` 以支持列表门店名称回显 |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix43` → `v6-fix44` |
---
## v6-fix43(2026-08-26)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已调整 | 公司介绍门店选择器节点规则 | 树形组织选择器仅展示渠道(type=1)、代理商(type=2)与门店(type=3);渠道与代理商节点 `selectable=false`(禁用、灰色、不可点击);门店节点保持可选;平台(type=0)与设备分组(type=4)不展示 |
| ✅ 已新增 | 节点选择工具方法 | 新增 `_isOrgVisible`、`_isOrgSelectable`,统一控制选择器内组织节点的展示与可选状态 |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix42` → `v6-fix43` |
---
## v6-fix42(2026-08-26)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已改造 | 公司介绍大类门店选择器 | 「所属门店」从 DropdownButtonFormField 改为与用户管理一致的树形组织选择器:底部弹窗、实时搜索组织名称、按组织层级缩进展示;复用 `OrgPickerSheet`/`OrgPickerFormField` 组件,数据走 `OrgService.getOrgTree()`(组织树 / flatList / select 三阶回退) |
| ✅ 已实现 | 编辑态自动高亮当前节点 | 编辑大类时 `OrgPickerFormField` 自动展开选择器,并默认选中高亮当前 `orgId` 对应的门店组织节点;若当前 category 所属组织不在用户可见树中,自动补入选项保证可展示 |
| ✅ 已新增 | 组织树工具方法 | `intro_category_page` 新增 `_loadOrgNodes`、`_buildOrgPickerOptions`、`_buildHierarchicalOptions`、`_buildHierarchyFromFlatList`、`_findOrgByName`、`_findOrgById`,与用户管理 `_UserForm` 对齐 |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix41` → `v6-fix42` |
---
## v6-fix41(2026-08-26)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已修复 | 公司介绍大类门店选择 | 修复新增/编辑大类时「所属门店」下拉为空、无法选择门店的问题:`intro_category_page._loadStores` 改用 `OrgService.getMyStores()`(组织树 / myStores / listByLoginOrg 三阶回退),覆盖当前用户可见的所有门店;表单打开前强制重新加载门店;编辑时若当前 category 所属门店不在返回列表中,自动补入下拉项;空字符串 `orgId` 统一视为未选择,避免收起态显示空白 |
| ✅ 已新增 | 国际化 | `i10n.dart` 新增 `introStoreLoadFailed`(门店列表加载失败提示)中英 key |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix40` → `v6-fix41` |
---
## v6-fix38(2026-08-25)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已实现 | 公司介绍多门店筛选 | 大类列表页门店筛选由单选下拉改为 `FilterChip` 多选,空选表示「全部门店」,数据按所选门店列表实时过滤 |
| ✅ 已实现 | 公司介绍图片上传 | 新增 `UploadService`,小类/详情表单支持从相册选择图片、本地即时预览、Dio `FormData` 上传至 `/sys/upload`,成功后自动回填 `mediaUrl` 并切换为网络预览;保留手动输入 `mediaUrl` 兜底 |
| ✅ 已对齐 | 大类表单 | 新增「所属门店」必填下拉(`orgId`/`orgName` 联动);移除 Web 后台不存在的 `status` 字段,保存字段与 Web 端一致 |
| ✅ 已对齐 | 小类/详情表单 | 新增「排序号」字段;`status` 语义与 Web 后台统一为 `1=启用/0=禁用`;小类新增可选「类型编码」字段(默认预填父大类编码) |
| ✅ 已改造 | 保存接口分流 | `IntroService` 三个 `save*` 方法按 `id` 是否存在分流到 `/save`(新增)或 `/update`(编辑),与 Web 端 `companyIntro.ts` 保持一致 |
| ✅ 已修正 | 状态语义 | `IntroCategory`/`IntroSubcategory`/`IntroDetail` 的 `_parseStatus` 改为后端原值映射(1=启用,0=禁用),列表 Chip 文案同步改为「启用/禁用」 |
| ✅ 已新增 | 国际化 | `i10n.dart` 补充 `introFilterStores`、`introSelectStoresHint`、`introCategoryStore`、`introStoreRequired`、`introSortOrder`、`introEnabled`、`introDisabled`、`introUploadImage`、`introPickImage`、`introImagePreview`、`introUploading`、`introUploadFailed`、`introInsertImage` 中英 key |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix37` → `v6-fix38` |
---
## v6-fix40(2026-08-26)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已实现 | 滚动字幕速度档位化 | 数字速度输入改为 5 档下拉选择(慢/较慢/中/较快/快),新增 `MarqueeConfig`/`MarqueeSpeed` 配置结构,提供 `setSpeed`/`setGrade`/`copyWith` 接口与 `speedFromValue`/`gradeFromValue` 历史值吸附;落库仍为 Web 端原值(200/100/50/30/10),卡片与表单直观显示当前生效档位 |
| ✅ 已实现 | 滚动字幕级别选择 | 新增 3 级「级别」下拉(危险/紧急/普通),落库原值(50/40/20),与 Web 端 `scrolling-text.data.ts` 一致;列表与表单显示当前生效级别 |
| ✅ 已实现 | 滚动字幕字体颜色色板 | 「字体颜色 / 背景颜色」由手动输入 `#HEX` 改为对齐 Web 端 `el-color-picker` 的色板弹窗:饱和度/明度 2D 选择区 + 色相条 + HEX 输入框 + 实时预览 + 取消/确定,回填 `#RRGGBB` |
| ✅ 已修复 | 公司介绍大类门店名称 | 新增/编辑大类「所属门店」选中后正确展示门店组织名称:下拉增加 `selectedItemBuilder` 确保收起态渲染所选名称;列表卡片在接口未返回 `orgName` 时按 `orgId` 从本地门店列表回查名称 |
| ✅ 已对齐 | 公司介绍小类图片上传 | 小类素材固定为图片(与 Web 端一致,Web 端无媒体类型选择、始终 `mediaType=image`);新增图片类型(jpg/png/gif/webp)与大小(≤300KB,建议 1920×1080)校验,超限给出提示;上传仍走后端 `/sys/upload`,返回 URL 存入 `mediaUrl` |
| ✅ 已对齐 | 公司介绍详情图片/视频上传 | 详情表单保留「媒体类型」图片/视频切换;图片(≤3MB)/ 视频(≤30MB)类型与大小校验与 Web 端一致,视频走 `pickVideo`;上传路径与返回结构同小类 |
---
## v6-fix39(2026-08-25)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已实现 | 滚动字幕起止时间筛选 | 「滚动字幕」新增/编辑表单的开始时间、结束时间改为纯 `HH:mm:ss` 滚轮选择器,不再复用日期时间选择器,与 Web 端 `/theme/scrollingText` 时间字段格式(`HH:mm:ss`)一致 |
| ✅ 已实现 | 时间滚轮控件 | 新增 `_TimePickerSheet` 底部弹窗:三列 `ListWheelScrollView` 滚轮(时 00-23 / 分 00-59 / 秒 00-59),`FixedExtentScrollController` 定位初始值;底部「取消/确定」按钮,确定后回传 `DateTime(2000,1,1,h,m,s)`,与 `MarqueeMessage._parseDateTime` 基准日期一致 |
| ✅ 已对齐 | 默认值与范围 | 新增字幕默认起止时间为 `00:00:00` ~ `23:59:59`;编辑时回显既有 `startTime`/`endTime`;秒级精度,可选范围覆盖全天,与 Web 后台一致 |
| ✅ 已新增 | 国际化 | `i10n.dart` 补充 `marqueeHour`/`marqueeMinute`/`marqueeSecond`(时/分/秒)中英 key,供滚轮单位标签使用 |
| ✅ 已清理 | 死代码 | 移除 `_TimePickerSheet._buildWheel` 中的未使用 `selected` 局部变量,消除 analyze 警告 |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix38` → `v6-fix39` |
---
## v6-fix37(2026-08-25)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已修复 | 组织选择器高亮 | `OrgPickerSheet` 列表项选中时立即通过本地 `_selectedId` 状态更新高亮背景、文字颜色与对勾图标,并延迟 120ms 后关闭弹窗,解决选中条目需要额外按一次才显示高亮的问题 |
| ✅ 已优化 | 选择器视觉反馈 | 已选项文字加粗并显示 `Icons.check_circle`,高亮底色透明度由 `0.24` 提升至 `0.38`,选中状态与视觉反馈同步呈现 |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix36` → `v6-fix37` |
---
## v6-fix36(2026-08-25)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已修复 | 滚动字幕数据源 | 移动端 `MarqueeWebSource` 默认接口由 `/marquee/list` 改为与 Web 后台一致的 `/theme/scrollingText/list`;解析 `totalCount` 与 Web 端字段映射(`orgId`→`storeId`、`orgName`→`storeName`、`scrollSpeed`→`speed`、`bgColor`→`backgroundColor`),解决移动端列表为空的问题 |
| ✅ 已修复 | 滚动字幕新增/编辑 | 修复 `_MarqueeFormState` 中 `late String? _storeId` 未在 `initState` 初始化导致的 `LateInitializationError`;新增「状态」下拉框(启用/禁用),表单提交携带真实状态 |
| ✅ 已改造 | 滚动字幕服务 | `MarqueeService` 新增/编辑/删除/启用/禁用均优先调用 Web 后台 `/theme/scrollingText/save|update|delete`;失败时回退本地 `SharedPreferences` 缓存,保证编辑内容实时同步到桌面与移动端 |
| ✅ 已优化 | 列表卡片 | `_StatusChip` 改为「启用/禁用」;`_MarqueeCard` 操作按钮改为「启用/禁用」;时间范围展示改为 `HH:mm:ss`,与 Web 后台一致 |
| ✅ 已新增 | 国际化 | `i10n.dart` 补充 `marqueeStatus`、`marqueeStatusEnabled`、`marqueeStatusDisabled` 中英 key |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix35` → `v6-fix36` |
---
## v6-fix35(2026-08-25)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已重构 | 设备详情页 | `DeviceDetailPage` 按需求分四个清晰区块:**基本信息**(设备名称、设备 ID、型号、状态)、**配置参数**(所属门店、分组、房号、位置、操作系统、设备序列号、设备 UID、创建时间)、**网络信息**(IP 地址、MAC 地址)、**运行时数据**(在线时长、最后活跃时间);原信息卡拆分为带标题的分区块卡片,缺口字段(设备名称 / 设备 ID / 最后活跃时间)已补齐并回显 |
| ✅ 已更新 | 详情头部 | 标题由「型号」改为「设备名称」(型号降级为副标题),与列表点击进入的语义一致 |
| ✅ 已新增 | 国际化 | `i10n.dart` 补充 `basicInfo`、`configParams`、`networkInfo`、`runtimeData`、`deviceName`、`deviceId`、`modelLabel`、`deviceSnLabel`、`deviceUidLabel`、`createTime`、`lastActiveTime` 等中英 key |
| ✅ 已保留 | 刷新同步 | 详情页下拉刷新(`RefreshIndicator`)刷新 `deviceDetailProvider`,与设备列表数据保持一致;「下发指令 / 修改分组」按钮保持可用 |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix34` → `v6-fix35` |
---
## v6-fix34(2026-08-25)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已实现 | Web 端滚动字幕数据源 | 新增 `lib/services/marquee_web_source.dart` 作为独立的 Web 端内容获取层;支持配置请求地址(`baseUrl` / `path`)与请求方式(`GET/POST` 等),内置超时、网络异常、HTTP 错误、业务码错误、解析失败等完善错误处理;将结果封装为结构化 `MarqueeFetchResult` |
| ✅ 已扩展 | 滚动字幕数据模型 | `MarqueeMessage` 新增 `fontColor`、`backgroundColor`、`fontSize`、`level`、`startTime`、`endTime` 字段,兼容 Web 端返回的 JSON 格式 |
| ✅ 已改造 | 字幕服务 | `MarqueeService.getMarqueeList` 优先调用 `MarqueeWebSource` 获取 Web 端数据,失败时自动回退到本地 `SharedPreferences` 缓存 |
| ✅ 已更新 | UI | `MarqueeManagementPage` 列表卡片展示字体颜色、背景颜色、字体大小、级别、生效时间;新增/编辑表单支持设置颜色、字号、级别与起止时间 |
| ✅ 已新增 | 国际化 | `i10n.dart` 补充字体颜色、背景颜色、字体大小、级别、开始/结束时间等中英 key |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix33` → `v6-fix34` |
---
## v6-fix33(2026-08-25)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已实现 | 滚动字幕管理 | 在「管理」界面新增「滚动字幕管理」入口;新增 `MarqueeManagementPage`(`/admin/marquee`),支持按门店查看、新增、编辑、删除、查询滚动字幕,并区分不同门店内容 |
| ✅ 已实现 | 下发/取消下发 | 字幕状态区分为「待下发」与「已下发」;点击「下发」调用设备指令 `marqueeUpdate` 按组织下发到门店设备,成功后状态更新为已下发;点击「取消下发」重置为待下发 |
| ✅ 已新增 | 数据模型 | `lib/models/marquee_message.dart` 定义滚动字幕实体;`lib/services/marquee_service.dart` 基于 `SharedPreferences` 实现字幕记录本地持久化与 CRUD,并提供下发/取消下发能力 |
| ✅ 已新增 | 国际化 | `lib/utils/i10n.dart` 补充滚动字幕管理相关 key(管理标题、新增/编辑、字幕内容、滚动速度、目标门店、状态、下发/取消下发等) |
| ✅ 已更新 | 管理首页 | `lib/screens/admin/admin_hub_page.dart` 增加「滚动字幕管理」入口卡片 |
| ✅ 已更新 | 路由 | `lib/app/router.dart` 增加 `/admin/marquee` 路由 |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix32` → `v6-fix33` |
---
## v6-fix32(2026-08-25)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已修复 | 激活码管理 | 英文语言下激活码管理页所有中文硬编码文案(门店选择、当前激活码、复制、最大激活数、已激活数、创建时间、生成/重置激活码、生成弹窗等)全部替换为 `AppLocalizations` 国际化 key,切换英文后完整显示英文 |
| ✅ 已新增 | 国际化 | `i10n.dart` 补充 `copy`、`generate`、`generateActivationCodeHint`、`activationCodeCopied`、`activationCodeLoadFailed`、`generateFailedWithError` 等 key |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix31` → `v6-fix32` |
---
## v6-fix31(2026-08-25)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已修复 | 管理模块返回键 | 管理中心「设备管理」「激活码」入口原 `context.push('/device')` / `context.push('/device/activation')` 会把页面压入**设备分支**导航栈,导致底部导航错乱 + 左上角返回箭头显示/点击异常 → 在 `/admin` 分支下新增别名路由 `/admin/device`(复用 `DeviceListPage`)与 `/admin/activation`(复用 `DeviceActivationPage`),入口改为 `context.push('/admin/device')` / `context.push('/admin/activation')`;仍保留 `push` 方式,用户可通过系统返回键回到管理首页 |
| ✅ 已修复 | 设备列表 AppBar | `DeviceListPage` 的 `AppBar` 增加 `automaticallyImplyLeading: false`,从管理模块进入时不显示左上角返回箭头(`DeviceActivationPage` 此前已设置,保持生效) |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix30` → `v6-fix31` |
⚠️ 备注:
- 原 `/device` 与 `/device/activation` 路由**保留不变**(设备 tab 入口及设备详情内的跳转仍使用),本次仅新增 `/admin` 分支下的别名路由,无命名冲突。
---
## v6-fix30(2026-08-25)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已实现 | 角色选择器 | 用户编辑页「角色」字段由下拉框改为可视化 `ChoiceChip` 横向选择器;编辑时根据 `roleId`/`roleName`/`roleNames` 自动选中对应角色,选中项显示主题色高亮背景 + `Icons.check` 勾选图标;角色来自动态 `widget.roles`,新增用户默认选中当前登录用户角色 |
| ✅ 已删除 | 全局返回悬浮按钮 | 移除 `AppBottomNav` 中的全局返回 FAB(`_BackFabLocation`、`floatingActionButton`、`floatingActionButtonLocation`)及 `navigator_key.dart` 导入;同步删除不再被引用的 `lib/app/navigator_key.dart`,并移除 `router.dart` 中的 `navigatorKey` 引用 |
| ✅ 已删除 | AppBar 返回键 | 扫描 `lib/screens/**` 所有 `AppBar`,移除自定义 `leading` 返回按钮(`device_scan`/`device_group`/`store_config` 三页),并对全部子页面 `AppBar` 设置 `automaticallyImplyLeading: false`,统一移除左上角返回箭头,保留标题与 actions |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix29` → `v6-fix30` |
---
## v6-fix29(2026-08-25)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已实现 | 组织选择器 | 新建/编辑门店与用户编辑页「所属组织」字段进入页面时自动弹出选择弹窗,已选项自动高亮勾选;弹窗支持关键字实时搜索与层级缩进展示 |
| ✅ 已实现 | 返回悬浮按钮 | 在 `AppBottomNav` 外层 Scaffold 增加全局返回悬浮按钮(左上角、仅非首页显示),点击优先 `pop`,不可 pop 时回到首页 |
| ✅ 已删除 | AppBar 返回键 | 移除「设备管理」「用户管理」「激活码」「公司介绍」四页 AppBar 左上角返回箭头,统一由全局返回悬浮按钮负责返回 |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix28` → `v6-fix29` |
---
## v6-fix28(2026-08-25)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已修复 | 默认选中 | 门店编辑页与用户管理编辑页进入时,已关联的组织/角色选项自动高亮选中;用户编辑时若后端未返回 ID,通过名称反向匹配并增加只读占位项兜底展示 |
| ✅ 已修复 | 门店组织搜索 | 编辑门店「所属组织」搜索框输入关键字后,新增实时搜索结果列表;点击结果立即更新 `_parentId`、清空搜索关键字并刷新页面,修复「选中无反应」问题 |
| ✅ 已优化 | 门店默认上级 | 编辑门店加载组织树后,若 `store.parentId` 为空,自动在组织树中按 `store.orgId` 查找门店节点并取其 `parentId` 作为默认上级;新增/仍为空时回退到当前用户所在组织 |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix27` → `v6-fix28` |
---
## v6-fix27(2026-08-25)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已优化 | 门店表单 | 编辑门店「所属组织」下拉选项仅渲染渠道(type=1)与代理商(type=2),门店类型(type=3)已过滤隐藏;新增组织名称关键字搜索框,输入时实时模糊匹配并动态筛选渠道/代理组织 |
| ✅ 已优化 | 默认选中 | 新增门店时,若未指定上级组织则默认选中当前登录用户所在组织;新增用户时,默认选中当前登录用户的所属组织与角色 |
| ✅ 已更新 | 用户模型 | `User` 模型新增 `orgId`/`roleId` 字段;`AuthService` 从登录响应与 `/sys/user/info` 中解析并持久化,供表单默认选中使用 |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix26` → `v6-fix27` |
---
## v6-fix26(2026-08-25)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已修复 | 用户编辑 | 编辑用户时角色字段为空 → 当列表接口未返回角色信息时,自动调用 `/sys/user/info/{id}` 拉取详情;同时通过 `roleName`/`roleNames` 反向匹配 `roleId`,确保角色下拉与用户信息页/Web 端展示一致 |
| ✅ 已优化 | 用户表单 | 新增/编辑用户「所属组织」下拉改为按组织层级自上而下排序:优先调用 `/sys/org/tree`,递归扁平化为带深度的选项并逐层缩进展示;失败时回退到 `/sys/org/flatList` 并按 `parentId` 重建层级顺序 |
| ✅ 已修复 | 返回按钮 | 管理模块入口进入的「设备列表」「激活码管理」「用户管理」页面左上角返回箭头点击无响应 → 统一将返回目标改为 `/admin`,确保回到管理首页 |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix25` → `v6-fix26` |
---
## v6-fix25(2026-08-25)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已优化 | 门店表单 | 编辑门店「所属组织」下拉列表改为按组织层级自上而下排序:调用 `/sys/org/tree` 获取树形结构,递归扁平化为带深度的选项;列表中从最上级组织开始,逐层缩进展示其下级组织,层级关系清晰 |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix24` → `v6-fix25` |
---
## v6-fix24(2026-08-25)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已修复 | 修改分组 | 分组下拉仍展示全部门店分组 → 修复 `/device/group/list` 接口参数:Web 端按 `orgId` 过滤门店,移动端此前只传 `storeId` 导致后端忽略门店过滤;现同时携带 `orgId` 与 `storeId`,确保仅返回当前设备所属门店的分组 |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix23` → `v6-fix24` |
---
## v6-fix23(2026-08-25)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已修复 | 修改分组 | 分组下拉报 `DioException [unknown]: null` → 改为调用已有接口 `/device/group/list?storeId=xxx`;同时增强 `Device.storeId` 解析,支持从 `store`/`storeInfo`/`org` 嵌套对象取门店 ID |
| ✅ 已修复 | 新建门店保存 | 保存仍提示「未知异常」→ `_save()` 先按 Web 端字段契约(`type='STORE'`/`status='ACTIVE'`)提交;失败时自动 fallback 到旧后端兼容格式(`type=3`/`status=1`),并展示后端返回的具体错误信息 |
| ✅ 已修复 | 首页快捷操作 | 「系统管理」文字/图标有误 → 改为「用户管理」并替换为 `Icons.people` |
| ✅ 已修复 | 返回按钮 | 管理模块下的「设备管理」「激活码」「用户管理」「公司介绍」页面左上角返回键无响应 → 统一为各页 AppBar 增加显式 `leading` 返回按钮(可 pop 时 pop,不可 pop 时回退到 `/admin`) |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix22` → `v6-fix23` |
---
## v6-fix22(2026-08-24)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已调整 | 首页快捷操作 | 移除「媒资管理」入口;当前项目阶段暂不需要,后续如需可在 `HomePage._buildDashboard` 中恢复 `_QuickAction(mediaManagement)` |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix21` → `v6-fix22` |
---
## v6-fix21(2026-08-24)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已修复 | 修改分组 | 分组下拉改为仅展示当前设备所属门店的分组:调用门店专用接口 `/sys/store/deviceGroups/{storeId}`,避免 `/device/group/list` 跨门店返回全部分组;无门店上下文时保留「未分组」与当前分组可选 |
| ✅ 已修复 | 返回按钮 | 扫码激活页左上角返回键无响应 → AppBar 增加显式 `leading` 返回按钮;全面排查并把设备列表/扫码页/首页管理入口/管理中心的 `context.go` 改为 `context.push`,确保子页面返回正常;为设备分组页、门店配置页也增加显式返回兜底 |
| ✅ 已修复 | 门店保存 | 门店保存仍失败(提示「未知异常,请联系管理员」)→ 对齐 Web 端 `/sys/org/saveOrUpdate` 字段契约:`type` 改为字符串 `'STORE'`,新增 `orderNo`/`status`,补充 `storeProfile` 对象(storeName/phone/address/province/city);移除后端不识别字段;所属组织下拉仅展示代理商(type=2),与 Web 端一致 |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix20` → `v6-fix21` |
⚠️ 备注:
- 门店保存字段已按 Web 端「新增门店」payload 重构;若后端仍返回具体业务错误,弹窗会展示后端 msg,请把完整错误文字发我进一步对齐。
- 返回按钮修复后,从底部导航进入的 tab 根页(首页/设备/门店/管理/我的)仍保持 `go` 替换,子页面统一使用 `push`,返回行为与原生一致。
---
## v6-fix20(2026-08-24)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已修复 | 新增门店 | 门店新增保存时报错 → `_save()` 增加「所在地区」「所属组织」必选项校验;保存失败/成功/校验失败均通过页面中央 `AlertDialog` 提示,避免 SnackBar 被遮挡 |
| ✅ 已优化 | 门店表单 | 新增 i18n 词条 `regionRequired`/`parentOrgRequired`;硬编码中文接入国际化 |
| ✅ 已修复 | 激活码管理 | 「重置激活码」结果提示改为页面居中 `AlertDialog` 展示(成功/失败均居中),替代原左下角 SnackBar |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix19` → `v6-fix20` |
---
## v6-fix19(2026-08-24)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已修复 | 设备列表 | 顶部搜索框 hint 被截断(`搜索设备名称 / 房号`)→ 顶部改为两行布局:第一行放门店下拉 + 型号筛选,第二行放全宽搜索框,确保 hint 完整显示;i18n `searchDeviceHint` 中英文词条已确认同步 |
| ✅ 已修复 | 设备详情 | 遥测字段(位置 / 操作系统 / 运行时长)全显示空或 `0分` → `Device.fromJson` 新增详情响应兜底解包(`{device|info|data|result|record}` 嵌套),并对 location/os/uptime 等做常见字段别名兼容;详情卡空值统一回退为「无」,保证 UI 不崩溃 |
| ✅ 已修复 | 修改分组 | 弹窗原显示「所属分组 ID」文本输入框 → 改为 `DropdownButtonFormField` 分组名称下拉,进入时按设备所属门店拉取 `/device/group/list`,下拉显示分组名称(`groupName`)、提交 `groupId`,与 Web 端一致;无门店上下文时安全降级 |
| ✅ 已修复 | 公司介绍 | 左上角返回箭头点击无响应 → AppBar 增加显式 `leading` 返回按钮:`context.pop()` 回管理中心,内部层级逐级返回,无法 pop 时兜底跳转 `/admin` |
| ✅ 已修复 | 编译 | 修复 `Device.fromJson` 引用未定义的 `_firstNum` 导致的编译错误(原交付版本潜在隐患);补充 `num? _firstNum(...)` 静态辅助方法 |
| ✅ 已优化 | i18n | 新增 `back`(返回)、`groupListLoading`(加载分组列表…)等词条,弹窗内所有硬编码中文均接入 `lib/utils/i10n.dart` |
| ✅ 已更新 | 版本号 | 登录页底部版本号 `v6-fix18` → `v6-fix19` |
⚠️ 备注:
- 设备详情遥测依赖后端 `/device/info/info/{id}` 实际返回字段;已做 `device/info/data` 等多层包络与 `cpu_usage`/`os`/`uptime` 等常见别名兼容,若后端确无数据则统一显示「无」,不崩溃。
- 修改分组下拉依赖设备携带的门店 ID(`Device.storeId`);若该字段后端未返回,下拉仅含「未分组」与当前分组,仍可正常提交。
⏳ 待验收:4 项修复在真机/模拟器上的实际表现。
---
## v6-fix18(2026-08-24)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已实现 | 新增门店 | 激活码改为由系统依据 web 端已有规则自动生成:移除手动输入框,保存门店后若终端数>0 自动调用 `/device/activationCode/generate`;每次点击「创建激活码」仅生成一个唯一码 |
| ✅ 已实现 | 门店编辑 | 编辑页直接展示「最大终端数 / 已用终端数」及当前正在使用的激活码,并提供「创建激活码」按钮 |
| ✅ 已修复 | 门店详情 | 创建时间字段空白 → `StoreInfo` 兼容 `createTime`/`create_time`/`createdTime`/`createdAt`/`created_at` 等常见别名;列表项与详情弹窗均补充展示创建时间 |
| ✅ 已修复 | 语言切换 | 切换英文后启动报错「token 失效」→ `ApiInterceptor` 刷新 token 成功后同步持久化到 `CredentialStorage`,避免语言切换/进程重启后恢复到旧令牌 |
| ✅ 已修复 | 扫码页 | 英文模式下扫码页全部文案接入 i10n,包括标题、提示、权限、弹窗、成功/失败提示等 |
| ✅ 已优化 | 门店表单 | 省市区、详细地址、联系电话、所属组织、保存/创建按钮等剩余硬编码中文全部接入 i10n |
⏳ 待验收:新增门店自动生成激活码、编辑页终端数/激活码展示、英文切换不报错、扫码页英文文案。
---
## v6-fix17(2026-08-24)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已修复 | 设备模型 | 新增 `Device.model` 字段,按 `model`/`modelName`/`deviceModel`/`productModel`/`productName`/`board`/`hardware`/`brand`/`deviceType`/`type` 等常见别名解析真实型号 |
| ✅ 已修复 | 设备显示 | `DeviceListTile` / `DeviceDetailPage` 标题统一使用 `device.model`,避免把 MAC 地址误当型号展示 |
| ✅ 已修复 | 型号过滤 | 设备列表「型号」筛选框改为基于 `Device.model` 过滤 |
| ✅ 已优化 | 降级处理 | 型号解析不到时,依次降级为 `name`(若 name 不是 MAC)→ `deviceSn` → `mac`;并新增 `_isMacAddress` 识别 MAC 格式,确保 MAC 不会混入型号展示 |
⏳ 待验收:设备标题是否显示真实型号(非 MAC)。
---
## v6-fix16(2026-08-24)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已实现 | 设备列表 | 顶部搜索区新增「型号」输入框,按 `Device.name` / `deviceSn` 实时过滤设备列表 |
| ✅ 已优化 | 设备列表 | Riverpod `AsyncValue.when` 启用 `skipLoadingOnRefresh`/`skipLoadingOnReload`,刷新/重新加载时保留已有数据,避免全屏转圈覆盖已加载项 |
| ✅ 已优化 | 设备列表 | 门店下拉已加载过门店后不再重新显示转圈;状态 Chip 颜色与 `DeviceStatus` 严格对应(在线绿/离线灰/告警橙) |
⏳ 待验收:型号筛选是否生效、刷新是否不再全屏转圈。
---
## v6-fix15(2026-08-24)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已修复 | 设备列表 | 标题前增加「型号:」前缀(如「型号:rk3566_r」),并接入 i10n |
| ✅ 已修复 | 扫码页 | 移除 AppBar「相册识别」按钮,进入页面即直接调用摄像头扫描;说明当前代码无「实时截屏/摄像头」选择弹窗,若仍出现请确认版本/入口 |
| ✅ 已实现 | 新增门店 | 表单新增「终端数」与「激活码」字段;保存门店后若终端数>0,自动调用 `/device/activationCode/generate` 创建激活码;`saveOrUpdateOrg` 改为返回 orgId 供后续操作 |
⏳ 待验收:设备标题前缀、扫码页直接摄像头、新增门店激活码/终端数。
---
## v6-fix14(2026-08-24)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已修复 | 设备列表 | web 端有房间号但手机端显示「未设置」→ 扩大 `Device.fromJson` 房号字段兼容:`roomNo`/`room`/`roomNumber`/`room_id`/`roomNum`/`roomName`/`houseNumber`/`roomCode` 等常见别名 |
✅ 已验收:设备列表房号。
---
## v6-fix13(2026-08-24)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已修复 | 门店详情 | 激活码仍显示「—」→ 改从激活码专用接口 `/device/activationCode/info/{orgId}` 拉取 `code` 字段,作为真实激活码展示;接口无值时回退门店列表里的 `activationCode` |
| ✅ 已修复 | 设备列表 | 副标题改为用户指定格式:**门店:XXX门店 MAC:AA:BB:CC:DD:EE:FF 房号:V888**(缺失项显示「未设置」) |
✅ 已验收:门店详情激活码、设备副标题格式。
---
## v6-fix12(2026-08-24)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已修复 | 用户管理 | 角色/组织显示「未分配」→ 兼容后端嵌套对象/数组返回(`roles`/`org`/`dept` 等),并建立完整 orgId→name 映射 |
| ✅ 已修复 | 设备列表 | 副标题改为 `MAC · 所属门店 · 房号`(原为设备型号/MAC) |
| ✅ 已修复 | 门店管理 | 手机端新增/编辑门店**真实上报后端**(`/sys/org/saveOrUpdate`),不再本地占位 |
| ✅ 已修复 | 门店列表 | 按创建时间倒序,最新门店排最前面(与 web 一致) |
⏳ 待验收:用户管理角色/组织回显、新增门店 web 可见、门店列表排序。
---
## v6-fix11(2026-08-24)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已实现 | 国际化 i18n | 设备详情、下发指令、修改分组/房号、激活码管理、用户管理、公司介绍等页面接入英文翻译 |
| ✅ 已修复 | 编译 | 修复 8 个 analyze error(i10n 重复定义、store_list 误用 `t.normal`/`t.disabled`、device_list 漏传参、user_page 漏 `t` 声明) |
---
## v6-fix10(2026-08-24)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已实现 | 门店编辑-界面配置 | 按设备分组展示已启用/添加的主题(接入 `/theme/page/org/{orgId}/photos`,ThemeGroup→ThemePage[]) |
| ✅ 已修复 | 设备列表 | `Device.fromJson` 兼容 `deviceName`/`ipAddress`/`deviceIp` 等字段,正确回显设备名、房号、MAC、IP |
---
## v6-fix9(2026-08-24)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已实现 | 国际化 i18n | 底部导航、首页、设备列表、门店列表、管理入口、设置页接入英文翻译(首批) |
---
## v6-fix8(2026-08-24)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已修复 | 门店详情 | 联系电话/激活码/到期时间/创建时间字段空白 → 调 `/sys/org/listByLoginOrg` 回填 |
| ✅ 已修复 | 扫码 | 路由冲突(`scan` 在 `:id` 后导致被当 deviceId)→ 路由前置 |
| ✅ 已修复 | 管理页 | 移除「门店配置」入口(后端 `/device/storeConfig/{orgId}` 500) |
---
## v6-fix7(2026-08-24)
| 类型 | 模块 | 内容 |
|------|------|------|
| ✅ 已修复 | 登录-记住密码 | `CredentialStorage` 由 `Directory.current`(Android 不可写)迁移到 `shared_preferences`,记住账号密码 + 免登录恢复均生效 |
---
## 历史(本会话早段)
| 版本 | 类型 | 模块 | 内容 |
|------|------|------|------|
| v6-fix6 | ✅ 已修复 | 门店编辑 | 移除扫码绑定按钮/启动图Logo区块;主题皮肤改接 web 模板;所属组织下拉过滤+透传 parentId |
| v6-fix5 | ✅ 已修复 | 登录灰块 | `localizationsDelegates` 漏默认 delegate 致 TextField 崩溃 → 补全三个默认 delegate |
| v6-fix3 | ✅ 已实现 | 排障 | `ErrorWidget.builder` 红色错误页 + DIAG 横幅,替代 release 灰屏 |
---
## ⏳ 已知待办 / 未改项
- ❌ **门店配置** 功能:后端 `/device/storeConfig/{orgId}` 持续 500,前端已移除入口并给友好提示,需后端修复后恢复。
- ⏳ 门店编辑「界面配置」目前为**展示型**(显示分组/主题/激活主题),移动端切换激活主题需后端 `/theme/page/active` 支持,可后续迭代。
- ⏳ 切换英文后如仍有个别深层弹窗/页面为中文,多为未接 i18n 的入口,截图可继续补。
---
## 交付物索引
| 版本 | APK 路径 | 大小 |
|------|----------|------|
| v6-fix35 | `E:/Project/ops-console/ops-console-release-v6-fix35.apk` | 33.5MB |
| v6-fix34 | `E:/Project/ops-console/ops-console-release-v6-fix34.apk` | 33.5MB |
| v6-fix33 | `E:/Project/ops-console/ops-console-release-v6-fix33.apk` | 33.5MB |
| v6-fix32 | `E:/Project/ops-console/ops-console-release-v6-fix32.apk` | 33.5MB |
| v6-fix31 | `E:/Project/ops-console/ops-console-release-v6-fix31.apk` | 33.5MB |
| v6-fix30 | `E:/Project/ops-console/ops-console-release-v6-fix30.apk` | 33.5MB |
| v6-fix12 | `E:/Project/ops-console/ops-console-release-v6-fix12.apk` | 33.4MB |
| v6-fix11 | `E:/Project/ops-console/ops-console-release-v6-fix11.apk` | 35.0MB |
| v6-fix7~10 | `E:/Project/ops-console/build/app/outputs/flutter-apk/app-release.apk` | 33.x MB |

216
README.md Normal file
View File

@@ -0,0 +1,216 @@
# 运维管理台(Ops Console)
设备/系统运维管理台移动端,覆盖 **Android / iOS / HarmonyOS (OpenHarmony)** 三端,共用同一套 `lib/` Dart 代码。
默认对接真实「爱视后台」(PHP/CodeIgniter),同时保留 mock 开关供无后端时演示。
## 技术栈
- **Flutter 3.22+**(Dart 3)
- **go_router** —— 声明式路由(登录重定向、底部导航多分支)
- **flutter_riverpod** —— 状态管理(登录态 / 设备与告警数据 / 主题模式)
- **dio** —— HTTP 客户端(Bearer 鉴权 + 统一信封解包 + 401 刷新重试;可选 mock 拦截器)
- **intl** —— 日期时间格式化
## API 配置(lib/utils/constants.dart)
| 常量 | 默认值 | 说明 |
| ------------ | --------------------- | ------------------------------------------------ |
| `useMock` | `false` | `false` 对接真实后端;`true` 由 MockInterceptor 本地返回示例数据 |
| `apiBaseUrl` | `http://58.220.83.58` | 真实后端 origin |
| `apiPrefix` | `/api` | 接口统一前缀;最终 URL = `apiBaseUrl + /api + path` |
## HTTP 通用规则
- **鉴权**:请求头 `Authorization: Bearer <token>`,不使用 Cookie。
- **Content-Type**:`application/json; charset=utf-8`。
- **防缓存**:GET 参数与 POST body 均自动附加 `t` = 毫秒时间戳。
- **统一响应**:`{ code:int, msg:string, data:T }`,成功码 `code ∈ {0, 200}`,
由 `ApiInterceptor` 自动解包(service 层拿到的即 `data`);非成功码抛出 `ApiException(code, msg)`。
- **401 处理**:业务 `code=401` 或 HTTP 401 时,自动用 refreshToken 调
`POST /api/sys/refresh {refreshToken}` 换新 token 并**原样重试一次**;
仍失败则清空登录态并跳转登录页(经 `ApiClient.onUnauthorized` → `authProvider` → 路由 redirect)。
## 真实接口清单
| 接口 | 方法 | 说明 |
| ------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `/api/sys/login` | POST | 登录,body `{username,password,captcha,uuid}` → `{token,expire,refreshToken,refreshExpire,orgId,orgType,superAdmin,roleNames}` |
| `/api/sys/refresh` | POST | 刷新令牌,body `{refreshToken}` → `{token,expire,refreshToken,refreshExpire}`(拦截器自动调用) |
| `/api/sys/logout` | POST | 登出 |
| `/api/sys/user/info` | GET | 当前用户信息(登录后自动拉取补充显示名) |
| `/api/device/info/list` | GET | 设备列表,params `page,limit,status,mac,deviceSn,deviceUid,groupId,storeOrgId`;响应 `{list,totalCount}` / `{page:{list,totalCount}}` / 直接数组三种形态均兼容 |
| `/api/device/info/info/{id}` | GET | 设备详情 |
| `/api/device/command/sendByMac` | POST | 按 MAC 下发指令,body `{mac,command,data}` |
| `/api/device/command/sendByOrg` | POST | 按组织批量下发指令,body `{orgId,command,data}` |
指令 `command` 取值(见 `DeviceCommand` 常量):`powerOff` / `reboot` / `screenshot` /
`themeUpdate` / `versionUpdate` / `marqueeUpdate` / `weatherUpdate` / `mediaZoneVol` /
`defaultVol` / `themeClear` / `cacheClear` / `storeExpire` / `storeDisable` /
`passwordUpdate` / `worldAd`。
**指令下发 UI 属下一轮迭代**:当前仅实现 `DeviceService.sendCommandByMac/sendCommandByOrg`
service 方法,界面未接入按钮。
## 告警推导(real 模式)
真实后端无 alerts 端点。real 模式下告警由设备列表推导:
`offline → critical`(设备离线)、`warning → warning`(设备指标异常),
在线设备映射为 `info` 级别但不产生告警条目。mock 模式仍使用内置示例告警。
## ⚠️ 权威构建路径(必读)
本仓库中的 `android/`、`ios/`、`ohos/` 原生壳是**手写的参考起点**,已包含可资源编译的最小配置
(图标、启动页、storyboard、媒体占位资源等)。但以下内容**只能由 Flutter 工具链生成,无法手写**:
| 生成物 | 所属平台 | 生成命令 |
| -------------------------------------------------------------------- | --------- | ------------------------------------------ |
| `gradlew` / `gradle-wrapper.jar` / `local.properties` | Android | `flutter create` |
| `Runner.xcodeproj` / `Generated.xcconfig` / `AppFrameworkInfo.plist` | iOS | `flutter create` |
| `hvigorfile.ts` / `oh_modules` 依赖链接 | HarmonyOS | `flutter create --platforms=ohos`(鸿蒙分支工具链) |
**因此首次构建前,必须在本机 Flutter 环境执行一次 `flutter create` 补齐工具链生成物:**
```bash
cd ops-console
# 一次性补齐三端原生壳的缺失文件(不会覆盖已存在的自定义文件)
flutter create --platforms=android,ios,ohos .
```
> 注意:
>
> 1. `flutter create` 只补缺、不覆盖已有文件,仓库中已定制的
> `android/app/build.gradle`(applicationId、JDK 17)、`AndroidManifest.xml`(应用名「运维管理台」)
> 会被保留。
> 2. HarmonyOS 端需使用 **Flutter OpenHarmony SDK**(`flutter-flutter` 鸿蒙分支)执行上述命令;
> 标准 Flutter SDK 不认识 `--platforms=ohos`。
## 环境要求
| 项目 | 要求 |
| ----------- | --------------------------------------------------------------- |
| Flutter SDK | 3.22 及以上(`flutter --version` 确认,Dart >= 3.4) |
| Android | Android Studio + Android SDK(API 21+),JDK 17 |
| iOS | macOS + Xcode 15+、CocoaPods |
| HarmonyOS | DevEco Studio + Flutter OpenHarmony SDK(`flutter-flutter` 鸿蒙分支) |
## 构建命令
```bash
# 0. 首次:补齐全端工具链生成物 + 安装依赖
flutter create --platforms=android,ios,ohos .
flutter pub get
```
### Android
```bash
flutter run # 连接设备或模拟器后直接运行
flutter build apk # 打包 APK(debug 签名,正式发布需配置签名)
```
### iOS(需 macOS + Xcode)
```bash
flutter pub get
cd ios && pod install && cd .. # 安装 CocoaPods 依赖
flutter run
flutter build ios
```
### HarmonyOS (OpenHarmony)
需使用 **Flutter OpenHarmony SDK**(鸿蒙分支的 flutter 工具链):
```bash
# 补齐/校验 ohos 平台工程
flutter create --platforms=ohos .
# 运行(连接鸿蒙设备/模拟器)
flutter run
# 构建 hap/app 包
flutter build ohos
```
也可在 DevEco Studio 中打开 `ohos/` 目录进行签名配置与构建(`build-profile.json5` 中的
`signingConfigs` 为空,需在 DevEco 中配置签名后才能出 release 包)。
## 目录结构
```
ops-console/
├── pubspec.yaml # 依赖声明
├── analysis_options.yaml # 静态分析规则(flutter_lints)
├── lib/
│ ├── main.dart # 入口:ProviderScope + MaterialApp.router
│ ├── app/
│ │ ├── theme.dart # 浅色/深色主题(主色科技蓝 #2E7DFF)
│ │ └── router.dart # go_router 路由表 + 未登录重定向
│ ├── utils/
│ │ ├── constants.dart # 全局常量(useMock / apiBaseUrl / mock 账号等)
│ │ └── formatters.dart # 时间/百分比/字节/运行时长格式化
│ ├── models/
│ │ ├── device.dart # Device + DeviceStatus + DeviceMetrics + DeviceCommand
│ │ ├── alert.dart # Alert + AlertLevel(critical/warning/info)
│ │ └── user.dart # User
│ ├── services/
│ │ ├── api_client.dart # Dio 单例 + ApiInterceptor(Bearer/t/解包/401重试)
│ │ │ # + MockInterceptor(useMock=true 时)+ TokenStore(内存态)
│ │ ├── auth_service.dart # 登录/登出/用户信息(真实接口)
│ │ └── device_service.dart # 设备列表/详情/指令/告警推导
│ ├── providers/
│ │ ├── auth_provider.dart # 登录态(authProvider,注册 onUnauthorized)
│ │ ├── devices_provider.dart # 设备/告警数据(devicesProvider 等)
│ │ └── theme_provider.dart # 主题模式(默认深色)
│ ├── widgets/
│ │ ├── status_chip.dart # 状态标签
│ │ ├── metric_card.dart # 指标卡片
│ │ ├── device_list_tile.dart # 设备列表项
│ │ └── app_bottom_nav.dart # 底部导航(设备/告警/设置)
│ └── screens/
│ ├── login/login_page.dart # 登录页
│ ├── home/device_list_page.dart # 首页:搜索 + 设备列表
│ ├── home/device_detail_page.dart # 设备详情:指标卡(real 模式显示 “—”)
│ ├── alerts/alerts_page.dart # 告警列表
│ └── profile/settings_page.dart # 设置:主题/语言占位/关于/退出
├── android/ # Android 壳(Gradle + Manifest + res 资源)
├── ios/ # iOS 壳(Runner + Flutter xcconfig + Podfile)
└── ohos/ # OpenHarmony 壳(stage 模型)
```
## 路由表
| 路径 | 页面 | 说明 |
| ------------- | ---- | ------------- |
| `/login` | 登录页 | 未登录时自动重定向到此 |
| `/` | 设备列表 | 底部导航第 1 个 tab |
| `/device/:id` | 设备详情 | 从设备列表进入 |
| `/alerts` | 告警中心 | 底部导航第 2 个 tab |
| `/settings` | 设置 | 底部导航第 3 个 tab |
## Mock 数据说明
- 将 `AppConstants.useMock` 改为 `true` 即进入演示模式:所有请求由
`MockInterceptor` 本地拦截,返回与真实后端一致的 `{code,msg,data}` 信封
(5 台设备含示例运行指标、5 条示例告警),不发起真实网络请求。
- mock 演示登录账号:`admin` / `123456`。
- 真实模式(默认)下请使用后端分配的账号登录。
## 主题
- 默认 **深色** 主题,主色科技蓝 `#2E7DFF`。
- 在「设置 → 主题模式」可切换 深色 / 浅色 / 跟随系统。
## 已知限制
1. **token 仅内存态**:为规避鸿蒙端插件兼容问题未引入 shared_preferences,
应用重启后需重新登录(持久化属后续迭代,需评估鸿蒙插件支持度)。
2. **验证码占位**:登录接口的 `captcha/uuid` 字段当前传空串(登录页 UI 无验证码输入,
属下一轮迭代);若后端强制校验验证码,需先补 UI。
3. **运行指标缺失**:真实后端不提供 CPU/内存/磁盘/网络指标,real 模式设备详情页
指标卡显示 “—”;`useMock=true` 时显示示例指标。
4. **指令下发 UI 未接入**:service 层已实现,界面按钮属下一轮迭代。
5. **图标为占位资源**(Android 自适应图标 / iOS 空 AppIcon / ohos 1x1 PNG),正式发布前请替换。
6. `android/gradlew`、`ios/Runner.xcodeproj`、`ohos/hvigorfile.ts` 等工具链生成物未入库,
按上文「权威构建路径」执行 `flutter create` 后自动生成。

18
analysis_options.yaml Normal file
View File

@@ -0,0 +1,18 @@
# 静态分析配置:基于官方推荐规则集 flutter_lints
include: package:flutter_lints/flutter.yaml
analyzer:
language:
strict-casts: true
strict-inference: true
errors:
# 示例工程中将部分提示降级,避免阻塞构建
dead_code: info
unused_import: warning
linter:
rules:
- prefer_single_quotes
- always_declare_return_types
- avoid_print
- require_trailing_commas

13
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

55
android/app/build.gradle Normal file
View File

@@ -0,0 +1,55 @@
plugins {
id "com.android.application"
id "kotlin-android"
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id "dev.flutter.flutter-gradle-plugin"
}
// The `flutter` Gradle extension is shadowed by a stale convention property on Gradle 8.3,
// so we resolve it explicitly instead of relying on the `project.flutter` shorthand.
def flutterExt = project.extensions.getByName("flutter")
android {
namespace = "com.tongshi.cloudhome"
compileSdk = flutterExt.compileSdkVersion
ndkVersion = flutterExt.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.tongshi.cloudhome"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutterExt.minSdkVersion
targetSdk = flutterExt.targetSdkVersion
versionCode = flutterExt.versionCode
versionName = flutterExt.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.debug
}
}
// 规避 Windows 下 packageReleaseResources 删除目录时文件被占用导致构建失败。
tasks.configureEach { task ->
if (task.name == 'packageReleaseResources') {
task.doFirst {
delete "${buildDir}/intermediates/incremental/release/packageReleaseResources"
}
}
}
}
flutterExt.source = "../.."

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,51 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- release 只合并 main,必须显式声明网络权限;后端为 HTTP 需放开明文 -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- 扫码功能需要相机权限(mobile_scanner) -->
<uses-permission android:name="android.permission.CAMERA" />
<application
android:label="云主页运营助手"
android:description="@string/app_description"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@@ -0,0 +1,5 @@
package com.tongshi.cloudhome
import io.flutter.embedding.android.FlutterActivity
class MainActivity: FlutterActivity()

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
<!-- Edge-to-Edge:允许 Flutter 绘制到状态栏/导航栏背后,避免黑边。 -->
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 应用副标题(APP 说明),在系统「应用信息」中展示 -->
<string name="app_description">Cloud Homepage Operations</string>
</resources>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
<!-- Edge-to-Edge:允许 Flutter 绘制到状态栏/导航栏背后,避免黑边。 -->
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
</resources>

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

20
android/build.gradle Normal file
View File

@@ -0,0 +1,20 @@
allprojects {
repositories {
// 本机 maven.google.com / mavenCentral 不可达,仅保留阿里云镜像,避免解析依赖时卡死。
maven { url 'https://maven.aliyun.com/repository/google' }
maven { url 'https://maven.aliyun.com/repository/central' }
maven { url 'https://maven.aliyun.com/repository/public' }
}
}
rootProject.buildDir = "../build"
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register("clean", Delete) {
delete rootProject.buildDir
}

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.3-all.zip

160
android/gradlew vendored Normal file
View File

@@ -0,0 +1,160 @@
#!/usr/bin/env bash
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn ( ) {
echo "$*"
}
die ( ) {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
esac
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
function splitJvmOpts() {
JVM_OPTS=("$@")
}
eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"

90
android/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,90 @@
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windowz variants
if not "%OS%" == "Windows_NT" goto win9xME_args
if "%@eval[2+2]" == "4" goto 4NT_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
goto execute
:4NT_args
@rem Get arguments from the 4NT Shell from JP Software
set CMD_LINE_ARGS=%$
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

29
android/settings.gradle Normal file
View File

@@ -0,0 +1,29 @@
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}()
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
// 本机 maven.google.com / mavenCentral / gradlePluginPortal 不可达,仅保留阿里云镜像,避免解析依赖时卡死。
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
maven { url 'https://maven.aliyun.com/repository/google' }
maven { url 'https://maven.aliyun.com/repository/central' }
maven { url 'https://maven.aliyun.com/repository/public' }
}
}
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.1.0" apply false
id "org.jetbrains.kotlin.android" version "1.8.22" apply false
}
rootProject.name = "CloudHome-Ops"
include ":app"

File diff suppressed because it is too large Load Diff

BIN
assets/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

@@ -0,0 +1,87 @@
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 : 扫码结果

270
docs/intro_v38_design.md Normal file
View File

@@ -0,0 +1,270 @@
# 公司介绍(Company Intro)模块增强 — 架构设计文档
**版本演进**:`v6-fix37` → `v6-fix38`
**模块**:`lib/screens/admin/intro/*`(公司介绍 / 类型编辑 / 内容编辑)
**后端**:`https://ips-test.ainetv.com`(测试),合同参考 `screen-front/src/api/companyIntro.ts`
**目标**:多门店筛选、表单字段与 Web 后台对齐、内容图片上传预览、兼容既有代码风格。
---
## 1. Scope & Assumptions(范围与假设)
### 1.1 范围(In Scope)
- 大类列表页:**门店多选筛选**(客户端按 `orgId` 过滤)。
- **类型编辑(Category)** 表单对齐 Web:新增 `orgId/orgName` 选择;移除 Web 不存在的 `status` 字段。
- **内容编辑(Subcategory / Detail)** 表单对齐 Web:补齐 `sortOrder`、修正 `status` 语义、Subcategory 补齐可选的 `categoryType`。
- 内容编辑支持 **图片上传 + 本地预览 + 回写 `mediaUrl`**。
- `save` / `update` 按 `id` 是否存在分流,对齐 Web 后台。
### 1.2 假设(Assumptions)
- A1. 分类列表已全量加载(`getIntroCategoryList(limit: 200)`),客户端多选过滤足够;后端 list 接口仅支持单 `orgId`,**不支持多门店服务端过滤**,故采用客户端过滤。
- A2. 门店数据源复用既有 `OrgService.getStoreListByLoginOrg()`(返回 `List<StoreInfo>`,含 `orgId`/`name`),替代当前从分类推导 `_orgMap` 的脆弱写法。
- A3. 图片上传后端端点假定为 `POST /sys/upload`(表单字段名 `file`,返回 URL 字符串或 `{url}`)。**确切端点与返回结构待后端确认**(见 §9 风险)。若端点不可用,保留手动 `mediaUrl` 输入作为兜底。
- A4. 描述(`description`)为纯文本字段,不做富文本图片内嵌;"插入图片" 等价于上传并写入 `mediaUrl` + `mediaType='image'`(与 Web 模型一致)。
- A5. 不改动后端、不改 `pubspec` 依赖(`image_picker`、`dio` 已具备)。
- A6. 既有 `ApiInterceptor` 已统一解包 `{code,msg,data}` 信封并附带 Bearer 令牌,新上传请求复用 `ApiClient.instance.dio` 即可,无需额外鉴权处理。
---
## 2. File Changes List(文件变更清单)
| 相对路径 | 变更类型 | 说明 |
|---|---|---|
| `lib/utils/i10n.dart` | 修改 | 新增门店筛选 / 表单字段 / 上传相关 i18n 键(见 §8)。 |
| `lib/models/intro_category.dart` | 修改 | 修正 `status` 语义(= 后端原值,`1`=启用/`0`=禁用),删除 `_parseStatus` 反转逻辑;模型字段本身已含 `orgId/orgName/sort`,无需新增字段。 |
| `lib/services/intro_service.dart` | 修改 | `saveIntroCategory / saveIntroSubcategory / saveIntroDetail` 按 `id` 是否存在分流 `/save`↔`/update`(对齐 Web)。 |
| `lib/services/upload_service.dart` | **新增** | `UploadService.uploadImage(XFile)`:Dio `FormData` 多部件上传,返回 URL 字符串。 |
| `lib/screens/admin/intro/intro_category_page.dart` | 修改 | (a) 门店筛选改为 `FilterChip` 多选;(b) 大类表单加门店下拉、移除 `status`;(c) 小类/详情表单加 `sortOrder`、`status` 对齐、图片上传+预览;(d) `initState` 预载门店列表。 |
| `lib/services/org_service.dart` | 复用(不改) | 复用 `getStoreListByLoginOrg()` 提供门店列表。 |
| `lib/models/store_info.dart` | 复用(不改) | 复用 `StoreInfo.orgId` / `StoreInfo.name`。 |
> 变更集中在 1 个页面 + 2 个 service + 1 个 model + 1 个 i18n,符合"最小侵入、兼容既有风格"。
---
## 3. Data Model / Field Changes(数据模型与字段变更)
### 3.1 后端字段 vs 当前 Flutter 缺口对照
| 实体 | 后端字段(companyIntro.ts) | 当前 Flutter 缺口 |
|---|---|---|
| **Category** | `id, orgId, orgName, categoryType, name, createTime, updateTime` | 表单**缺 `orgId/orgName` 选择**;表单**多出 Web 不存在的 `status`** |
| **Subcategory** | `id, categoryId, categoryType, mediaType, mediaUrl, title, description, status, sortOrder` | 表单**缺 `sortOrder`**;`status` **语义反转**;**缺 `categoryType`**(可选);**无图片上传** |
| **Detail** | `id, subcategoryId, subcategoryTitle, mediaType, mediaUrl, title, description, status, sortOrder` | 表单**缺 `sortOrder`**;`status` **语义反转**;**无图片上传** |
### 3.2 `IntroCategory` / `IntroSubcategory` / `IntroDetail` 模型变更
- **`status` 语义统一为后端原值**:`1`=启用(正常)、`0`=禁用。
- 模型 `_parseStatus(dynamic)` 改为:`int.tryParse(value?.toString() ?? '') ?? 1`(缺省启用,对齐 Web `?? 1`);**删除** "1→0 / 0→1" 反转映射。
- `toJson()` 输出 `status` 原值,保存时**不再反转**。
- **`sort` 字段**(已存在,映射 `sortOrder`)保持不变;仅需在表单中暴露编辑(见 §5)。
- **`orgId` / `orgName`**(已存在)保持不变;仅需在 Category 表单中可编辑并随保存上送。
- 模型不再需要新增字段;`categoryType`(Subcategory 可选)可由表单 `TextEditingController` 直接读取父级大类值,无需模型新增。
### 3.3 新增 `UploadService`(伪代码,非实现)
```
class UploadService {
final Dio _dio; // = ApiClient.instance.dio
Future<String> uploadImage(XFile file) async {
// FormData: { 'file': MultipartFile.fromFile(file.path, filename: basename) }
// POST /sys/upload (复用 ApiInterceptor 解包)
// 返回 data 为 URL 字符串,或 {url: '...'} 时取 url
}
}
```
---
## 4. API Call Changes(接口调用变更)
### 4.1 `IntroService` 保存分流(对齐 Web `save* / update*`)
| 方法 | 当前 | 目标 |
|---|---|---|
| `saveIntroCategory(data)` | 始终 `POST /company/introCategory/save` | `id` 非空 → `…/update`,否则 `…/save` |
| `saveIntroSubcategory(data)` | 始终 `…/save` | 同上(含 `categoryId, categoryType?, mediaType, mediaUrl, title, description, status, sortOrder`) |
| `saveIntroDetail(data)` | 始终 `…/save` | 同上(含 `subcategoryId, mediaType, mediaUrl, title, description, status, sortOrder`) |
> 端点、`list/info/delete` 不变;`list` 仍客户端过滤(见 A1)。
### 4.2 新增上传调用
- `UploadService.uploadImage(XFile)` → `POST /sys/upload`(多部件)。(端点待确认,见 §9)
### 4.3 门店列表(复用,不改写)
- `OrgService().getStoreListByLoginOrg()` → `GET /sys/store/listByLoginOrg`,用于筛选 chips 与 Category 表单门店下拉。
---
## 5. UI/UX Changes(界面与交互变更)
### 5.1 大类列表 — 多门店筛选
- 替换现有单选 `DropdownButton<String?>` 为 **`Wrap` + `FilterChip` 多选**:
- 一项 `All`("全部门店");其余每项一个门店,`selected` = `_selectedOrgIds.contains(orgId)`。
- 空选集合 = 全部(与现有"全部门店"语义一致)。
- 状态:`List<String> _selectedOrgIds = []`。
- `_applyOrgFilter()`:`_selectedOrgIds.isEmpty ? _allCategories : _allCategories.where((c) => _selectedOrgIds.contains(c.orgId)).toList()`。
- 门店来源:`initState` 中 `OrgService().getStoreListByLoginOrg()` 构建 `Map<orgId, name>`(替代从分类推导,更完整)。
### 5.2 类型编辑(Category)表单
- **新增** 门店下拉 `DropdownButtonFormField<String>`(必填):选项来自门店列表,`value=orgId`,联动 `orgName`。
- **保留** `name`、`categoryType`(类型编码,纯字符串无中文)。
- **移除** `status` 下拉(Web 大类无 `status` 字段)。
- 保存 body:`{id?, orgId, orgName, categoryType, name}`(无 status/sort)。
### 5.3 小类(Subcategory)/ 详情(Detail)表单
- **新增 `sortOrder`**:`TextField`(`keyboardType: TextInputType.number`),上送 `sortOrder`。
- **`status` 对齐**:下拉项 `1`=启用/正常、`0`=禁用,默认 `editing?.status ?? 1`;保存上送原值(不再反转)。
- **小类新增可选 `categoryType`**:`TextField`,预填父大类 `categoryType`(Web 允许与大类一致)。
- **媒体区改造(图片上传)**:
- 保留 `mediaType` 下拉(image/video)与 `mediaUrl` 手动输入(兜底)。
- 新增「上传图片」按钮:`ImagePicker.pickImage(source: gallery)` → 本地 `Image.file` 预览 → 调 `UploadService.uploadImage` → 成功后回填 `mediaUrlCtrl.text=url` 且 `mediaType='image'`,并切换为 `Image.network(url)` 预览。
- 上传中显示 `CircularProgressIndicator` + `introUploading` 文案;失败 `SnackBar(introUploadFailed)`。
- 列表 tile 的 `status` Chip 文案改为 `status==1 ? introEnabled : introDisabled`。
### 5.4 交互一致性
- 继续复用现有 `showModalBottomSheet` + `StatefulBuilder` 表单骨架、`PopupMenuButton` 编辑/删除、`ScaffoldMessenger` 提示,保持与既有代码风格一致。
---
## 6. Image Upload Implementation Approach(图片上传实现方案)
1. **选型**:复用既有 `image_picker`(相册选择)+ `dio`(`FormData` 多部件)。**不引入新依赖**。
2. **服务层**:新增 `UploadService`(`lib/services/upload_service.dart`),封装 `POST /sys/upload`,复用 `ApiClient.instance.dio`(自动带令牌、解包信封、401 刷新)。
3. **预览**:
- 选中后本地即时预览:`Image.file(File(xFile.path))`(需 `dart:io`)。
- 上传成功回填 URL 后:`Image.network(url)` 预览(与现有 tile 预览一致)。
4. **存储**:上传返回 URL 字符串写入 `mediaUrlCtrl` → 随 `save*` 上送 `mediaUrl` + `mediaType='image'`;不本地持久化原文件路径(与 Web 模型一致)。
5. **兜底**:保留 `mediaUrl` 手动输入,支持直接填视频/已托管图片地址;上传失败不阻断保存(可后补)。
6. **文件名**:用 `xFile.path.split(Platform.pathSeparator).last` 取 basename,避免引入 `path` 包。
> 注意:上传端点 `/sys/upload` 为假设,返回结构(字符串 vs `{url}`)需与后端确认;`UploadService` 两种形态均兼容。
---
## 7. Incremental Task List(增量任务清单,按实现顺序)
| ID | 任务 | 涉及文件 | 依赖 | 优先级 |
|---|---|---|---|---|
| **T1** | **i18n 键新增**:门店筛选/表单字段/上传文案(§8 全部键) | `lib/utils/i10n.dart` | — | P0 |
| **T2** | **模型 status 语义修正**:`_parseStatus` 改为后端原值(`1`=启用),删除反转;`toJson` 不再反转 | `lib/models/intro_category.dart` | T1 | P0 |
| **T3** | **新增 `UploadService.uploadImage`**(Dio 多部件,兼容字符串/`{url}` 返回) | `lib/services/upload_service.dart`(新) | — | P1 |
| **T4** | **`IntroService` 保存分流**:三个 `save*` 按 `id` 是否存在走 `/save`↔`/update` | `lib/services/intro_service.dart` | — | P0 |
| **T5** | **多门店筛选 UI**:`FilterChip` 多选 + `initState` 预载 `getStoreListByLoginOrg` 构建门店 map + `_applyOrgFilter` 改为多值过滤 | `lib/screens/admin/intro/intro_category_page.dart` | T1 | P0 |
| **T6** | **大类表单对齐**:加门店下拉(必填,`orgId/orgName` 联动)、移除 `status` | `lib/screens/admin/intro/intro_category_page.dart` | T2, T4, T5 | P0 |
| **T7** | **小类表单对齐**:加 `sortOrder`、`categoryType`(预填)、`status` 对齐(1=启用)、图片上传+预览 | `lib/screens/admin/intro/intro_category_page.dart` | T2, T3, T4 | P0 |
| **T8** | **详情表单对齐**:加 `sortOrder`、`status` 对齐、图片上传+预览、tile 状态文案改用 `introEnabled/introDisabled` | `lib/screens/admin/intro/intro_category_page.dart` | T2, T3, T4 | P0 |
| **T9** | **联调与冒烟**:`flutter analyze` + 真机/模拟器跑通多店筛选、增改、图片上传,`v6-fix38` 构建 | 全部 | T1–T8 | P1 |
> 依赖示意:`T1→T2→{T6,T7,T8}`;`T4→{T6,T7,T8}`;`T3→{T7,T8}`;`T5→T6`。
---
## 8. i18n Key Additions(需新增的 i18n 键)
新增于 `lib/utils/i10n.dart` 的「公司介绍」分区(中 / 英):
| Key | 中文 | English | 用途 |
|---|---|---|---|
| `introFilterStores` | 筛选门店 | Filter Stores | 多选 chip 区标题 |
| `introSelectStoresHint` | 可多选门店 | Select multiple stores | 空态提示 |
| `introAllStores` | *已存在* | *已存在* | 全部门店(复用) |
| `introCategoryStore` | 所属门店 | Store | 大类表单门店下拉 label |
| `introStoreRequired` | 请选择所属门店 | Please select a store | 门店必填校验 |
| `introSortOrder` | 排序号 | Sort Order | 小类/详情 `sortOrder` label |
| `introEnabled` | 启用 | Enabled | `status==1` 显示 |
| `introDisabled` | 禁用 | Disabled | `status==0` 显示 |
| `introUploadImage` | 上传图片 | Upload Image | 图片上传按钮 |
| `introPickImage` | 选择图片 | Pick Image | 选择前占位按钮 |
| `introImagePreview` | 图片预览 | Image Preview | 预览区标题 |
| `introUploading` | 上传中… | Uploading… | 上传 loading 文案 |
| `introUploadFailed` | 图片上传失败 | Image upload failed | 上传失败提示 |
| `introInsertImage` | 插入图片 | Insert Image | (可选)上传并写入 mediaUrl 按钮 |
> 注:`statusNormal`/`statusDisabled` 已存在但语义与 Web 相反,改用新增的 `introEnabled`/`introDisabled` 以避免歧义;如团队倾向复用既有键,可二选一,但需同步修正显示判断。
---
## 9. Potential Risks / Open Questions(风险与待确认项)
1. **上传端点未知(高)**:`/sys/upload` 为假设;后端实际路径、字段名(`file`?)、返回结构(字符串 / `{url}` / `{data}`)需后端确认。`UploadService` 已兼容常见形态,但仍可能需微调。
2. **`status` 反转回归风险(中)**:当前模型反转 + 表单再反转"巧合正确",修改后需重点回归小类/详情的显示与保存往返,确保 `1`=启用、`0`=禁用。
3. **大类 `status` 移除(中)**:现有 Flutter 大类模型含 `status` 且仅列表展示;移除表单字段后,模型字段保留(防御性默认 0/1 不影响),但需确认后端 `save` 忽略该字段或忽略 null。
4. **门店列表权限(低)**:`getStoreListByLoginOrg` 返回当前登录组织下门店;若运维账号可见全部门店则符合预期,否则筛选范围受登录态限制(与现有门店管理一致)。
5. **分类超过 200 条(低)**:列表 `limit:200` 上限下,多选过滤可能漏掉未加载分类;属既有限制,v6-fix38 暂不处理分页服务端过滤(后端不支持多 `orgId`)。
6. **`categoryType` 必填性(低)**:Web 小类 `categoryType` 为可选,表单预填父大类值即可;若后端要求必填需补校验。
7. **鸿蒙端 `image_picker`(低)**:`image_picker` 在 HarmonyOS 需对应插件适配;若不可用,图片上传在该端降级为手动 `mediaUrl` 输入(兜底已设计)。
8. **视频上传(低)**:本期仅做图片上传;视频仍走 `mediaUrl` 手动输入(与 Web 一致),不做视频文件上传。
---
## 附录:关键关系与流程(Mermaid)
### A.1 类 / 服务关系
```mermaid
classDiagram
class IntroCategoryPage {
+List~String~ _selectedOrgIds
+Map~String,String~ _orgMap
+_buildOrgFilter() FilterChip
+_showCategoryForm() 加门店/去status
+_showSubcategoryForm() 加sortOrder+上传
+_showDetailForm() 加sortOrder+上传
}
class IntroService {
+saveIntroCategory(data) /save|/update
+saveIntroSubcategory(data) /save|/update
+saveIntroDetail(data) /save|/update
}
class UploadService {
+uploadImage(XFile) String
}
class OrgService {
+getStoreListByLoginOrg() List~StoreInfo~
}
class StoreInfo {
+String orgId
+String name
}
class IntroCategory {
+String orgId
+String orgName
+int status // =后端原值 1启用/0禁用
}
class IntroSubcategory {
+int sort // sortOrder
+int status
}
class IntroDetail {
+int sort
+int status
}
IntroCategoryPage ..> IntroService : 保存分流
IntroCategoryPage ..> UploadService : 图片上传
IntroCategoryPage ..> OrgService : 门店列表
OrgService ..> StoreInfo
IntroService ..> IntroCategory
IntroService ..> IntroSubcategory
IntroService ..> IntroDetail
```
### A.2 小类保存 + 图片上传时序
```mermaid
sequenceDiagram
participant U as 用户
participant P as IntroCategoryPage
participant UP as UploadService
participant S as IntroService
participant B as 后端
U->>P: 打开小类表单,选图「上传图片」
P->>P: ImagePicker.pickImage → 本地预览
P->>UP: uploadImage(xFile)
UP->>B: POST /sys/upload (FormData file)
B-->>UP: {code,data: url}
UP-->>P: url
P->>P: mediaUrlCtrl=url, mediaType='image', 网络预览
U->>P: 填 sortOrder/status/title → 保存
P->>S: saveIntroSubcategory({id?,categoryId,...,sortOrder,status,mediaUrl})
S->>B: POST /update (有id) 或 /save
B-->>S: {code,data}
S-->>P: 成功
P->>P: 关闭表单 + 刷新列表
```

View File

@@ -0,0 +1,70 @@
%% 主流程:门店编辑(省市区+主题+品牌)
sequenceDiagram
actor U as 用户
participant S as _StoreFormSheet
participant RP as RegionPicker
participant RD as RegionData
participant TG as StoreThemeGrid
participant TP as StoreThemePreview
participant BE as BrandingEditor
participant IP as image_picker
participant OS as OrgService
participant LC as StoreLocalCache
U->>S: 打开新增/编辑门店
S->>RD: load() (懒加载 province.json)
U->>RP: 点「地址」
RP->>RD: citiesOf/ districtsOf
RP-->>S: provinceCode/cityCode/districtCode + text
U->>TG: 点「主题皮肤」
TG->>TP: 选 themeId → 预览模拟头部
TG-->>S: themeId
U->>BE: 点「启动图/Logo」
BE->>IP: 选图
IP-->>BE: XFile.path
BE-->>S: splashUrl/logoUrl (本地路径)
U->>S: 点「保存」
S->>OS: saveStore(data) [占位]
OS->>LC: saveTheme/saveBranding/saveRegion
OS-->>S: 完成(未真实上报 TODO)
S-->>U: 关闭 sheet, 刷新列表
%% 设备扫码:查询模式
sequenceDiagram
actor U as 用户
participant DL as device_list_page
participant SP as device_scan_page
participant MS as mobile_scanner
participant DS as DeviceService
U->>DL: AppBar 扫码图标
DL->>SP: go('/device/scan?mode=query')
SP->>MS: 实时扫码 / 相册识别
MS-->>SP: scanPayload (mac/sn)
SP->>DS: fetchDevices(mac: payload)
DS-->>SP: Device?
alt 命中
SP-->>U: go('/device/:id') 跳详情
else 未命中
SP-->>U: 提示「未找到设备」
end
%% 设备扫码:绑定模式
sequenceDiagram
actor U as 用户
participant DD as device_detail_page
participant SP as device_scan_page
participant MS as mobile_scanner
participant DS as DeviceService
participant LC as StoreLocalCache
U->>DD: 「扫二维码绑定」
DD->>SP: go('/device/scan?mode=bind&deviceId=xxx')
SP->>MS: 扫码得门店/分组码
MS-->>SP: scanPayload
SP->>U: 弹窗确认绑定门店/分组
U->>SP: 确认
SP->>DS: bindDeviceByScan(payload, storeId, groupId) [占位]
DS->>LC: updateDevice(groupId/storeName)
DS-->>SP: 完成(真实 POST TODO)
SP-->>DD: 返回, 展示更新归属

292
docs/system_design.md Normal file
View File

@@ -0,0 +1,292 @@
# 云端管理端 — 增量架构设计(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 改动
```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)
```mermaid
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 新增字段**(全部可空,向后兼容既有后端响应):
```dart
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)**:
```dart
// 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 |
### 任务依赖图
```mermaid
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 顺序落地即可。

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@@ -0,0 +1,14 @@
// This is a generated file; do not edit or check into version control.
FLUTTER_ROOT=E:\Project\flutter
FLUTTER_APPLICATION_PATH=E:\Project\ops-console
COCOAPODS_PARALLEL_CODE_SIGN=true
FLUTTER_TARGET=lib\main.dart
FLUTTER_BUILD_DIR=build
FLUTTER_BUILD_NAME=1.0.2
FLUTTER_BUILD_NUMBER=4
EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386
EXCLUDED_ARCHS[sdk=iphoneos*]=armv7
DART_OBFUSCATION=false
TRACK_WIDGET_CREATION=true
TREE_SHAKE_ICONS=false
PACKAGE_CONFIG=.dart_tool/package_config.json

View File

@@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@@ -0,0 +1,13 @@
#!/bin/sh
# This is a generated file; do not edit or check into version control.
export "FLUTTER_ROOT=E:\Project\flutter"
export "FLUTTER_APPLICATION_PATH=E:\Project\ops-console"
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
export "FLUTTER_TARGET=lib\main.dart"
export "FLUTTER_BUILD_DIR=build"
export "FLUTTER_BUILD_NAME=1.0.2"
export "FLUTTER_BUILD_NUMBER=4"
export "DART_OBFUSCATION=false"
export "TRACK_WIDGET_CREATION=true"
export "TREE_SHAKE_ICONS=false"
export "PACKAGE_CONFIG=.dart_tool/package_config.json"

41
ios/Podfile Normal file
View File

@@ -0,0 +1,41 @@
# 全局平台版本,避免与插件的最低版本冲突
platform :ios, '12.0'
# CocoaPods 统计上报会拖慢 CI 构建
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do
use_frameworks!
use_modular_headers!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end

View File

@@ -0,0 +1,13 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

View File

@@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,20 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="22505" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22504"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="0.1803921569" green="0.4901960784" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-3s-v16"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="22505" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22504"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="55" y="375"/>
</scene>
</scenes>
</document>

View File

@@ -0,0 +1,19 @@
//
// Generated file. Do not edit.
//
// clang-format off
#ifndef GeneratedPluginRegistrant_h
#define GeneratedPluginRegistrant_h
#import <Flutter/Flutter.h>
NS_ASSUME_NONNULL_BEGIN
@interface GeneratedPluginRegistrant : NSObject
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry;
@end
NS_ASSUME_NONNULL_END
#endif /* GeneratedPluginRegistrant_h */

View File

@@ -0,0 +1,63 @@
//
// Generated file. Do not edit.
//
// clang-format off
#import "GeneratedPluginRegistrant.h"
#if __has_include(<file_picker/FilePickerPlugin.h>)
#import <file_picker/FilePickerPlugin.h>
#else
@import file_picker;
#endif
#if __has_include(<image_picker_ios/FLTImagePickerPlugin.h>)
#import <image_picker_ios/FLTImagePickerPlugin.h>
#else
@import image_picker_ios;
#endif
#if __has_include(<mobile_scanner/MobileScannerPlugin.h>)
#import <mobile_scanner/MobileScannerPlugin.h>
#else
@import mobile_scanner;
#endif
#if __has_include(<package_info_plus/FPPPackageInfoPlusPlugin.h>)
#import <package_info_plus/FPPPackageInfoPlusPlugin.h>
#else
@import package_info_plus;
#endif
#if __has_include(<permission_handler_apple/PermissionHandlerPlugin.h>)
#import <permission_handler_apple/PermissionHandlerPlugin.h>
#else
@import permission_handler_apple;
#endif
#if __has_include(<shared_preferences_foundation/SharedPreferencesPlugin.h>)
#import <shared_preferences_foundation/SharedPreferencesPlugin.h>
#else
@import shared_preferences_foundation;
#endif
#if __has_include(<url_launcher_ios/URLLauncherPlugin.h>)
#import <url_launcher_ios/URLLauncherPlugin.h>
#else
@import url_launcher_ios;
#endif
@implementation GeneratedPluginRegistrant
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
[FilePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FilePickerPlugin"]];
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
[MobileScannerPlugin registerWithRegistrar:[registry registrarForPlugin:@"MobileScannerPlugin"]];
[FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]];
[PermissionHandlerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PermissionHandlerPlugin"]];
[SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
[URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]];
}
@end

67
ios/Runner/Info.plist Normal file
View File

@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>云主页运营助手</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>cloudhome_ops</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<!-- 真实后端为纯 HTTP,仅按域名/IP 放开明文,不做全局 NSAllowsArbitraryLoads -->
<key>NSExceptionDomains</key>
<dict>
<key>58.220.83.58</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
<key>NSBonjourServices</key>
<array>
<string>_dartVmService._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>调试模式下用于发现 Dart VM 服务(热重载)</string>
</dict>
</plist>

355
lib/app/router.dart Normal file
View File

@@ -0,0 +1,355 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/auth_provider.dart';
import '../utils/permissions.dart';
import '../screens/admin/admin_hub_page.dart';
import '../screens/admin/app/app_version_page.dart';
import '../screens/admin/app/tv_app_page.dart';
import '../screens/admin/channel/channel_management_page.dart';
import '../screens/admin/intro/intro_category_page.dart';
import '../screens/admin/intro/intro_store_content_page.dart';
import '../screens/admin/intro/intro_store_list_page.dart';
import '../screens/admin/marquee/marquee_management_page.dart';
import '../screens/admin/media/media_list_page.dart';
import '../screens/admin/media/media_rank_page.dart';
import '../screens/admin/sys/config_page.dart';
import '../screens/admin/sys/log_page.dart';
import '../screens/admin/sys/menu_page.dart';
import '../screens/admin/sys/role_page.dart';
import '../screens/admin/sys/schedule_page.dart';
import '../screens/admin/sys/user_page.dart';
import '../screens/device/device_activation_page.dart';
import '../screens/device/device_group_page.dart';
import '../screens/device/device_scan_page.dart';
import '../screens/device/store_config_page.dart';
import '../screens/home/device_detail_page.dart';
import '../screens/home/device_list_page.dart';
import '../screens/home/home_page.dart';
import '../screens/login/login_page.dart';
import '../models/store_info.dart';
import '../screens/profile/settings_page.dart';
import '../screens/store/org_tree_page.dart';
import '../screens/store/store_detail_page.dart';
import '../screens/store/store_list_page.dart';
import '../widgets/app_bottom_nav.dart';
/// 全局路由配置。
///
/// 路由表:
/// - `/login` 登录页(未登录时重定向到此)
/// - `/` 首页仪表盘(底部导航第 1 个 tab)
/// - `/device` 设备列表(底部导航第 2 个 tab)
/// - `/device/:id` 设备详情
/// - `/device/group` 设备分组
/// - `/device/activation` 激活码管理
/// - `/device/store-config` 门店配置
/// - `/store` 门店管理(底部导航第 3 个 tab)
/// - `/store/org-tree` 组织架构
/// - `/store/:id` 门店详情
/// - `/admin` 管理中心(底部导航第 4 个 tab)
/// - `/admin/sys/*` 系统管理子模块
/// - `/admin/app/*` 应用管理子模块
/// - `/admin/media/*` 媒资管理子模块
/// - `/admin/intro/*` 公司介绍子模块
/// - `/profile` 我的(底部导航第 5 个 tab)
/// - `/profile/settings` 设置
final appRouterProvider = Provider<GoRouter>((ref) {
// 登录态变化时触发路由重定向刷新,而不重建整个 GoRouter。
final refreshNotifier = ValueNotifier<int>(0);
ref
..onDispose(refreshNotifier.dispose)
..listen(authProvider, (_, __) => refreshNotifier.value++);
return GoRouter(
initialLocation: '/',
refreshListenable: refreshNotifier,
redirect: (context, state) {
final auth = ref.read(authProvider);
final loggedIn = auth.isLoggedIn;
final isLoginRoute = state.matchedLocation == '/login';
if (!loggedIn && !isLoginRoute) {
return '/login';
}
if (loggedIn && isLoginRoute) {
return '/';
}
// 功能模块按账号权限收敛(菜单可见性与此处路由可达性由同一套
// [SessionPermissions] 谓词驱动,确保一致):
// - 系统管理中仅 /admin/sys/user(用户管理)对所有已登录账号开放;
// 其余 /admin/sys/*(角色/菜单/配置/日志/定时任务)仅管理员可访问;
// - 媒资 /admin/media/*、应用 /admin/app/*、公司介绍(分类维度)
// /admin/intro/category 仅管理员可见/可访问;
// - 渠道商管理 /admin/channel 对管理员与渠道商开放,代理商不可访问;
// 非管理员(受限账号)即使通过深链/返回键进入上述受限路由也重定向回
// 管理首页,作为入口收敛的兜底,防止越权查看/操作跨组织数据。
// 注:公司介绍门店维度入口(/admin/intro/store-list、/admin/intro/
// store-content/*)按门店维度展示、数据已由 getMyStores 收敛,对所有账号开放。
final loc = state.matchedLocation;
final user = ref.read(authProvider).user;
final cat = SessionPermissions.category(user);
if (cat != AccountCategory.admin) {
final isUserManagement =
loc == '/admin/sys/user' || loc.startsWith('/admin/sys/user/');
final isSysModule = loc.startsWith('/admin/sys') && !isUserManagement;
if (isSysModule ||
loc.startsWith('/admin/media') ||
loc.startsWith('/admin/app') ||
loc.startsWith('/admin/intro/category')) {
return '/admin';
}
// 代理商(非渠道商)访问渠道商管理 → 弹回(其菜单中本就隐藏该入口)。
if (loc.startsWith('/admin/channel') &&
cat != AccountCategory.channelMerchant) {
return '/admin';
}
}
return null;
},
routes: [
GoRoute(
path: '/login',
name: 'login',
builder: (context, state) => const LoginPage(),
),
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) =>
AppBottomNav(shell: navigationShell),
branches: [
// 首页(仪表盘)
StatefulShellBranch(
routes: [
GoRoute(
path: '/',
name: 'home',
builder: (context, state) => const HomePage(),
),
],
),
// 设备
StatefulShellBranch(
routes: [
GoRoute(
path: '/device',
name: 'device',
builder: (context, state) => const DeviceListPage(),
routes: [
// 具名子路由(group/activation/store-config/scan)必须放在参数路由 :id 之前,
// 否则 `/device/scan` 会被 `:id` 匹配成设备 ID="scan",导致进入错误的详情页。
GoRoute(
path: 'group',
name: 'deviceGroup',
builder: (context, state) => const DeviceGroupPage(),
),
GoRoute(
path: 'activation',
name: 'deviceActivation',
builder: (context, state) =>
const DeviceActivationPage(),
),
GoRoute(
path: 'store-config',
name: 'storeConfig',
builder: (context, state) => const StoreConfigPage(),
),
GoRoute(
path: 'scan',
name: 'deviceScan',
builder: (context, state) {
final mode =
state.uri.queryParameters['mode'] ?? 'query';
final deviceId =
state.uri.queryParameters['deviceId'];
final storeId =
state.uri.queryParameters['storeId'];
final storeName =
state.uri.queryParameters['storeName'];
final groupId =
state.uri.queryParameters['groupId'];
return DeviceScanPage(
mode: mode,
deviceId: deviceId,
storeId: storeId,
storeName: storeName,
groupId: groupId,
);
},
),
GoRoute(
path: ':id',
name: 'deviceDetail',
builder: (context, state) {
final id = state.pathParameters['id'] ?? '';
return DeviceDetailPage(deviceId: id);
},
),
],
),
],
),
// 门店
StatefulShellBranch(
routes: [
GoRoute(
path: '/store',
name: 'store',
builder: (context, state) => const StoreListPage(),
routes: [
GoRoute(
path: 'org-tree',
name: 'orgTree',
builder: (context, state) => const OrgTreePage(),
),
GoRoute(
path: ':id',
name: 'storeDetail',
builder: (context, state) {
final id = state.pathParameters['id'] ?? '';
return StoreDetailPage(storeId: id);
},
),
],
),
],
),
// 管理
StatefulShellBranch(
routes: [
GoRoute(
path: '/admin',
name: 'admin',
builder: (context, state) => const AdminHubPage(),
routes: [
// 管理模块内的设备/激活码别名路由:复用设备分支的页面组件,
// 但在 /admin 分支栈下 push,使底部导航仍停留「管理」tab,
// 返回键可正常 pop 回管理首页,避免此前 push 到 /device 分支栈导致的返回键异常。
GoRoute(
path: 'device',
name: 'adminDevice',
builder: (context, state) => const DeviceListPage(),
),
GoRoute(
path: 'activation',
name: 'adminActivation',
builder: (context, state) =>
const DeviceActivationPage(),
),
// 系统管理
GoRoute(
path: 'sys/user',
name: 'sysUser',
builder: (context, state) => const UserPage(),
),
GoRoute(
path: 'sys/role',
name: 'sysRole',
builder: (context, state) => const RolePage(),
),
GoRoute(
path: 'sys/menu',
name: 'sysMenu',
builder: (context, state) => const MenuPage(),
),
GoRoute(
path: 'sys/config',
name: 'sysConfig',
builder: (context, state) => const ConfigPage(),
),
GoRoute(
path: 'sys/log',
name: 'sysLog',
builder: (context, state) => const LogPage(),
),
GoRoute(
path: 'sys/schedule',
name: 'sysSchedule',
builder: (context, state) => const SchedulePage(),
),
// 应用管理
GoRoute(
path: 'app/version',
name: 'appVersion',
builder: (context, state) => const AppVersionPage(),
),
GoRoute(
path: 'app/tv-app',
name: 'tvApp',
builder: (context, state) => const TvAppPage(),
),
// 滚动字幕管理
GoRoute(
path: 'marquee',
name: 'marqueeManagement',
builder: (context, state) => const MarqueeManagementPage(),
),
// 渠道商管理
GoRoute(
path: 'channel',
name: 'channelManagement',
builder: (context, state) => const ChannelManagementPage(),
),
// 媒资管理
GoRoute(
path: 'media/list',
name: 'mediaList',
builder: (context, state) => const MediaListPage(),
),
GoRoute(
path: 'media/rank',
name: 'mediaRank',
builder: (context, state) => const MediaRankPage(),
),
// 公司介绍(门店维度)
GoRoute(
path: 'intro/store-list',
name: 'introStoreList',
builder: (context, state) => const IntroStoreListPage(),
),
GoRoute(
path: 'intro/store-content/:storeId',
name: 'introStoreContent',
builder: (context, state) {
final storeId = state.pathParameters['storeId'] ?? '';
final store = state.extra is StoreInfo
? state.extra as StoreInfo
: null;
return IntroStoreContentPage(
storeId: storeId,
store: store,
);
},
),
// 公司介绍(原分类维度,保留作为管理备用入口)
GoRoute(
path: 'intro/category',
name: 'introCategory',
builder: (context, state) => const IntroCategoryPage(),
),
],
),
],
),
// 我的
StatefulShellBranch(
routes: [
GoRoute(
path: '/profile',
name: 'profile',
builder: (context, state) => const SettingsPage(),
routes: [
GoRoute(
path: 'settings',
name: 'profileSettings',
builder: (context, state) => const SettingsPage(),
),
],
),
],
),
],
),
],
);
});

423
lib/app/theme.dart Normal file
View File

@@ -0,0 +1,423 @@
import 'package:flutter/material.dart';
/// 应用主题:橙白「双橙策略」,回归并与旧版「云端手机端」视觉延续。
///
/// 设计依据:docs/手机端UI优化方案-v2.md
/// - 行动色 [brandPrimary] (#C2410C) 承载白字(对比度 5.18 ✅),用于主按钮 / 导航选中 / 可点文字。
/// - 品牌亮橙 [brandOrange] (#FF914D) 仅用于大色块 / 渐变 / 图表,绝不配白字(对比度仅 2.23 ❌)。
/// - 辅金 [accentGold] (#FFBE58) 用于高亮 / 星级 / 推荐位。
class AppTheme {
AppTheme._();
/// 行动色(主色)。
static const Color brandPrimary = Color(0xFFC2410C);
/// 品牌亮橙(仅大色块 / 渐变 / 图表)。
static const Color brandOrange = Color(0xFFFF914D);
/// 辅金。
static const Color accentGold = Color(0xFFFFBE58);
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),
shadow: Color(0xFF5C2E12),
scrim: Color(0xFF1A1614),
inverseSurface: Color(0xFF2E2823),
onInverseSurface: Color(0xFFF7EFE9),
inversePrimary: Color(0xFFFFB487),
surfaceTint: Color(0xFFC2410C),
);
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),
shadow: Color(0xFF000000),
scrim: Color(0xFF0F0D0A),
inverseSurface: Color(0xFFF3E9E2),
onInverseSurface: Color(0xFF2E2823),
inversePrimary: Color(0xFFC2410C),
surfaceTint: Color(0xFFFFB487),
);
/// 浅色主题(橙白品牌主场)。
static ThemeData light() => _base(_light).copyWith(
extensions: const <ThemeExtension<dynamic>>[AppSemanticColors.light],
);
/// 深色主题(暖化 surface,主色提亮为 #FFB487)。
static ThemeData dark() => _base(_dark).copyWith(
extensions: const <ThemeExtension<dynamic>>[AppSemanticColors.dark],
);
static ThemeData _base(ColorScheme scheme) {
return ThemeData(
useMaterial3: true,
brightness: scheme.brightness,
colorScheme: scheme,
scaffoldBackgroundColor: scheme.surface,
// 暖棕低透明阴影,替代默认偏硬的黑色阴影(云感)。
shadowColor: const Color(0x245C2E12),
materialTapTargetSize: MaterialTapTargetSize.padded,
visualDensity: VisualDensity.standard,
extensions: const <ThemeExtension<dynamic>>[],
iconTheme: IconThemeData(
size: AppIconSize.regular,
color: scheme.onSurfaceVariant,
),
appBarTheme: AppBarTheme(
centerTitle: true,
backgroundColor: scheme.surface,
foregroundColor: scheme.onSurface,
elevation: 0,
// 滚动后轻微浮起,呼应云影。
scrolledUnderElevation: 3,
shadowColor: const Color(0x245C2E12),
),
cardTheme: CardTheme(
elevation: 0,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
),
),
chipTheme: ChipThemeData(
backgroundColor: scheme.surfaceContainerHighest,
labelStyle: TextStyle(color: scheme.onSurface),
selectedColor: scheme.primaryContainer,
checkmarkColor: scheme.onPrimaryContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.full),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
),
filled: true,
fillColor: scheme.surfaceContainerHighest,
labelStyle: TextStyle(color: scheme.onSurfaceVariant),
hintStyle: TextStyle(color: scheme.onSurfaceVariant),
errorStyle: TextStyle(color: scheme.error),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
snackBarTheme: const SnackBarThemeData(
behavior: SnackBarBehavior.floating,
),
// 全局兜底:30 处 showModalBottomSheet 一次性拿到把手 / 安全区 / 顶部圆角 / 限宽。
bottomSheetTheme: BottomSheetThemeData(
showDragHandle: true,
backgroundColor: scheme.surface,
surfaceTintColor: scheme.surfaceTint,
modalBackgroundColor: scheme.surface,
elevation: 0,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(borderRadius: AppRadius.sheetTop),
constraints: const BoxConstraints(maxWidth: 640),
),
navigationBarTheme: NavigationBarThemeData(
height: 72,
elevation: 0,
backgroundColor: scheme.surfaceContainer,
surfaceTintColor: scheme.surfaceTint,
indicatorColor: scheme.secondaryContainer,
indicatorShape: const StadiumBorder(),
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
iconTheme: WidgetStateProperty.resolveWith((states) => IconThemeData(
size: 24,
color: states.contains(WidgetState.selected)
? scheme.onSecondaryContainer
: scheme.onSurfaceVariant,
)),
labelTextStyle: WidgetStateProperty.resolveWith((states) => TextStyle(
fontSize: 12,
fontWeight: states.contains(WidgetState.selected)
? FontWeight.w600
: FontWeight.w500,
color: states.contains(WidgetState.selected)
? scheme.onSurface
: scheme.onSurfaceVariant,
)),
),
navigationRailTheme: NavigationRailThemeData(
backgroundColor: scheme.surfaceContainer,
elevation: 0,
indicatorColor: scheme.secondaryContainer,
indicatorShape: const StadiumBorder(),
minWidth: 72,
groupAlignment: -0.2,
labelType: NavigationRailLabelType.all,
selectedIconTheme:
IconThemeData(size: 24, color: scheme.onSecondaryContainer),
unselectedIconTheme:
IconThemeData(size: 24, color: scheme.onSurfaceVariant),
selectedLabelTextStyle: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: scheme.onSurface,
),
unselectedLabelTextStyle: TextStyle(
fontSize: 12,
color: scheme.onSurfaceVariant,
),
),
dialogTheme: DialogThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.xxl),
),
backgroundColor: scheme.surfaceContainerLow,
surfaceTintColor: scheme.surfaceTint,
),
iconButtonTheme: IconButtonThemeData(
style: IconButton.styleFrom(
minimumSize: const Size(48, 48),
tapTargetSize: MaterialTapTargetSize.padded,
padding: EdgeInsets.zero,
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size(64, 52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.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(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
),
),
),
);
}
}
/// 圆角梯度:非胶囊、四角一致、克制感(呼应 logo ≈3% 圆角)。
class AppRadius {
static const double xs = 4; // 进度条、缩略图角
static const double sm = 8; // Chip、SnackBar、小标签
static const double md = 12; // 输入框(沿用现值,保持手感连续)
static const double lg = 16; // ★ 标准卡片、按钮(主力值)
static const double xl = 20; // 分组容器、大 Banner 卡
static const double xxl = 28; // ★ 底部弹窗顶部两角(云朵弧线核心表达)
static const double full = 999; // 仅状态 Chip / 头像 / pill
/// 底部弹窗顶部两角大圆角、底部直角(云朵上缘多弧、下缘平缓)。
static const BorderRadius sheetTop =
BorderRadius.vertical(top: Radius.circular(xxl));
AppRadius._();
}
/// 云感暖阴影:模糊:偏移 ≈4:1,暖棕而非纯黑。
class AppShadows {
static const List<BoxShadow> xs = [
BoxShadow(
color: Color(0x0F5C2E12),
blurRadius: 8,
offset: Offset(0, 2),
spreadRadius: -1,
),
];
static const List<BoxShadow> sm = [
BoxShadow(
color: Color(0x145C2E12),
blurRadius: 12,
offset: Offset(0, 4),
spreadRadius: -2,
),
];
static const List<BoxShadow> md = [
BoxShadow(
color: Color(0x1A5C2E12),
blurRadius: 20,
offset: Offset(0, 8),
spreadRadius: -4,
),
];
static const List<BoxShadow> lg = [
BoxShadow(
color: Color(0x245C2E12),
blurRadius: 32,
offset: Offset(0, 12),
spreadRadius: -6,
),
];
static const List<BoxShadow> xl = [
BoxShadow(
color: Color(0x245C2E12),
blurRadius: 48,
offset: Offset(0, 20),
spreadRadius: -8,
),
];
/// 深色:暖色阴影在暗底不可见,改用纯黑 + 描边。
static const List<BoxShadow> dark = [
BoxShadow(
color: Color(0x66000000),
blurRadius: 24,
offset: Offset(0, 8),
spreadRadius: -4,
),
];
AppShadows._();
}
/// 图标尺寸栅格(24dp 为默认)。
class AppIconSize {
static const double inline = 16; // 正文行内
static const double dense = 20; // 密集信息行
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、引导页
AppIconSize._();
}
/// 品牌视觉常量:logo 云朵橙→金渐变。
class AppBrand {
static const Color cloud = Color(0xFFFF914D); // logo 主色
static const Color cloudGold = Color(0xFFFFBE58); // logo 金黄
static const Color onCloud = Color(0xFF3D1A00); // 亮橙底上的深棕文字(6.99 ✅)
/// logo 橙→金渐变(云朵受光)。
static const LinearGradient cloudGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFF914D), Color(0xFFFFBE58)],
);
AppBrand._();
}
/// 语义色(成功 / 警告 / 错误 / 信息),通过 ThemeExtension 暴露。
///
/// 警告用琥珀黄而非橙,避免与品牌橙语义打架。
class AppSemanticColors extends ThemeExtension<AppSemanticColors> {
const AppSemanticColors({
required this.success,
required this.successContainer,
required this.onSuccessContainer,
required this.warning,
required this.warningContainer,
required this.onWarningContainer,
required this.info,
required this.infoContainer,
required this.onInfoContainer,
});
final Color success;
final Color successContainer;
final Color onSuccessContainer;
final Color warning;
final Color warningContainer;
final Color onWarningContainer;
final Color info;
final Color infoContainer;
final Color 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
ThemeExtension<AppSemanticColors> copyWith() => this;
@override
ThemeExtension<AppSemanticColors> lerp(
covariant ThemeExtension<AppSemanticColors>? other, double t) =>
other ?? this;
}

View File

@@ -0,0 +1,55 @@
import '../utils/constants.dart';
import 'environment.dart';
import 'prod_config.dart';
import 'staging_config.dart';
import 'test_config.dart';
/// 运行时环境配置解析。
///
/// 当前环境 [environment] 默认 [AppEnvironment.test],并受 [ProductionGuard]
/// 在启动时强制校验,绝不可能进入生产模式。
class AppConfig {
AppConfig._();
/// 接口统一前缀。
static const String apiPrefix = '/api';
/// 当前环境,默认生产(正式版,域名 https://ips.ainetv.com)。
/// 如需回归测试构建,改回 [AppEnvironment.test] 即可。
static AppEnvironment _environment = AppEnvironment.production;
/// 当前环境(只读)。
static AppEnvironment get environment => _environment;
/// 切换环境:接受 [AppEnvironment.test] / [AppEnvironment.staging] / [AppEnvironment.production]。
static void switchEnvironment(AppEnvironment env) {
_environment = env;
}
/// 当前环境的后端 origin。
static String get apiBaseUrl {
switch (_environment) {
case AppEnvironment.test:
return TestConfig.apiBaseUrl;
case AppEnvironment.staging:
return StagingConfig.apiBaseUrl;
case AppEnvironment.production:
return ProdConfig.apiBaseUrl;
}
}
/// 完整 API 基础地址(origin + 前缀)。
static String get apiFullBaseUrl => apiBaseUrl + apiPrefix;
/// 当前环境是否启用登录验证码。
static bool get enableCaptcha {
switch (_environment) {
case AppEnvironment.test:
return TestConfig.enableCaptcha;
case AppEnvironment.staging:
return StagingConfig.enableCaptcha;
case AppEnvironment.production:
return ProdConfig.enableCaptcha;
}
}
}

View File

@@ -0,0 +1,17 @@
/// 应用运行环境枚举。
///
/// 默认含 [test](测试)、[staging](预发)。
/// [production] 原被 [ProductionGuard] 与 [AppConfig.switchEnvironment] 刻意阻断;
/// 现按用户明确要求出正式版(域名 https://ips.ainetv.com)开放。注意
/// `ips.ainetv.com` 子域不在 ProductionGuard 拒绝名单(精确匹配
/// ainetv.com / api.ainetv.com / 58.220.83.58)内,故守卫不会拦截。
enum AppEnvironment {
/// 测试环境。后端 `https://ips-test.ainetv.com`。
test,
/// 预发环境。后端地址见 [StagingConfig],同样非生产。
staging,
/// 生产环境。后端 `https://ips.ainetv.com`(见 [ProdConfig])。
production,
}

View File

@@ -0,0 +1,17 @@
/// 生产环境专属配置。
///
/// 由用户明确要求出正式版(域名 https://ips.ainetv.com)而启用。
///
/// 注意:生产地址仅限 `ips.ainetv.com` 子域,该子域**不在** [ProductionGuard]
/// 的拒绝名单(精确匹配 `ainetv.com` / `api.ainetv.com` / `58.220.83.58`)内,
/// 故守卫不会拦截本构建。若日后切换到 `api.ainetv.com` 或裸 `ainetv.com`,
/// 需同步调整守卫的拒绝名单,否则启动会被 `exit(1)` 阻断。
class ProdConfig {
ProdConfig._();
/// 生产环境后端 origin。
static const String apiBaseUrl = 'https://ips.ainetv.com';
/// 生产环境是否启用登录验证码。
static const bool enableCaptcha = true;
}

View File

@@ -0,0 +1,48 @@
import 'dart:io';
import 'app_config.dart';
/// 生产环境强制守卫。
///
/// 设计目标:本构建(APK)**永远无法进入生产模式**。
/// - 编译期常量 [kAllowProduction] 恒为 false;
/// - 运行期校验当前后端地址 host 不在生产拒绝名单中;
/// 任一命中即打印清晰报错并 [exit],阻断后续执行。
class ProductionGuard {
ProductionGuard._();
/// 编译期开关:绝不允许开启。
static const bool kAllowProduction = false;
/// 生产环境 host/IP 拒绝名单(精确匹配,区分大小写前已小写化)。
static const List<String> _productionHostDenyList = <String>[
'58.220.83.58', // 生产服务器 IP
'api.ainetv.com',
'ainetv.com',
];
/// 启动期执行:断言非生产,命中即失败退出。
///
/// 同时作为构建期守卫([assert] 在 debug/profile 构建抛错)与运行期守卫
/// (release 构建中 [assert] 被剥离,但 host 校验仍生效)。
static void enforce() {
// 编译期常量断言:若有人将 kAllowProduction 改为 true,调试构建立即崩溃。
assert(!kAllowProduction, '生产模式被编译期禁用 (kAllowProduction=false)');
// 解析当前后端地址并校验 host,禁止任何生产标识。
final full = AppConfig.apiFullBaseUrl;
final host = Uri.parse(full).host.toLowerCase();
if (_productionHostDenyList.contains(host) || host.contains('prod')) {
_failAndExit(
'检测到生产环境地址 host=$host(来自 $full),已强制阻断启动',
);
}
}
/// 打印清晰报错并退出进程,阻断后续执行。
static Never _failAndExit(String reason) {
// ignore: avoid_print
print('[PRODUCTION-GUARD] 启动被拒绝:$reason');
exit(1);
}
}

View File

@@ -0,0 +1,16 @@
/// 预发环境专属配置。
///
/// 与 [TestConfig] 完全隔离,**禁止交叉引用生产环境**的任何常量或文件。
/// 预发环境的服务地址、密钥、数据库连接等敏感配置统一在此独立管理。
class StagingConfig {
StagingConfig._();
/// 预发环境后端 origin(按实际预发域名填写,**严禁填写生产地址**)。
static const String apiBaseUrl = 'https://ips-staging.ainetv.com';
/// 预发环境是否启用登录验证码。
static const bool enableCaptcha = true;
// 预发环境密钥 / 数据库连接等敏感配置在此独立维护,
// 不得出现任何生产环境标识或引用。
}

View File

@@ -0,0 +1,16 @@
/// 测试环境专属配置。
///
/// 与 [StagingConfig] 完全隔离,**禁止交叉引用生产环境**的任何常量或文件。
/// 测试环境的服务地址、密钥、数据库连接等敏感配置统一在此独立管理。
class TestConfig {
TestConfig._();
/// 测试环境后端 origin。
static const String apiBaseUrl = 'https://ips-test.ainetv.com';
/// 测试环境是否启用登录验证码(该环境开放 `/api/captcha.jpg`)。
static const bool enableCaptcha = true;
// 测试环境密钥 / 数据库连接等敏感配置在此独立维护,
// 不得出现任何生产环境标识或引用。
}

145
lib/data/region_data.dart Normal file
View File

@@ -0,0 +1,145 @@
import 'dart:convert';
import 'package:flutter/services.dart' show rootBundle;
/// 省市区节点(省 → 市 → 区三级树)。
///
/// 对应 `assets/data/MCityPiker/province.json` 结构:
/// `{code, name, sub:[{code, name, sub:[{code, name}]}]}`。
class RegionNode {
const RegionNode({
required this.code,
required this.name,
this.sub = const [],
});
/// 行政区划代码(省 6 位、市 6 位、区 6 位)。
final String code;
/// 行政区划名称(如「北京市」「朝阳区」)。
final String name;
/// 下一级子节点(省含市,市含区,区无 sub)。
final List<RegionNode> sub;
factory RegionNode.fromJson(Map<String, dynamic> json) {
final subRaw = json['sub'];
final List<RegionNode> children = subRaw is List
? subRaw
.whereType<Map<String, dynamic>>()
.map(RegionNode.fromJson)
.toList()
: const <RegionNode>[];
return RegionNode(
code: json['code']?.toString() ?? '',
name: json['name']?.toString() ?? '',
sub: children,
);
}
}
/// 省市区数据源。
///
/// 统一由 [RegionData.load] 懒加载 `province.json` 并缓存为
/// `List<RegionNode>` 树;组件禁止直接 `rootBundle` 读取,避免重复解析
/// (文件约 284KB)。
class RegionData {
RegionData._();
/// 全局单例。
static final RegionData instance = RegionData._();
/// 是否已加载。
bool get isLoaded => _loaded;
bool _loaded = false;
/// 省级节点树(已缓存)。
List<RegionNode> get provinces => List.unmodifiable(_tree);
final List<RegionNode> _tree = [];
/// 省份名称缓存(code → 名称),用于反查。
final Map<String, String> _nameByCode = {};
/// 懒加载省市区数据。重复调用安全(仅首次解析)。
Future<void> load() async {
if (_loaded) return;
final raw = await rootBundle
.loadString('assets/data/MCityPiker/province.json');
final decoded = jsonDecode(raw);
final list = decoded is List
? decoded.whereType<Map<String, dynamic>>().map(RegionNode.fromJson)
: const <RegionNode>[];
_tree
..clear()
..addAll(list);
_buildNameIndex(_tree);
_loaded = true;
}
/// 构建 code → 名称 反查索引(省/市/区全部登记)。
void _buildNameIndex(List<RegionNode> nodes) {
for (final node in nodes) {
if (node.code.isNotEmpty) {
_nameByCode[node.code] = node.name;
}
if (node.sub.isNotEmpty) {
_buildNameIndex(node.sub);
}
}
}
/// 获取某省下的市级节点;[provinceCode] 为空或不存在时返回空列表。
List<RegionNode> citiesOf(String provinceCode) {
for (final province in _tree) {
if (province.code == provinceCode) return province.sub;
}
return const [];
}
/// 获取某市下的区级节点;
/// [provinceCode]/[cityCode] 任一为空或不存在时返回空列表。
List<RegionNode> districtsOf(String provinceCode, String cityCode) {
for (final province in _tree) {
if (province.code != provinceCode) continue;
for (final city in province.sub) {
if (city.code == cityCode) return city.sub;
}
}
return const [];
}
/// 按 code 反查名称(省/市/区通用)。
String nameOf(String code) => _nameByCode[code] ?? '';
/// 由历历史纯文本地址尽力映射省市区。
///
/// 当前实现:遍历所有省份/城市/区名称,若 [address] 包含该名称,
/// 则按「省 → 市 → 区」优先级取最长命中,返回
/// `[provinceCode, cityCode, districtCode]`;匹配不到的维度为空字符串。
/// 由于区名可能重复,此映射仅作尽力回显,无法保证 100% 准确。
List<String> tryMapAddress(String address) {
final text = address.trim();
if (text.isEmpty) return const ['', '', ''];
String provinceCode = '';
String cityCode = '';
String districtCode = '';
for (final province in _tree) {
if (text.contains(province.name)) {
provinceCode = province.code;
for (final city in province.sub) {
if (text.contains(city.name)) {
cityCode = city.code;
for (final district in city.sub) {
if (text.contains(district.name)) {
districtCode = district.code;
}
}
}
}
}
}
return [provinceCode, cityCode, districtCode];
}
}

315
lib/main.dart Normal file
View File

@@ -0,0 +1,315 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app/router.dart';
import 'app/theme.dart';
import 'providers/locale_provider.dart';
import 'providers/theme_provider.dart';
import 'providers/display_provider.dart';
import 'utils/constants.dart';
import 'utils/credential_storage.dart';
import 'utils/i10n.dart';
import 'utils/store_local_cache.dart';
import 'config/prod_guard.dart';
/// 应用入口。
///
/// 三端(Android / iOS / HarmonyOS)共用同一套 lib/ 代码,
/// 通过 [ProviderScope] 注入全局状态,使用 go_router 进行声明式路由。
/// 根组件 key;重试时更换以强制整棵树重建。
GlobalKey _appRootKey = GlobalKey();
void main() {
// 全局 ErrorWidget:任何 widget 在 build/layout/paint 阶段抛异常时,
// 直接显示可读的红色错误页,而不是 release 模式默认的灰色错误块(静默灰屏)。
ErrorWidget.builder = (FlutterErrorDetails details) {
return _ErrorWidgetScreen(
message: details.exceptionAsString(),
stack: details.stack,
);
};
// 框架级异常兜底:记录并写入 _fatalError,供根组件在 build 时渲染错误页。
// 注意:不再调用 FlutterError.presentError,避免默认灰屏覆盖自定义错误页。
FlutterError.onError = (details) {
_reportFatal(details.exceptionAsString(), details.stack);
};
// 异步/Zone 异常兜底。
PlatformDispatcher.instance.onError = (error, stack) {
_reportFatal(error.toString(), stack);
return true;
};
runZonedGuarded<void>(
() async {
WidgetsFlutterBinding.ensureInitialized();
// 启用 Edge-to-Edge 全屏:内容延伸至状态栏与导航栏区域,
// 避免手机四周出现黑边;同时保持系统手势/状态栏可见。
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
// 状态栏 / 导航栏图标亮度跟随系统明暗(修复此前硬编码 Brightness.light
// 导致浅色模式下图标不可见的 bug)。
final isDark =
WidgetsBinding.instance.platformDispatcher.platformBrightness ==
Brightness.dark;
SystemChrome.setSystemUIOverlayStyle(
SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.transparent,
systemNavigationBarDividerColor: Colors.transparent,
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
systemNavigationBarIconBrightness:
isDark ? Brightness.light : Brightness.dark,
),
);
// 预初始化凭据持久化(记住密码 / 免登录恢复依赖 SharedPreferences 同步读取)。
await CredentialStorage.init();
// 生产环境强制守卫:启动即校验当前后端非生产,命中则失败退出并阻断启动。
ProductionGuard.enforce();
// 预初始化本地缓存(语言、主题等配置需要同步读取)。
// 即便失败也用默认值兜底,不让启动崩溃。
StoreLocalCache.instance.initialize().catchError((_) {
// 忽略:SharedPreferences 不可用时退化为内存态。
});
// 应用已保存的显示方向偏好(跟随系统 / 竖屏 / 横屏),
// 确保首帧即按用户设置的方向渲染,不闪一下系统默认朝向。
final savedOrientation = await loadSavedOrientation();
applyDisplayOrientation(savedOrientation);
_launchApp();
},
(error, stack) {
_reportFatal(error.toString(), stack);
},
);
}
/// 启动根组件(重试时再次调用即可重建整棵树)。
void _launchApp() {
runApp(
ProviderScope(
key: _appRootKey,
child: const OpsConsoleApp(),
),
);
}
/// 全局致命错误信息;非空时 [OpsConsoleApp] 渲染错误页而非正常界面。
String? _fatalError;
/// 记录致命错误并写入全局变量,供 [OpsConsoleApp] 在 build 时渲染错误页。
void _reportFatal(Object error, StackTrace? stack) {
if (_fatalError == null) {
_fatalError =
'$error\n\n${stack?.toString().split('\n').take(8).join('\n') ?? ''}';
}
debugPrint('FATAL: $error');
debugPrint('STACK: $stack');
}
/// 清空致命错误并重启根组件(错误页「重试」按钮调用)。
void _retryApp() {
_fatalError = null;
_appRootKey = GlobalKey();
_launchApp();
}
/// 运维管理台根组件。
class OpsConsoleApp extends ConsumerWidget {
const OpsConsoleApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// 全局致命错误:直接渲染可读错误页,避免 release 模式静默灰屏。
if (_fatalError != null) {
return MaterialApp(
title: AppConstants.appName,
debugShowCheckedModeBanner: false,
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
themeMode: ThemeMode.system,
home: _FatalErrorScreen(error: _fatalError!),
);
}
final router = ref.watch(appRouterProvider);
final themeMode = ref.watch(themeModeProvider);
final appLocale = ref.watch(localeProvider);
return MaterialApp.router(
title: AppConstants.appName,
// 应用级名称,与 AndroidManifest/iOS Info.plist/鸿蒙配置保持一致
debugShowCheckedModeBanner: false,
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
// 默认深色主题,可在“设置”页切换
themeMode: themeMode,
locale: appLocale.locale,
supportedLocales: const [
Locale('zh', 'CN'),
Locale('en', 'US'),
],
localizationsDelegates: const [
// 必须包含 Flutter 默认的本地化委托,否则 MaterialLocalizations
// 不会被注册,所有 TextField/InputDecoration 会因取 null 而崩溃。
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
_AppLocaleDelegate(),
],
routerConfig: router,
);
}
}
/// [AppLocale] 的轻量 [LocalizationsDelegate],
/// 使 [AppLocalizations.of] 能按当前 locale 取到对应文案。
class _AppLocaleDelegate extends LocalizationsDelegate<AppLocale> {
const _AppLocaleDelegate();
@override
bool isSupported(Locale locale) =>
locale.languageCode == 'zh' || locale.languageCode == 'en';
@override
Future<AppLocale> load(Locale locale) async {
return locale.languageCode == 'en' ? AppLocale.en : AppLocale.zh;
}
@override
bool shouldReload(covariant LocalizationsDelegate<AppLocale> old) => false;
}
/// 全局致命错误展示页。
///
/// 当 [FlutterError.onError] / [PlatformDispatcher.onError] / Zone 异常捕获到
/// 未处理异常时,用可读的红色错误页替代默认的灰/白屏,便于真机直接看到根因。
class _FatalErrorScreen extends StatelessWidget {
const _FatalErrorScreen({required this.error});
final String error;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.red.shade50,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 16),
const Text(
'应用启动时发生错误',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.red,
),
),
const SizedBox(height: 8),
const Text(
'请将下方错误信息反馈给开发人员:',
style: TextStyle(color: Colors.black87),
),
const SizedBox(height: 12),
Expanded(
child: SingleChildScrollView(
child: SelectableText(
error,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: Colors.black87,
),
),
),
),
const SizedBox(height: 12),
FilledButton(
onPressed: _retryApp,
child: const Text('重试'),
),
],
),
),
),
);
}
}
/// 全局渲染异常兜底页([ErrorWidget.builder] 使用)。
///
/// 与 [_FatalErrorScreen] 不同,此组件不依赖 MaterialApp/Theme(此时 app 可能
/// 尚未建立),是一个完全自包含、硬编码红色背景的可读错误页,确保任何 widget
/// 渲染异常都能暴露文字而非静默灰屏。
class _ErrorWidgetScreen extends StatelessWidget {
const _ErrorWidgetScreen({required this.message, this.stack});
final String message;
final StackTrace? stack;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.red.shade50,
child: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.error_outline, size: 40, color: Colors.red),
const SizedBox(height: 12),
const Text(
'渲染异常(Widget Error)',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.red,
),
),
const SizedBox(height: 8),
const Text(
'请将下方文字反馈给开发人员:',
style: TextStyle(color: Colors.black87),
),
const SizedBox(height: 8),
SelectableText(
message,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: Colors.black87,
),
),
if (stack != null) ...[
const SizedBox(height: 8),
SelectableText(
stack.toString(),
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 11,
color: Colors.black54,
),
),
],
],
),
),
),
);
}
}

95
lib/models/alert.dart Normal file
View File

@@ -0,0 +1,95 @@
/// 告警级别枚举。
enum AlertLevel {
/// 严重。
critical,
/// 警告。
warning,
/// 提示。
info;
/// 级别中文名称。
String get label {
switch (this) {
case AlertLevel.critical:
return '严重';
case AlertLevel.warning:
return '警告';
case AlertLevel.info:
return '提示';
}
}
/// 从字符串解析级别。
static AlertLevel fromString(String value) {
return AlertLevel.values.firstWhere(
(level) => level.name == value,
orElse: () => AlertLevel.info,
);
}
}
/// 告警模型。
class Alert {
const Alert({
required this.id,
required this.deviceId,
required this.deviceName,
required this.title,
required this.message,
required this.level,
required this.occurredAt,
required this.acknowledged,
});
/// 告警唯一标识。
final String id;
/// 关联设备 ID。
final String deviceId;
/// 关联设备名称。
final String deviceName;
/// 告警标题。
final String title;
/// 告警详情。
final String message;
/// 告警级别。
final AlertLevel level;
/// 发生时间。
final DateTime occurredAt;
/// 是否已确认。
final bool acknowledged;
factory Alert.fromJson(Map<String, dynamic> json) {
return Alert(
id: json['id'] as String,
deviceId: json['deviceId'] as String,
deviceName: json['deviceName'] as String,
title: json['title'] as String,
message: json['message'] as String,
level: AlertLevel.fromString(json['level'] as String),
occurredAt: DateTime.parse(json['occurredAt'] as String),
acknowledged: json['acknowledged'] as bool,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'deviceId': deviceId,
'deviceName': deviceName,
'title': title,
'message': message,
'level': level.name,
'occurredAt': occurredAt.toIso8601String(),
'acknowledged': acknowledged,
};
}
}

View File

@@ -0,0 +1,95 @@
/// APP 版本模型。
///
/// 对应后端 /device/orgGroupAppVersion/* 接口的版本实体。
class AppVersion {
const AppVersion({
required this.id,
required this.orgId,
required this.groupId,
required this.appName,
required this.packageName,
required this.versionCode,
required this.versionName,
required this.apkUrl,
required this.status,
required this.createTime,
});
/// 版本 ID。
final String id;
/// 组织 ID。
final String orgId;
/// 设备分组 ID。
final String groupId;
/// 应用名称。
final String appName;
/// 包名。
final String packageName;
/// 版本号(整数)。
final int versionCode;
/// 版本名。
final String versionName;
/// APK 下载地址。
final String apkUrl;
/// 状态(0=正常 1=禁用)。
final int status;
/// 创建时间。
final DateTime? createTime;
factory AppVersion.fromJson(Map<String, dynamic> json) {
return AppVersion(
id: json['id']?.toString() ?? '',
orgId: json['orgId']?.toString() ?? '',
groupId: json['groupId']?.toString() ?? '',
appName: json['appName']?.toString() ?? '',
// 包名:后端可能返回 packageName / package_name(与既有 AppVersionPage 一致)。
packageName: json['packageName']?.toString() ??
json['package_name']?.toString() ??
'',
versionCode: int.tryParse(json['versionCode']?.toString() ?? '0') ?? 0,
versionName: json['versionName']?.toString() ?? '',
// 下载地址:对齐 Web 端 normalizeAppVersion(downloadUrl/download_url),
// 同时兼容移动端既有 AppVersionPage 使用的 apkUrl——后端不同部署字段名不一,
// 取首个非空值,避免「立即更新」因 url 为空而失效。
apkUrl: json['apkUrl']?.toString() ??
json['downloadUrl']?.toString() ??
json['download_url']?.toString() ??
'',
status: int.tryParse(json['status']?.toString() ?? '0') ?? 0,
createTime: _parseDateTime(json['createTime']),
);
}
/// 兼容 `yyyy-MM-dd HH:mm:ss` 与 ISO 8601 两种时间格式。
static DateTime? _parseDateTime(dynamic value) {
if (value == null) return null;
final text = value.toString().trim();
if (text.isEmpty) return null;
return DateTime.tryParse(text) ??
DateTime.tryParse(text.replaceFirst(' ', 'T'));
}
Map<String, dynamic> toJson() {
return {
'id': id,
'orgId': orgId,
'groupId': groupId,
'appName': appName,
'packageName': packageName,
'versionCode': versionCode,
'versionName': versionName,
'apkUrl': apkUrl,
'status': status,
'createTime': createTime?.toIso8601String(),
};
}
}

622
lib/models/device.dart Normal file
View File

@@ -0,0 +1,622 @@
/// 设备状态枚举。
enum DeviceStatus {
/// 在线。
online,
/// 离线。
offline,
/// 告警(指标异常)。
warning;
/// 状态中文名称。
String get label {
switch (this) {
case DeviceStatus.online:
return '在线';
case DeviceStatus.offline:
return '离线';
case DeviceStatus.warning:
return '告警';
}
}
/// 从后端状态字符串解析。
///
/// 规则(与「爱视后台」约定一致):`offline` → 离线、`warning` → 告警、
/// 其余(含 `online` 与未知值)→ 在线。
static DeviceStatus fromString(String? value) {
final status = (value ?? '').toLowerCase();
if (status == 'offline') {
return DeviceStatus.offline;
}
if (status == 'warning') {
return DeviceStatus.warning;
}
return DeviceStatus.online;
}
}
/// 设备指令常量(对应 /device/command/sendByMac|sendByOrg 的 command 字段)。
class DeviceCommand {
DeviceCommand._();
static const String powerOff = 'powerOff';
static const String reboot = 'reboot';
static const String screenshot = 'screenshot';
static const String themeUpdate = 'themeUpdate';
static const String versionUpdate = 'versionUpdate';
static const String marqueeUpdate = 'marqueeUpdate';
static const String weatherUpdate = 'weatherUpdate';
static const String mediaZoneVol = 'mediaZoneVol';
static const String defaultVol = 'defaultVol';
static const String themeClear = 'themeClear';
static const String cacheClear = 'cacheClear';
static const String storeExpire = 'storeExpire';
static const String storeDisable = 'storeDisable';
static const String passwordUpdate = 'passwordUpdate';
static const String worldAd = 'worldAd';
}
/// 设备运行指标(CPU / 内存 / 磁盘 / 网络)。
///
/// 注意:真实后端不提供该类指标,仅 mock 模式有值;
/// 由 [Device.hasMetrics] 标识当前数据是否包含指标。
class DeviceMetrics {
const DeviceMetrics({
required this.cpuUsage,
required this.memoryUsage,
required this.diskUsage,
required this.networkRxKbps,
required this.networkTxKbps,
});
/// 全零指标(真实模式下的占位值,配合 hasMetrics=false 使用)。
const DeviceMetrics.zero()
: cpuUsage = 0,
memoryUsage = 0,
diskUsage = 0,
networkRxKbps = 0,
networkTxKbps = 0;
/// CPU 使用率(0.0 ~ 1.0)。
final double cpuUsage;
/// 内存使用率(0.0 ~ 1.0)。
final double memoryUsage;
/// 磁盘使用率(0.0 ~ 1.0)。
final double diskUsage;
/// 网络下行速率(KB/s)。
final double networkRxKbps;
/// 网络上行速率(KB/s)。
final double networkTxKbps;
/// 从后端 JSON 解析运行指标。
///
/// 兼容嵌套 `metrics` 对象与顶层扁平字段两种形态,并对百分比做 0~100→0~1
/// 归一化;任意字段缺失时安全回退为 0,保证 UI 不崩溃。
factory DeviceMetrics.fromJson(Map<String, dynamic> json) {
return DeviceMetrics(
cpuUsage: _normalizePercent(
json['cpuUsage'] ?? json['cpu_usage'] ?? json['cpu'],
),
memoryUsage: _normalizePercent(
json['memoryUsage'] ??
json['memory_usage'] ??
json['mem'] ??
json['memUsage'] ??
json['memory'],
),
diskUsage: _normalizePercent(
json['diskUsage'] ?? json['disk_usage'] ?? json['disk'],
),
networkRxKbps: _toDouble(
json['networkRxKbps'] ?? json['rxKbps'] ?? json['networkRx'],
),
networkTxKbps: _toDouble(
json['networkTxKbps'] ?? json['txKbps'] ?? json['networkTx'],
),
);
}
Map<String, dynamic> toJson() {
return {
'cpuUsage': cpuUsage,
'memoryUsage': memoryUsage,
'diskUsage': diskUsage,
'networkRxKbps': networkRxKbps,
'networkTxKbps': networkTxKbps,
};
}
}
/// 将后端返回的百分数字段归一化为 0.0~1.0。
///
/// 兼容后端可能返回 0~100(如 87)或 0~1(如 0.87)两种形态,避免 UI 进度条
/// 与百分比文本越界。
double _normalizePercent(dynamic value) {
if (value == null) return 0;
final n = value is num
? value.toDouble()
: double.tryParse(value.toString());
if (n == null) return 0;
return n > 1 ? n / 100 : n;
}
/// 将动态值安全转换为 double,无法解析时返回 0。
double _toDouble(dynamic value) {
if (value == null) return 0;
return (value is num
? value.toDouble()
: double.tryParse(value.toString())) ??
0;
}
/// 设备模型。
///
/// 字段对齐真实后端 DeviceInfo(id / mac / name / deviceSn / deviceUid /
/// status / groupId / lastSyncTime / createTime),并保留 UI 展示兼容字段
/// (ip / location / os / uptimeSeconds / metrics / storeId),真实模式下取默认值。
class Device {
const Device({
required this.id,
required this.mac,
required this.name,
required this.model,
required this.deviceSn,
required this.deviceUid,
required this.status,
required this.groupId,
this.storeId = '',
required this.lastSyncTime,
required this.createTime,
this.ip = '',
this.location = '',
this.storeName = '',
this.os = '',
this.roomNo = '',
this.uptimeSeconds = 0,
this.hasMetrics = false,
this.metrics = const DeviceMetrics.zero(),
});
/// 设备唯一标识。
final String id;
/// 设备 MAC 地址(指令下发的目标标识)。
final String mac;
/// 设备名称(后端可能返回 MAC、SN 或自定义别名)。
final String name;
/// 设备型号(如 rk3566_r、p321)。
///
/// 优先从后端 model/modelName/deviceModel/productModel 等字段解析;
/// 缺失时降级为 [name](若 name 是 MAC 地址则跳过),再降级为 [deviceSn],
/// 最终兜底 [mac],确保不会把 MAC 误当成型号展示。
final String model;
/// 设备序列号。
final String deviceSn;
/// 设备 UID。
final String deviceUid;
/// 当前状态。
final DeviceStatus status;
/// 分组 ID。
final String groupId;
/// 所属门店 ID(用于拉取设备分组列表;后端命名多样,按常见别名解析)。
final String storeId;
/// 房号(房间号,如「802」「A栋301」)。
final String roomNo;
/// 最近一次同步时间。
final DateTime? lastSyncTime;
/// 创建时间。
final DateTime? createTime;
/// IP 地址(真实后端无此字段,默认回退为 MAC)。
final String ip;
/// 机房 / 位置(真实后端无此字段,默认为空)。
final String location;
/// 所属门店名称(真实后端可能返回 storeName / orgName)。
final String storeName;
/// 操作系统描述(真实后端无此字段,默认为空)。
final String os;
/// 已连续运行时长(秒)(真实后端无此字段,默认为 0)。
final int uptimeSeconds;
/// 是否包含运行指标(仅 mock 模式为 true)。
final bool hasMetrics;
/// 运行指标。
final DeviceMetrics metrics;
/// 创建副本并覆盖指定字段。
Device copyWith({
String? id,
String? mac,
String? name,
String? model,
String? deviceSn,
String? deviceUid,
DeviceStatus? status,
String? groupId,
String? storeId,
String? roomNo,
DateTime? lastSyncTime,
DateTime? createTime,
String? ip,
String? location,
String? storeName,
String? os,
int? uptimeSeconds,
bool? hasMetrics,
DeviceMetrics? metrics,
}) {
return Device(
id: id ?? this.id,
mac: mac ?? this.mac,
name: name ?? this.name,
model: model ?? this.model,
deviceSn: deviceSn ?? this.deviceSn,
deviceUid: deviceUid ?? this.deviceUid,
status: status ?? this.status,
groupId: groupId ?? this.groupId,
storeId: storeId ?? this.storeId,
roomNo: roomNo ?? this.roomNo,
lastSyncTime: lastSyncTime ?? this.lastSyncTime,
createTime: createTime ?? this.createTime,
ip: ip ?? this.ip,
location: location ?? this.location,
storeName: storeName ?? this.storeName,
os: os ?? this.os,
uptimeSeconds: uptimeSeconds ?? this.uptimeSeconds,
hasMetrics: hasMetrics ?? this.hasMetrics,
metrics: metrics ?? this.metrics,
);
}
/// 设备标识:统一为「MAC / 房号」组合。
/// 优先 MAC + 房号;任一缺失则只用存在的那项;两者皆空时逐级降级到
/// name → model → mac,确保永不展示空白。
String get macRoomLabel {
final m = mac.trim();
final r = roomNo.trim();
if (m.isNotEmpty && r.isNotEmpty) return '$m / $r';
if (m.isNotEmpty) return m;
if (r.isNotEmpty) return r;
if (name.isNotEmpty) return name;
if (model.isNotEmpty) return model;
return m;
}
/// 仅展示 MAC 地址(房号改为独立「房号:XXX」列示,不再用「/」把两者粘在一起)。
/// MAC 为空时逐级降级到 name → model → mac,确保标题不空白。
String get macLabel {
final m = mac.trim();
if (m.isNotEmpty) return m;
if (name.isNotEmpty) return name;
if (model.isNotEmpty) return model;
return m;
}
factory Device.fromJson(Map<String, dynamic> json) {
// 兼容详情接口可能再包一层 {device|info|data:{...}} 的形态,先兜底解包。
final Map<String, dynamic> data = _unwrapDeviceJson(json);
final mac = data['mac']?.toString() ?? '';
final metricsJson = data['metrics'];
// 合并嵌套 metrics 与顶层扁平遥测字段(详情接口可能直接返回 cpuUsage /
// cpu_usage / cpu 等)。任一使用率字段存在即视为有指标数据,便于详情页展示。
final Map<String, dynamic> mergedMetrics = <String, dynamic>{};
if (metricsJson is Map<String, dynamic>) {
mergedMetrics.addAll(metricsJson);
}
final flatCpu = _firstNum(data, [
'cpuUsage',
'cpu_usage',
'cpu',
'cpuPercent',
'cpu_percent',
'cpuRate',
'cpu_rate',
]);
if (flatCpu != null) {
mergedMetrics.putIfAbsent('cpuUsage', () => flatCpu);
}
final flatMem = _firstNum(data, [
'memoryUsage',
'memory_usage',
'mem',
'memUsage',
'memory',
'memoryPercent',
'mem_percent',
]);
if (flatMem != null) {
mergedMetrics.putIfAbsent('memoryUsage', () => flatMem);
}
final flatDisk = _firstNum(data, [
'diskUsage',
'disk_usage',
'disk',
'diskPercent',
'disk_percent',
]);
if (flatDisk != null) {
mergedMetrics.putIfAbsent('diskUsage', () => flatDisk);
}
// 爱视后台列表字段名为 deviceName / device_name,name 字段常缺失或存 MAC/IP。
final name = data['deviceName']?.toString() ??
data['device_name']?.toString() ??
data['name']?.toString() ??
'';
// IP 可能落在 ip / ipAddress / ip_address / deviceIp / device_ip 等多个字段。
final ip = data['ip']?.toString() ??
data['ipAddress']?.toString() ??
data['ip_address']?.toString() ??
data['deviceIp']?.toString() ??
data['device_ip']?.toString() ??
'';
final deviceSn = data['deviceSn']?.toString() ?? '';
// 型号字段后端命名多样,按常见别名逐级解析;解析不到时按规则降级。
final rawModel = _firstString(data, [
'model',
'modelName',
'model_name',
'deviceModel',
'device_model',
'productModel',
'product_model',
'productName',
'product_name',
'board',
'hardware',
'brand',
'deviceType',
'device_type',
'type',
]) ??
'';
final model = _resolveModel(rawModel, name, deviceSn, mac);
return Device(
id: data['id']?.toString() ?? '',
mac: mac,
name: name,
model: model,
deviceSn: deviceSn,
deviceUid: data['deviceUid']?.toString() ?? '',
status: DeviceStatus.fromString(data['status']?.toString()),
groupId: data['groupId']?.toString() ?? '',
storeId: _resolveStoreId(data),
// Web 端设备列表把最近心跳时间存为 heartbeatTime / heartbeat_time,
// 详情接口多为 lastSyncTime;兼容两种命名,避免最后活跃时间解析为空。
lastSyncTime: _parseDateTime(
data['lastSyncTime'] ??
data['last_sync_time'] ??
data['heartbeatTime'] ??
data['heartbeat_time'] ??
data['lastHeartbeat'] ??
data['last_heartbeat'],
),
createTime: _parseDateTime(data['createTime']),
// UI 兼容字段:真实数据缺失时回退。
ip: ip,
location: _firstString(data, [
'location',
'locationDesc',
'loc',
'position',
'place',
'area',
'region',
]) ??
'',
storeName: data['storeName']?.toString() ??
data['orgName']?.toString() ??
data['store']?.toString() ?? '',
os: _firstString(data, [
'os',
'osName',
'system',
'systemVersion',
'osVersion',
'platform',
'osInfo',
'sysVersion',
]) ??
'',
// 房号/房间号字段后端命名极不统一,按常见别名逐级回退。
roomNo: _firstString(data, [
'roomNo',
'room',
'roomNoStr',
'roomNumber',
'room_number',
'roomNum',
'roomNumStr',
'roomId',
'room_id',
'roomName',
'houseNumber',
'house_number',
'houseNo',
'houseNoStr',
'roomCode',
'room_code',
]) ??
'',
uptimeSeconds: _firstNum(data, [
'uptimeSeconds',
'uptime',
'uptimeSec',
'uptimeSecs',
'runTime',
'runningTime',
'onlineTime',
'bootTime',
'upTime',
'up_time',
])?.toInt() ?? 0,
hasMetrics: mergedMetrics.isNotEmpty,
metrics: mergedMetrics.isNotEmpty
? DeviceMetrics.fromJson(mergedMetrics)
: const DeviceMetrics.zero(),
);
}
/// 兜底解包设备 JSON。
///
/// 后端 /device/info/info/{id} 详情接口可能并未把设备对象直接作为响应体,
/// 而是再包一层 `{device|info|data: {...}}`;若顶层没有 `id` 但某嵌套字段
/// 是含 `id` 的 Map,则返回该嵌套 Map,否则原样返回。
/// 这样可避免详情页所有遥测字段因解析不到而显示为空。
static Map<String, dynamic> _unwrapDeviceJson(Map<String, dynamic> json) {
if (json.containsKey('id')) return json;
for (final key in const ['device', 'info', 'data', 'result', 'record']) {
final nested = json[key];
if (nested is Map<String, dynamic> &&
(nested.containsKey('id') || nested.containsKey('mac'))) {
return nested;
}
}
return json;
}
/// 从 [json] 中按 [keys] 顺序取第一个非空数值。
///
/// 兼容后端返回字符串或数字两种形态;任意匹配键取到有效数值即返回,
/// 否则返回 null(交由调用处按默认 0 处理,避免 UI 崩溃)。
static num? _firstNum(
Map<String, dynamic> json,
List<String> keys,
) {
for (final key in keys) {
final value = json[key];
if (value == null) continue;
if (value is num) return value;
final n = double.tryParse(value.toString());
if (n != null) return n;
}
return null;
}
/// 从 [json] 中按 [keys] 顺序取第一个非空字符串值。
static String? _firstString(
Map<String, dynamic> json,
List<String> keys,
) {
for (final key in keys) {
final value = json[key];
if (value == null) continue;
final text = value.toString().trim();
if (text.isNotEmpty) return text;
}
return null;
}
/// 解析设备所属门店 ID。
///
/// 后端字段命名极不统一:可能直接在顶层返回 `storeId` / `orgId`,也可能嵌套在
/// `store` / `storeInfo` / `org` 对象中。本方法先尝试顶层字段,再尝试嵌套对象
/// 中的 `id` / `orgId` / `storeId`,尽力避免分组下拉因取不到门店 ID 而无法加载。
static String _resolveStoreId(Map<String, dynamic> json) {
final topLevel = _firstString(json, [
'storeId',
'storeOrgId',
'orgId',
'store_id',
'org_id',
'storeIdStr',
]);
if (topLevel != null && topLevel.isNotEmpty) return topLevel;
for (final nestedKey in const ['store', 'storeInfo', 'org', 'organization']) {
final nested = json[nestedKey];
if (nested is Map<String, dynamic>) {
final id = _firstString(nested, [
'id',
'orgId',
'storeId',
'store_id',
'org_id',
]);
if (id != null && id.isNotEmpty) return id;
}
}
return '';
}
/// 解析最终展示的型号文本。
///
/// 优先级:
/// 1. 后端明确返回的 [rawModel]
/// 2. [name](仅当非空且不是 MAC 地址时)
/// 3. [deviceSn]
/// 4. [mac](最终兜底,避免空标题)
static String _resolveModel(
String rawModel,
String name,
String deviceSn,
String mac,
) {
if (rawModel.isNotEmpty) return rawModel;
if (name.isNotEmpty && !_isMacAddress(name)) return name;
if (deviceSn.isNotEmpty) return deviceSn;
return mac;
}
/// 判断 [value] 是否为 MAC 地址格式(xx:xx:xx:xx:xx:xx 或 xx-xx-xx-xx-xx-xx)。
static bool _isMacAddress(String value) {
return RegExp(r'^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$')
.hasMatch(value.trim());
}
/// 兼容 `yyyy-MM-dd HH:mm:ss` 与 ISO 8601 两种时间格式。
static DateTime? _parseDateTime(dynamic value) {
if (value == null) {
return null;
}
final text = value.toString().trim();
if (text.isEmpty) {
return null;
}
return DateTime.tryParse(text) ??
DateTime.tryParse(text.replaceFirst(' ', 'T'));
}
Map<String, dynamic> toJson() {
return {
'id': id,
'mac': mac,
'name': name,
'model': model,
'deviceSn': deviceSn,
'deviceUid': deviceUid,
'status': status.name,
'groupId': groupId,
'storeId': storeId,
'lastSyncTime': lastSyncTime?.toIso8601String(),
'createTime': createTime?.toIso8601String(),
'ip': ip,
'location': location,
'storeName': storeName,
'os': os,
'roomId': roomNo,
'uptimeSeconds': uptimeSeconds,
'metrics': hasMetrics ? metrics.toJson() : null,
};
}
}

View File

@@ -0,0 +1,281 @@
/// 介绍页分类模型。
///
/// 对应后端 /company/introCategory/* 接口。
///
/// 后端实际字段:
/// - id, orgId, orgName, categoryType, name, subCategories,
/// createTime, updateTime
/// 注:后端分类列表不返回 sort/status,兼容处理为默认值。
class IntroCategory {
const IntroCategory({
required this.id,
required this.orgId,
required this.orgName,
required this.categoryType,
required this.name,
required this.sort,
required this.status,
required this.createTime,
this.subCategories,
});
/// 分类 ID。
final String id;
/// 所属门店/组织 ID。
final String orgId;
/// 所属门店/组织名称(用于门店筛选展示)。
final String orgName;
/// 分类类型编码(如 gongsi/gsjs/hotel 等)。
final String categoryType;
/// 分类名称。
final String name;
/// 排序号(后端可能缺失,缺省 0)。
final int sort;
/// 状态(1=启用/正常,0=禁用),与 Web 后端保持一致。
final int status;
/// 创建时间。
final DateTime? createTime;
/// 后端可能嵌入的子分类原始数据(暂不直接解析,用于后续扩展)。
final List<dynamic>? subCategories;
factory IntroCategory.fromJson(Map<String, dynamic> json) {
return IntroCategory(
id: json['id']?.toString() ?? '',
orgId: json['orgId']?.toString() ?? '',
orgName: json['orgName']?.toString() ?? '',
categoryType: json['categoryType']?.toString() ?? '',
name: json['name']?.toString() ?? '',
sort: int.tryParse(json['sortOrder']?.toString() ??
json['sort']?.toString() ??
'0') ??
0,
status: _parseStatus(json['status']),
createTime: _parseDateTime(json['createTime']),
subCategories: json['subCategories'] is List
? List<dynamic>.from(json['subCategories'] as List)
: null,
);
}
/// 与 Web 后端保持一致:1=启用/正常,0=禁用;缺失/null 视为启用。
static int _parseStatus(dynamic value) {
if (value == null) return 1;
if (value is bool) return value ? 1 : 0;
final text = value.toString().trim().toLowerCase();
if (text == '1' || text == 'active' || text == '正常' || text == '启用' ||
text == 'enabled' || text == 'true') {
return 1;
}
if (text == '0' ||
text == 'inactive' ||
text == '禁用' ||
text == '停用' ||
text == 'disabled' ||
text == 'false') {
return 0;
}
return 1;
}
/// 兼容 `yyyy-MM-dd HH:mm:ss` 与 ISO 8601 两种时间格式。
static DateTime? _parseDateTime(dynamic value) {
if (value == null) return null;
final text = value.toString().trim();
if (text.isEmpty) return null;
return DateTime.tryParse(text) ??
DateTime.tryParse(text.replaceFirst(' ', 'T'));
}
Map<String, dynamic> toJson() {
return {
'id': id,
'orgId': orgId,
'orgName': orgName,
'categoryType': categoryType,
'name': name,
'sort': sort,
'status': status,
'createTime': createTime?.toIso8601String(),
'subCategories': subCategories,
};
}
}
/// 介绍页子分类模型。
///
/// 对应后端 /company/introSubcategory/* 接口。
///
/// 后端实际字段:
/// - id, categoryId, categoryType, mediaType, mediaUrl,
/// title, description, status, sortOrder, contents, createTime, updateTime
class IntroSubcategory {
const IntroSubcategory({
required this.id,
required this.categoryId,
required this.categoryType,
required this.title,
required this.description,
required this.mediaType,
required this.mediaUrl,
required this.sort,
required this.status,
this.contents,
});
/// 子分类 ID。
final String id;
/// 所属分类 ID。
final String categoryId;
/// 分类类型编码。
final String categoryType;
/// 子分类标题(后端用 title 而非 name)。
final String title;
/// 描述/简介。
final String description;
/// 媒体类型:image / video。
final String mediaType;
/// 媒体地址。
final String mediaUrl;
/// 排序号(后端字段 sortOrder)。
final int sort;
/// 状态(0=正常 1=禁用)。
final int status;
/// 后端可能嵌入的内容原始数据。
final List<dynamic>? contents;
factory IntroSubcategory.fromJson(Map<String, dynamic> json) {
return IntroSubcategory(
id: json['id']?.toString() ?? '',
categoryId: json['categoryId']?.toString() ?? '',
categoryType: json['categoryType']?.toString() ?? '',
title: json['title']?.toString() ??
json['name']?.toString() ??
json['subcategoryTitle']?.toString() ??
'',
description: json['description']?.toString() ?? '',
mediaType: json['mediaType']?.toString() ?? '',
mediaUrl: json['mediaUrl']?.toString() ?? '',
sort: int.tryParse(json['sortOrder']?.toString() ??
json['sort']?.toString() ??
'0') ??
0,
status: IntroCategory._parseStatus(json['status']),
contents: json['contents'] is List
? List<dynamic>.from(json['contents'] as List)
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'categoryId': categoryId,
'categoryType': categoryType,
'title': title,
'description': description,
'mediaType': mediaType,
'mediaUrl': mediaUrl,
'sortOrder': sort,
'status': status,
'contents': contents,
};
}
}
/// 介绍页详情模型。
///
/// 对应后端 /company/introDetail/* 接口。
///
/// 后端实际字段:
/// - id, subcategoryId, subcategoryTitle, mediaType, mediaUrl,
/// title, description, status, sortOrder, createTime, updateTime
class IntroDetail {
const IntroDetail({
required this.id,
required this.subcategoryId,
required this.title,
required this.description,
required this.mediaType,
required this.mediaUrl,
required this.sort,
required this.status,
});
/// 详情 ID。
final String id;
/// 所属子分类 ID。
final String subcategoryId;
/// 标题。
final String title;
/// 描述/内容。
final String description;
/// 媒体类型:image / video。
final String mediaType;
/// 媒体地址。
final String mediaUrl;
/// 排序号(后端字段 sortOrder)。
final int sort;
/// 状态(0=正常 1=禁用)。
final int status;
factory IntroDetail.fromJson(Map<String, dynamic> json) {
return IntroDetail(
id: json['id']?.toString() ?? '',
subcategoryId: json['subcategoryId']?.toString() ?? '',
title: json['title']?.toString() ??
json['name']?.toString() ??
json['subcategoryTitle']?.toString() ??
'',
description: json['description']?.toString() ??
json['content']?.toString() ??
'',
mediaType: json['mediaType']?.toString() ?? '',
mediaUrl: json['mediaUrl']?.toString() ??
json['imageUrl']?.toString() ??
json['videoUrl']?.toString() ??
'',
sort: int.tryParse(json['sortOrder']?.toString() ??
json['sort']?.toString() ??
'0') ??
0,
status: IntroCategory._parseStatus(json['status']),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'subcategoryId': subcategoryId,
'title': title,
'description': description,
'mediaType': mediaType,
'mediaUrl': mediaUrl,
'sortOrder': sort,
'status': status,
};
}
}

View File

@@ -0,0 +1,132 @@
/// 滚动字幕配置参数:速度档位与级别。
///
/// 与 Web 后台 `/views/modules/theme/scrolling-text.data.ts` 保持一致,
/// 移动端仅展示档位文案,落库仍是原始数值,便于与 Web 端互相识别。
///
/// 速度(scrollSpeed,单位 ms/像素,数值越小滚动越快):
/// - 慢 = 200
/// - 较慢 = 100
/// - 中 = 50
/// - 较快 = 30
/// - 快 = 10
///
/// 级别(level):
/// - 危险 = 50
/// - 紧急 = 40
/// - 普通 = 20
class MarqueeConfig {
const MarqueeConfig({
this.speed = MarqueeSpeed.medium,
this.grade = MarqueeGrade.normal,
});
final MarqueeSpeed speed;
final MarqueeGrade grade;
/// 将任意历史 speed 值吸附到最近档位(兼容 Web 端精确值)。
static MarqueeSpeed speedFromValue(int? value) {
if (value == null) return MarqueeSpeed.medium;
final target = MarqueeSpeed.values.fold<MarqueeSpeed>(
MarqueeSpeed.values.first,
(closest, current) {
final diffClosest = (closest.value - value).abs();
final diffCurrent = (current.value - value).abs();
return diffCurrent < diffClosest ? current : closest;
},
);
return target;
}
/// 将任意历史 level 值吸附到最近级别。
static MarqueeGrade gradeFromValue(int? value) {
if (value == null) return MarqueeGrade.normal;
final target = MarqueeGrade.values.fold<MarqueeGrade>(
MarqueeGrade.values.first,
(closest, current) {
final diffClosest = (closest.value - value).abs();
final diffCurrent = (current.value - value).abs();
return diffCurrent < diffClosest ? current : closest;
},
);
return target;
}
MarqueeConfig setSpeed(MarqueeSpeed speed) =>
MarqueeConfig(speed: speed, grade: grade);
MarqueeConfig setGrade(MarqueeGrade grade) =>
MarqueeConfig(speed: speed, grade: grade);
MarqueeConfig copyWith({
MarqueeSpeed? speed,
MarqueeGrade? grade,
}) =>
MarqueeConfig(
speed: speed ?? this.speed,
grade: grade ?? this.grade,
);
}
/// 滚动速度档位。
enum MarqueeSpeed {
slow(200, '慢'),
slower(100, '较慢'),
medium(50, '中'),
faster(30, '较快'),
fast(10, '快');
const MarqueeSpeed(this.value, this.labelZh);
/// 后端落库值。
final int value;
/// 中文展示文案。
final String labelZh;
/// 英文展示文案。
String get labelEn {
switch (this) {
case MarqueeSpeed.slow:
return 'Slow';
case MarqueeSpeed.slower:
return 'Slower';
case MarqueeSpeed.medium:
return 'Medium';
case MarqueeSpeed.faster:
return 'Faster';
case MarqueeSpeed.fast:
return 'Fast';
}
}
String label(bool useEnglish) => useEnglish ? labelEn : labelZh;
}
/// 滚动字幕级别。
enum MarqueeGrade {
danger(50, '危险'),
urgent(40, '紧急'),
normal(20, '普通');
const MarqueeGrade(this.value, this.labelZh);
/// 后端落库值。
final int value;
/// 中文展示文案。
final String labelZh;
/// 英文展示文案。
String get labelEn {
switch (this) {
case MarqueeGrade.danger:
return 'Danger';
case MarqueeGrade.urgent:
return 'Urgent';
case MarqueeGrade.normal:
return 'Normal';
}
}
String label(bool useEnglish) => useEnglish ? labelEn : labelZh;
}

View File

@@ -0,0 +1,211 @@
/// 滚动字幕消息模型。
///
/// 对应 Web 后台「滚动字幕」表(tv_scrolling_text / theme_scrolling_text)
/// 以及管理模块「滚动字幕管理」中的单条字幕记录。
///
/// 字段映射:
/// - Web 端返回 `orgId` / `orgName`,本模型内部使用 `storeId` / `storeName`。
/// - Web 端返回 `scrollSpeed`,本模型内部使用 `speed`。
/// - Web 端返回 `bgColor`,本模型内部使用 `backgroundColor`。
/// - `status` 沿用 Web 端语义:`1=启用`,`0=禁用`。
class MarqueeMessage {
const MarqueeMessage({
required this.id,
required this.storeId,
required this.storeName,
required this.content,
required this.status,
required this.speed,
required this.createTime,
required this.updateTime,
this.fontColor,
this.backgroundColor,
this.fontSize,
this.level,
this.startTime,
this.endTime,
});
/// 字幕 ID。
final String id;
/// 所属门店 ID(对应 Web 端 orgId)。
final String storeId;
/// 所属门店名称(对应 Web 端 orgName)。
final String storeName;
/// 字幕内容。
final String content;
/// 状态:`1=启用`,`0=禁用`。
final int status;
/// 滚动速度(对应 Web 端 scrollSpeed,单位 ms/像素,越小越快)。
///
/// 档位映射:慢=200 / 较慢=100 / 中=50 / 较快=30 / 快=10。
final int speed;
/// 字体颜色(Hex,如 `#FFFFFF`)。
final String? fontColor;
/// 背景颜色(Hex,如 `#3B82F6`,对应 Web 端 bgColor)。
final String? backgroundColor;
/// 字体大小(px,如 32)。
final int? fontSize;
/// 字幕级别/优先级。
final int? level;
/// 开始生效时间(Web 端为 `HH:mm:ss` 字符串,本地存为时间)。
final DateTime? startTime;
/// 结束生效时间(Web 端为 `HH:mm:ss` 字符串,本地存为时间)。
final DateTime? endTime;
/// 创建时间。
final DateTime? createTime;
/// 更新时间。
final DateTime? updateTime;
bool get isEnabled => status == 1;
bool get isDisabled => status == 0;
factory MarqueeMessage.fromJson(Map<String, dynamic> json) {
return MarqueeMessage(
id: (json['id'] ?? json['marqueeId'])?.toString() ?? '',
storeId: json['storeId']?.toString() ??
json['orgId']?.toString() ??
json['org_id']?.toString() ??
'',
storeName: json['storeName']?.toString() ??
json['orgName']?.toString() ??
json['org_name']?.toString() ??
'',
content: json['content']?.toString() ?? '',
status: _parseInt(json['status'], fallback: 0) ?? 0,
speed: _parseInt(json['speed'], fallback: 50) ??
_parseInt(json['scrollSpeed'], fallback: 50) ??
_parseInt(json['scroll_speed'], fallback: 50) ??
50,
fontColor: json['fontColor']?.toString() ?? json['font_color']?.toString(),
backgroundColor: json['backgroundColor']?.toString() ??
json['bgColor']?.toString() ??
json['bg_color']?.toString(),
fontSize: _parseInt(json['fontSize'] ?? json['font_size']),
level: _parseInt(json['level']),
startTime: _parseDateTime(json['startTime'] ?? json['start_time']),
endTime: _parseDateTime(json['endTime'] ?? json['end_time']),
createTime: _parseDateTime(json['createTime'] ?? json['create_time']),
updateTime: _parseDateTime(json['updateTime'] ?? json['update_time']),
);
}
/// 转换为本地缓存使用的 JSON。
Map<String, dynamic> toJson() {
return {
'id': id,
'storeId': storeId,
'storeName': storeName,
'content': content,
'status': status,
'speed': speed,
if (fontColor != null) 'fontColor': fontColor,
if (backgroundColor != null) 'backgroundColor': backgroundColor,
if (fontSize != null) 'fontSize': fontSize,
if (level != null) 'level': level,
if (startTime != null) 'startTime': _formatTime(startTime!),
if (endTime != null) 'endTime': _formatTime(endTime!),
'createTime': createTime?.toIso8601String(),
'updateTime': updateTime?.toIso8601String(),
};
}
/// 转换为 Web 后台可接收的字段名(用于新增/编辑请求)。
Map<String, dynamic> toWebJson() {
return {
if (id.isNotEmpty) 'id': int.tryParse(id),
'orgId': int.tryParse(storeId) ?? storeId,
'orgName': storeName,
'content': content,
'scrollSpeed': speed,
if (fontColor != null) 'fontColor': fontColor,
if (backgroundColor != null) 'bgColor': backgroundColor,
if (fontSize != null) 'fontSize': fontSize,
if (level != null) 'level': level,
'status': status,
if (startTime != null) 'startTime': _formatTime(startTime!),
if (endTime != null) 'endTime': _formatTime(endTime!),
};
}
MarqueeMessage copyWith({
String? id,
String? storeId,
String? storeName,
String? content,
int? status,
int? speed,
String? fontColor,
String? backgroundColor,
int? fontSize,
int? level,
DateTime? startTime,
DateTime? endTime,
DateTime? createTime,
DateTime? updateTime,
}) {
return MarqueeMessage(
id: id ?? this.id,
storeId: storeId ?? this.storeId,
storeName: storeName ?? this.storeName,
content: content ?? this.content,
status: status ?? this.status,
speed: speed ?? this.speed,
fontColor: fontColor ?? this.fontColor,
backgroundColor: backgroundColor ?? this.backgroundColor,
fontSize: fontSize ?? this.fontSize,
level: level ?? this.level,
startTime: startTime ?? this.startTime,
endTime: endTime ?? this.endTime,
createTime: createTime ?? this.createTime,
updateTime: updateTime ?? this.updateTime,
);
}
/// 颜色字段兜底:当 Web 端未返回颜色时返回默认半透明色。
static const String defaultFontColor = '#FFFFFF';
static const String defaultBackgroundColor = '#3B82F6';
/// 兼容 `yyyy-MM-dd HH:mm:ss`、ISO 8601 以及纯 `HH:mm:ss` 时间字符串。
static DateTime? _parseDateTime(dynamic value) {
if (value == null) return null;
final text = value.toString().trim();
if (text.isEmpty) return null;
// 纯时间:补齐一个固定日期用于本地 DateTime 存储。
if (RegExp(r'^\d{2}:\d{2}:\d{2}$').hasMatch(text)) {
return DateTime.tryParse('2000-01-01 $text');
}
return DateTime.tryParse(text) ??
DateTime.tryParse(text.replaceFirst(' ', 'T'));
}
/// 时间字段序列化为 `HH:mm:ss`,与 Web 后台保持一致。
static String _formatTime(DateTime value) {
return '${value.hour.toString().padLeft(2, '0')}:'
'${value.minute.toString().padLeft(2, '0')}:'
'${value.second.toString().padLeft(2, '0')}';
}
/// 解析整数字段,兼容 int / String / null。
static int? _parseInt(dynamic value, {int? fallback}) {
if (value == null) return fallback;
if (value is int) return value;
if (value is String) return int.tryParse(value.trim()) ?? fallback;
return fallback;
}
}

128
lib/models/media_asset.dart Normal file
View File

@@ -0,0 +1,128 @@
/// 媒体资源模型。
///
/// 对应后端 /sys/mediaAsset/* 接口的媒体实体。
class MediaAsset {
const MediaAsset({
required this.id,
required this.code,
required this.name,
required this.type,
required this.coverUrl,
required this.playUrl,
required this.duration,
required this.status,
required this.createTime,
});
/// 媒体 ID。
final String id;
/// 媒体编码。
final String code;
/// 媒体名称。
final String name;
/// 媒体类型(video / image / audio 等)。
final String type;
/// 封面图地址。
final String coverUrl;
/// 播放地址。
final String playUrl;
/// 时长(秒)。
final int duration;
/// 状态(0=正常 1=禁用)。
final int status;
/// 创建时间。
final DateTime? createTime;
factory MediaAsset.fromJson(Map<String, dynamic> json) {
return MediaAsset(
id: json['id']?.toString() ?? '',
code: json['code']?.toString() ?? '',
name: json['name']?.toString() ?? '',
type: json['type']?.toString() ?? '',
coverUrl: json['coverUrl']?.toString() ?? '',
playUrl: json['playUrl']?.toString() ?? '',
duration: int.tryParse(json['duration']?.toString() ?? '0') ?? 0,
status: int.tryParse(json['status']?.toString() ?? '0') ?? 0,
createTime: _parseDateTime(json['createTime']),
);
}
/// 兼容 `yyyy-MM-dd HH:mm:ss` 与 ISO 8601 两种时间格式。
static DateTime? _parseDateTime(dynamic value) {
if (value == null) return null;
final text = value.toString().trim();
if (text.isEmpty) return null;
return DateTime.tryParse(text) ??
DateTime.tryParse(text.replaceFirst(' ', 'T'));
}
Map<String, dynamic> toJson() {
return {
'id': id,
'code': code,
'name': name,
'type': type,
'coverUrl': coverUrl,
'playUrl': playUrl,
'duration': duration,
'status': status,
'createTime': createTime?.toIso8601String(),
};
}
}
/// 媒体排行榜条目模型。
///
/// 对应后端 /sys/mediaAssetRank/* 接口的排行实体。
class MediaRankItem {
const MediaRankItem({
required this.id,
required this.code,
required this.mediaName,
required this.sortOrder,
required this.coverUrl,
});
/// 条目 ID。
final String id;
/// 媒体编码。
final String code;
/// 媒体名称。
final String mediaName;
/// 排序序号。
final int sortOrder;
/// 封面图地址。
final String coverUrl;
factory MediaRankItem.fromJson(Map<String, dynamic> json) {
return MediaRankItem(
id: json['id']?.toString() ?? '',
code: (json['code'] ?? json['mediaCode'])?.toString() ?? '',
mediaName: json['mediaName']?.toString() ?? '',
sortOrder: int.tryParse(json['sortOrder']?.toString() ?? '0') ?? 0,
coverUrl: json['coverUrl']?.toString() ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'code': code,
'mediaName': mediaName,
'sortOrder': sortOrder,
'coverUrl': coverUrl,
};
}
}

273
lib/models/org_node.dart Normal file
View File

@@ -0,0 +1,273 @@
/// 组织节点模型。
///
/// 对应后端 /sys/org/* 接口的组织实体,支持树形结构。
/// type 取值:0=平台 1=渠道 2=代理商 3=门店 4=设备分组。
class OrgNode {
const OrgNode({
required this.id,
required this.name,
required this.type,
required this.parentId,
this.children = const [],
required this.address,
required this.contactPhone,
required this.activationCode,
this.province = '',
this.city = '',
this.themeId,
DateTime? createdAt,
this.expireTime,
int? groupLimit,
this.orderNo = 0,
this.status = 'ACTIVE',
this.mpath = '',
}) : createdAt = createdAt,
groupLimit = groupLimit;
/// 组织节点 ID。
final String id;
/// 组织名称。
final String name;
/// 类型(0=平台 1=渠道 2=代理商 3=门店 4=设备分组)。
final int type;
/// 父级 ID。
final String parentId;
/// 子节点列表(树形结构)。
final List<OrgNode> children;
/// 地址。
final String address;
/// 联系电话。
final String contactPhone;
/// 省(新接口 /sys/org/tree 返回 province)。
final String province;
/// 市(新接口 /sys/org/tree 返回 city)。
final String city;
/// 主题 ID(新接口 /sys/org/tree 返回 themeId)。
final int? themeId;
/// 激活码。
final String activationCode;
/// 创建时间(组织树接口返回 createdAt,ISO8601 字符串)。
///
/// 门店列表需按建店时间倒序排列,故从组织树节点透传该字段;
/// 老记录可能缺省(null),此时排序回退到 orgId 数值倒序。
final DateTime? createdAt;
/// 到期时间(组织树/门店接口可能返回 expireTime/expire_time/expiredTime)。
///
/// 门店预览列表需展示到期状态(已过期 / 无到期时间),故从组织树节点透传该字段。
final DateTime? expireTime;
/// 设备总数上限(组织树接口返回 groupLimit)。
///
/// 「建店时设置的设备上限」需回显并随门店保存,故从组织树节点透传。
final int? groupLimit;
/// 排序号(组织接口返回 orderNo,渠道商管理页按此排序展示)。
final int orderNo;
/// 状态:'ACTIVE'=启用,'DISABLED'=禁用。
///
/// 后端可能返回字符串(ACTIVE/DISABLED)或整型(0=正常/1=禁用,
/// 与 [AppVersion.status] 约定一致),统一规整为字符串便于 UI 判断。
final String status;
/// 组织编码(mpath 路径编码,渠道商管理页「组织编码」列展示)。
///
/// 后端未返回时回退为空串,UI 层再降级显示节点 id。
final String mpath;
/// 后端组织类型字符串到数字的映射。
/// 旧接口用 int:0=平台 1=渠道 2=代理商 3=门店 4=设备分组。
/// 新接口 /sys/org/tree 用字符串 type:CHANNEL / SERVICE_PROVIDER / STORE / GROUP。
/// 兼容 `yyyy-MM-dd HH:mm:ss` 与 ISO 8601(含时区、毫秒)两种时间格式。
static DateTime? _parseDateTime(dynamic value) {
if (value == null) return null;
final text = value.toString().trim();
if (text.isEmpty) return null;
return DateTime.tryParse(text) ??
DateTime.tryParse(text.replaceFirst(' ', 'T'));
}
/// 解析可空整型(设备上限 groupLimit 等)。
static int? _parseInt(dynamic value) {
if (value == null) return null;
return int.tryParse(value.toString());
}
static int _parseType(dynamic value) {
if (value == null) return 0;
if (value is int) return value;
final s = value.toString().toUpperCase();
switch (s) {
case 'CHANNEL':
case '渠道':
return 1;
case 'SERVICE_PROVIDER':
case '代理商':
return 2;
case 'STORE':
case '门店':
return 3;
case 'GROUP':
case '设备分组':
return 4;
default:
return int.tryParse(s) ?? 0;
}
}
/// 解析组织状态,统一规整为 'ACTIVE' / 'DISABLED'。
///
/// 兼容后端两种形态:字符串(ACTIVE/DISABLED、启用/禁用)与整型
/// (与 [AppVersion.status] 一致:0=正常 1=禁用)。
static String _parseStatus(dynamic value) {
if (value == null) return 'ACTIVE';
if (value is int) return value == 0 ? 'ACTIVE' : 'DISABLED';
final s = value.toString().toUpperCase();
if (s == 'DISABLED' || s == '禁用') return 'DISABLED';
return 'ACTIVE';
}
factory OrgNode.fromJson(Map<String, dynamic> json) {
final childrenRaw = json['list'] ?? json['children'];
return OrgNode(
// 对齐 Web 端 normalizeOrg:id 兼容 orgId / org_id,parentId 兼容 parent_id,
// 避免后端偶发返回 orgId/parent_id 时节点 id 为空导致展开/编辑/删除失效。
id: json['id']?.toString() ??
json['orgId']?.toString() ??
json['org_id']?.toString() ??
'',
name: json['name']?.toString() ?? '',
type: _parseType(json['type']),
parentId: json['parentId']?.toString() ??
json['parent_id']?.toString() ??
'',
children: childrenRaw is List
? childrenRaw
.whereType<Map<String, dynamic>>()
.map(OrgNode.fromJson)
.toList()
: const <OrgNode>[],
address: json['address']?.toString() ?? '',
contactPhone: json['contactPhone']?.toString() ?? json['phone']?.toString() ?? '',
activationCode: json['activationCode']?.toString() ?? '',
createdAt: _parseDateTime(
json['createdAt'] ??
json['createTime'] ??
json['created_time'] ??
json['created_at'],
),
expireTime: _parseDateTime(
json['expireTime'] ?? json['expire_time'] ?? json['expiredTime'],
),
groupLimit: _parseInt(
json['groupLimit'] ?? json['deviceLimit'] ?? json['device_limit'],
),
province: json['province']?.toString() ?? '',
city: json['city']?.toString() ?? '',
themeId: json['themeId'] is int
? json['themeId'] as int
: int.tryParse(json['themeId']?.toString() ?? ''),
orderNo: _parseInt(json['orderNo'] ?? json['order_no']) ?? 0,
status: _parseStatus(json['status']),
mpath: json['mpath']?.toString() ??
json['orgCode']?.toString() ??
json['org_code']?.toString() ??
'',
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'type': type,
'parentId': parentId,
'children': children.map((e) => e.toJson()).toList(),
'address': address,
'contactPhone': contactPhone,
'activationCode': activationCode,
'province': province,
'city': city,
'themeId': themeId,
'orderNo': orderNo,
'status': status,
'mpath': mpath,
'expireTime': expireTime?.toIso8601String(),
};
}
/// 不可变拷贝(主要用于渠道商管理页给节点挂载子节点树)。
OrgNode copyWith({
String? id,
String? name,
int? type,
String? parentId,
List<OrgNode>? children,
String? address,
String? contactPhone,
String? activationCode,
String? province,
String? city,
int? themeId,
DateTime? createdAt,
DateTime? expireTime,
int? groupLimit,
int? orderNo,
String? status,
String? mpath,
}) {
return OrgNode(
id: id ?? this.id,
name: name ?? this.name,
type: type ?? this.type,
parentId: parentId ?? this.parentId,
children: children ?? this.children,
address: address ?? this.address,
contactPhone: contactPhone ?? this.contactPhone,
activationCode: activationCode ?? this.activationCode,
province: province ?? this.province,
city: city ?? this.city,
themeId: themeId ?? this.themeId,
createdAt: createdAt ?? this.createdAt,
expireTime: expireTime ?? this.expireTime,
groupLimit: groupLimit ?? this.groupLimit,
orderNo: orderNo ?? this.orderNo,
status: status ?? this.status,
mpath: mpath ?? this.mpath,
);
}
/// 类型展示文案(渠道商管理页徽章用)。
String get typeLabel {
switch (type) {
case 1:
return '渠道';
case 2:
return '代理';
case 3:
return '门店';
case 4:
return '设备分组';
default:
return '平台';
}
}
/// 是否已禁用。
bool get isDisabled => status.toUpperCase() == 'DISABLED';
/// 「组织编码」列展示值:优先 mpath,缺省时降级为节点 id。
String get orgCodeLabel => mpath.isNotEmpty ? mpath : id;
}

75
lib/models/schedule.dart Normal file
View File

@@ -0,0 +1,75 @@
/// 定时任务模型。
///
/// 对应后端 /sys/schedule/* 接口的定时任务实体。
/// status 取值:0=正常 1=暂停。
class Schedule {
const Schedule({
required this.jobId,
required this.beanName,
required this.methodName,
required this.params,
required this.cronExpression,
required this.status,
required this.remark,
required this.createTime,
});
/// 任务 ID。
final String jobId;
/// Spring Bean 名称。
final String beanName;
/// 方法名称。
final String methodName;
/// 方法参数。
final String params;
/// Cron 表达式。
final String cronExpression;
/// 状态(0=正常 1=暂停)。
final int status;
/// 备注。
final String remark;
/// 创建时间。
final DateTime? createTime;
factory Schedule.fromJson(Map<String, dynamic> json) {
return Schedule(
jobId: (json['jobId'] ?? json['id'])?.toString() ?? '',
beanName: json['beanName']?.toString() ?? '',
methodName: json['methodName']?.toString() ?? '',
params: json['params']?.toString() ?? '',
cronExpression: json['cronExpression']?.toString() ?? '',
status: int.tryParse(json['status']?.toString() ?? '0') ?? 0,
remark: json['remark']?.toString() ?? '',
createTime: _parseDateTime(json['createTime']),
);
}
/// 兼容 `yyyy-MM-dd HH:mm:ss` 与 ISO 8601 两种时间格式。
static DateTime? _parseDateTime(dynamic value) {
if (value == null) return null;
final text = value.toString().trim();
if (text.isEmpty) return null;
return DateTime.tryParse(text) ??
DateTime.tryParse(text.replaceFirst(' ', 'T'));
}
Map<String, dynamic> toJson() {
return {
'jobId': jobId,
'beanName': beanName,
'methodName': methodName,
'params': params,
'cronExpression': cronExpression,
'status': status,
'remark': remark,
'createTime': createTime?.toIso8601String(),
};
}
}

View File

@@ -0,0 +1,59 @@
/// 门店配置(到期时间 / 禁用状态等)。
///
/// 对应后端 `GET /device/storeConfig/info/{orgId}`,与 Web 后台「门店配置」弹窗
/// 同源(参考 screen-front `src/api/device.ts` 的 `getStoreConfig`)。
///
/// 重要:组织树 `/sys/org/tree` 与通用门店列表接口(`myStores` / `listByLoginOrg`)
/// 通常**不返回** `expireTime`;移动端门店的到期时间必须由此端点补全,
/// 否则会恒为「无到期时间」。
class StoreConfig {
const StoreConfig({
this.expireTime,
this.mediaVolume,
this.disabled = false,
});
/// 到期时间(null 表示不限/未设置)。
final DateTime? expireTime;
/// 媒体区域音量(0-100,null 表示未设置)。
final int? mediaVolume;
/// 是否禁用(后端用 bool 或 '1'/0)。
final bool disabled;
factory StoreConfig.fromJson(Map<String, dynamic> json) {
final rawDisabled = json['disabled'];
final disabled = rawDisabled == true ||
(rawDisabled is num && rawDisabled != 0) ||
(rawDisabled is String && rawDisabled == '1');
return StoreConfig(
expireTime: _parseDateTime(
json['expireTime'] ?? json['expire_time'] ?? json['expiredTime'],
),
mediaVolume: _parseInt(json['mediaVolume'] ?? json['media_volume']),
disabled: disabled,
);
}
/// 空配置(接口异常/无数据时的占位)。
static StoreConfig empty() => const StoreConfig();
/// 兼容 `yyyy-MM-dd HH:mm:ss` 与 ISO 8601 两种时间格式。
static DateTime? _parseDateTime(dynamic value) {
if (value == null) return null;
final text = value.toString().trim();
if (text.isEmpty) return null;
return DateTime.tryParse(text) ??
DateTime.tryParse(text.replaceFirst(' ', 'T'));
}
/// 兼容 int / num / String 的整数解析(null 安全)。
static int? _parseInt(dynamic value) {
if (value == null) return null;
if (value is int) return value;
final text = value.toString().trim();
if (text.isEmpty) return null;
return int.tryParse(text);
}
}

219
lib/models/store_info.dart Normal file
View File

@@ -0,0 +1,219 @@
import '../data/region_data.dart';
/// 门店信息模型。
///
/// 对应后端 /sys/store/* 接口的门店实体。
///
/// 扩展字段([themeId]/[splashUrl]/[logoUrl]/省市区/[addressDetail])均为可空,
/// 向后兼容既有后端响应;这些字段仅在「前端占位」阶段由本地缓存承载,
/// 后续由后端提供后自动填充。
class StoreInfo {
const StoreInfo({
required this.orgId,
required this.name,
required this.address,
required this.contactPhone,
required this.activationCode,
required this.status,
required this.expireTime,
required this.createTime,
this.parentId,
this.themeId,
this.splashUrl,
this.logoUrl,
this.provinceCode,
this.cityCode,
this.districtCode,
this.addressDetail,
int? deviceLimit,
}) : deviceLimit = deviceLimit;
/// 门店组织 ID。
final String orgId;
/// 父级组织 ID(所属渠道/代理商)。
final String? parentId;
/// 门店名称。
final String name;
/// 地址(兼容旧文本地址;新结构优先用省市区拼接)。
final String address;
/// 联系电话。
final String contactPhone;
/// 激活码。
final String activationCode;
/// 状态(0=正常 1=禁用)。
final int status;
/// 到期时间。
final DateTime? expireTime;
/// 创建时间。
final DateTime? createTime;
/// 设备总数上限(组织树 groupLimit / 激活码 maxActivateCount)。
final int? deviceLimit;
/// 主题 id(如 'ocean');前端占位,对应 [StoreThemePresets.byId]。
final String? themeId;
/// 启动图本地路径 / 远程 URL(占位)。
final String? splashUrl;
/// Logo 本地路径 / 远程 URL(占位)。
final String? logoUrl;
/// 省 code(6 位)。
final String? provinceCode;
/// 市 code。
final String? cityCode;
/// 区 code。
final String? districtCode;
/// 街道/门牌等详情文本。
final String? addressDetail;
factory StoreInfo.fromJson(Map<String, dynamic> json) {
return StoreInfo(
orgId: (json['orgId'] ?? json['id'])?.toString() ?? '',
parentId: json['parentId']?.toString() ??
json['parent_id']?.toString() ??
(json['parent'] is Map ? json['parent']['id']?.toString() : null),
name: json['name']?.toString() ?? '',
address: json['address']?.toString() ?? '',
contactPhone: json['contactPhone']?.toString() ?? '',
activationCode: json['activationCode']?.toString() ?? '',
status: int.tryParse(json['status']?.toString() ?? '0') ?? 0,
expireTime: _parseDateTime(
json['expireTime'] ?? json['expire_time'] ?? json['expiredTime'],
),
createTime: _parseDateTime(
json['createTime'] ??
json['create_time'] ??
json['createdTime'] ??
json['createdAt'] ??
json['created_at'],
),
deviceLimit: _parseInt(
json['groupLimit'] ?? json['deviceLimit'] ?? json['device_limit'],
),
themeId: json['themeId']?.toString(),
splashUrl: json['splashUrl']?.toString(),
logoUrl: json['logoUrl']?.toString(),
provinceCode: json['provinceCode']?.toString(),
cityCode: json['cityCode']?.toString(),
districtCode: json['districtCode']?.toString(),
addressDetail: json['addressDetail']?.toString(),
);
}
/// 兼容 `yyyy-MM-dd HH:mm:ss` 与 ISO 8601 两种时间格式。
static DateTime? _parseDateTime(dynamic value) {
if (value == null) return null;
final text = value.toString().trim();
if (text.isEmpty) return null;
return DateTime.tryParse(text) ??
DateTime.tryParse(text.replaceFirst(' ', 'T'));
}
/// 解析可空整型(设备上限等)。
static int? _parseInt(dynamic value) {
if (value == null) return null;
return int.tryParse(value.toString());
}
Map<String, dynamic> toJson() {
return {
'orgId': orgId,
'parentId': parentId,
'name': name,
'address': address,
'contactPhone': contactPhone,
'activationCode': activationCode,
'status': status,
'expireTime': expireTime?.toIso8601String(),
'createTime': createTime?.toIso8601String(),
'deviceLimit': deviceLimit,
'themeId': themeId,
'splashUrl': splashUrl,
'logoUrl': logoUrl,
'provinceCode': provinceCode,
'cityCode': cityCode,
'districtCode': districtCode,
'addressDetail': addressDetail,
};
}
/// 不可变更新副本(仅覆盖传入的非空参数)。
///
/// 用于门店列表加载后,用 [OrgService.getStoreListByLoginOrg] 回填的
/// 到期时间等非组织树字段,避免重建整个对象。
StoreInfo copyWith({
String? orgId,
String? parentId,
String? name,
String? address,
String? contactPhone,
String? activationCode,
int? status,
DateTime? expireTime,
DateTime? createTime,
int? deviceLimit,
String? themeId,
String? splashUrl,
String? logoUrl,
String? provinceCode,
String? cityCode,
String? districtCode,
String? addressDetail,
}) {
return StoreInfo(
orgId: orgId ?? this.orgId,
parentId: parentId ?? this.parentId,
name: name ?? this.name,
address: address ?? this.address,
contactPhone: contactPhone ?? this.contactPhone,
activationCode: activationCode ?? this.activationCode,
status: status ?? this.status,
expireTime: expireTime ?? this.expireTime,
createTime: createTime ?? this.createTime,
deviceLimit: deviceLimit ?? this.deviceLimit,
themeId: themeId ?? this.themeId,
splashUrl: splashUrl ?? this.splashUrl,
logoUrl: logoUrl ?? this.logoUrl,
provinceCode: provinceCode ?? this.provinceCode,
cityCode: cityCode ?? this.cityCode,
districtCode: districtCode ?? this.districtCode,
addressDetail: addressDetail ?? this.addressDetail,
);
}
/// 拼接展示地址:省名 + 市名 + 区名 + 详情文本。
///
/// 若已设置省市区则优先用编码反查名称拼接;否则回退到旧文本 [address]。
String get regionText {
final names = <String>[];
if (provinceCode != null && provinceCode!.isNotEmpty) {
final p = RegionData.instance.nameOf(provinceCode!);
if (p.isNotEmpty) names.add(p);
}
if (cityCode != null && cityCode!.isNotEmpty) {
final c = RegionData.instance.nameOf(cityCode!);
if (c.isNotEmpty) names.add(c);
}
if (districtCode != null && districtCode!.isNotEmpty) {
final d = RegionData.instance.nameOf(districtCode!);
if (d.isNotEmpty) names.add(d);
}
final detail = (addressDetail ?? '').trim();
if (detail.isNotEmpty) names.add(detail);
if (names.isEmpty) return address;
return names.join('');
}
}

View File

@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
/// 门店主题/皮肤枚举。
///
/// 仅定义「id + 名称 + 主色」,UI 统一引用 [id](`themeId`),不硬编码颜色。
/// 当前为前端占位实现(纯色块 + 名称),后续可扩展为内置占位图。
enum StoreTheme {
/// 经典(默认)。
classic,
/// 海洋蓝。
ocean,
/// 森林绿。
forest,
/// 日落橙。
sunset,
/// 极简灰。
mono;
/// 主题 id(小写字符串,用于网络传输与本地缓存)。
String get id => name;
/// 主题中文名称。
String get label {
switch (this) {
case StoreTheme.classic:
return '经典';
case StoreTheme.ocean:
return '海洋蓝';
case StoreTheme.forest:
return '森林绿';
case StoreTheme.sunset:
return '日落橙';
case StoreTheme.mono:
return '极简灰';
}
}
/// 主题主色(用于色块预览与模拟主页头部)。
Color get primary {
switch (this) {
case StoreTheme.classic:
return const Color(0xFF1565C0);
case StoreTheme.ocean:
return const Color(0xFF00838F);
case StoreTheme.forest:
return const Color(0xFF2E7D32);
case StoreTheme.sunset:
return const Color(0xFFE65100);
case StoreTheme.mono:
return const Color(0xFF455A64);
}
}
}
/// 内置主题清单与查询工具。
class StoreThemePresets {
StoreThemePresets._();
/// 全部内置主题(顺序即展示顺序)。
static const List<StoreTheme> all = StoreTheme.values;
/// 按 id 查询主题;找不到返回 null。
static StoreTheme? byId(String? id) {
if (id == null || id.isEmpty) return null;
for (final theme in all) {
if (theme.id == id) return theme;
}
return null;
}
}

View File

@@ -0,0 +1,41 @@
/// 系统参数配置模型。
///
/// 对应后端 /sys/config/* 接口的配置实体。
class SysConfig {
const SysConfig({
required this.id,
required this.paramKey,
required this.paramValue,
required this.remark,
});
/// 主键 ID。
final String id;
/// 参数键名。
final String paramKey;
/// 参数值。
final String paramValue;
/// 备注。
final String remark;
factory SysConfig.fromJson(Map<String, dynamic> json) {
return SysConfig(
id: json['id']?.toString() ?? '',
paramKey: json['paramKey']?.toString() ?? '',
paramValue: json['paramValue']?.toString() ?? '',
remark: json['remark']?.toString() ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'paramKey': paramKey,
'paramValue': paramValue,
'remark': remark,
};
}
}

74
lib/models/sys_log.dart Normal file
View File

@@ -0,0 +1,74 @@
/// 系统操作日志模型。
///
/// 对应后端 /sys/log/* 接口的日志实体。
class SysLog {
const SysLog({
required this.id,
required this.username,
required this.operation,
required this.method,
required this.params,
required this.time,
required this.ip,
required this.createDate,
});
/// 日志 ID。
final String id;
/// 操作用户名。
final String username;
/// 操作描述。
final String operation;
/// 请求方法(类名#方法名)。
final String method;
/// 请求参数。
final String params;
/// 执行时长(毫秒)。
final int time;
/// 操作 IP。
final String ip;
/// 创建时间。
final DateTime? createDate;
factory SysLog.fromJson(Map<String, dynamic> json) {
return SysLog(
id: json['id']?.toString() ?? '',
username: json['username']?.toString() ?? '',
operation: json['operation']?.toString() ?? '',
method: json['method']?.toString() ?? '',
params: json['params']?.toString() ?? '',
time: int.tryParse(json['time']?.toString() ?? '0') ?? 0,
ip: json['ip']?.toString() ?? '',
createDate: _parseDateTime(json['createDate']),
);
}
/// 兼容 `yyyy-MM-dd HH:mm:ss` 与 ISO 8601 两种时间格式。
static DateTime? _parseDateTime(dynamic value) {
if (value == null) return null;
final text = value.toString().trim();
if (text.isEmpty) return null;
return DateTime.tryParse(text) ??
DateTime.tryParse(text.replaceFirst(' ', 'T'));
}
Map<String, dynamic> toJson() {
return {
'id': id,
'username': username,
'operation': operation,
'method': method,
'params': params,
'time': time,
'ip': ip,
'createDate': createDate?.toIso8601String(),
};
}
}

72
lib/models/sys_menu.dart Normal file
View File

@@ -0,0 +1,72 @@
/// 系统菜单模型。
///
/// 对应后端 /sys/menu/* 接口的菜单实体。
/// type 取值:0=目录 1=菜单 2=按钮。
class SysMenu {
const SysMenu({
required this.menuId,
required this.name,
required this.url,
required this.type,
required this.icon,
required this.parentId,
required this.orderNum,
required this.perms,
required this.show,
});
/// 菜单 ID。
final String menuId;
/// 菜单名称。
final String name;
/// 路由地址。
final String url;
/// 类型(0=目录 1=菜单 2=按钮)。
final int type;
/// 图标。
final String icon;
/// 父级 ID。
final String parentId;
/// 排序号。
final int orderNum;
/// 权限标识。
final String perms;
/// 是否显示(0=显示 1=隐藏)。
final int show;
factory SysMenu.fromJson(Map<String, dynamic> json) {
return SysMenu(
menuId: (json['menuId'] ?? json['id'])?.toString() ?? '',
name: json['name']?.toString() ?? '',
url: json['url']?.toString() ?? '',
type: int.tryParse(json['type']?.toString() ?? '0') ?? 0,
icon: json['icon']?.toString() ?? '',
parentId: json['parentId']?.toString() ?? '',
orderNum: int.tryParse(json['orderNum']?.toString() ?? '0') ?? 0,
perms: json['perms']?.toString() ?? '',
show: int.tryParse(json['show']?.toString() ?? '0') ?? 0,
);
}
Map<String, dynamic> toJson() {
return {
'menuId': menuId,
'name': name,
'url': url,
'type': type,
'icon': icon,
'parentId': parentId,
'orderNum': orderNum,
'perms': perms,
'show': show,
};
}
}

84
lib/models/sys_role.dart Normal file
View File

@@ -0,0 +1,84 @@
/// 系统角色模型。
///
/// 对应后端 /sys/role/* 接口的角色实体。
class SysRole {
const SysRole({
required this.roleId,
required this.roleName,
required this.remark,
required this.createTime,
});
/// 角色 ID。
final String roleId;
/// 角色名称。
final String roleName;
/// 备注。
final String remark;
/// 创建时间。
final DateTime? createTime;
factory SysRole.fromJson(Map<String, dynamic> json) {
return SysRole(
roleId: (json['roleId'] ?? json['id'])?.toString() ?? '',
roleName: json['roleName']?.toString() ?? '',
remark: json['remark']?.toString() ?? '',
createTime: _parseDateTime(json['createTime']),
);
}
/// 兼容 `yyyy-MM-dd HH:mm:ss` 与 ISO 8601 两种时间格式。
static DateTime? _parseDateTime(dynamic value) {
if (value == null) return null;
final text = value.toString().trim();
if (text.isEmpty) return null;
return DateTime.tryParse(text) ??
DateTime.tryParse(text.replaceFirst(' ', 'T'));
}
Map<String, dynamic> toJson() {
return {
'roleId': roleId,
'roleName': roleName,
'remark': remark,
'createTime': createTime?.toIso8601String(),
};
}
}
/// 角色-菜单关联模型(用于角色权限分配)。
class RoleMenu {
const RoleMenu({
required this.id,
required this.roleId,
required this.menuId,
});
/// 主键 ID。
final String id;
/// 角色 ID。
final String roleId;
/// 菜单 ID。
final String menuId;
factory RoleMenu.fromJson(Map<String, dynamic> json) {
return RoleMenu(
id: json['id']?.toString() ?? '',
roleId: json['roleId']?.toString() ?? '',
menuId: json['menuId']?.toString() ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'roleId': roleId,
'menuId': menuId,
};
}
}

218
lib/models/sys_user.dart Normal file
View File

@@ -0,0 +1,218 @@
/// 系统用户模型。
///
/// 对应后端 /sys/user/* 接口的用户实体。
class SysUser {
const SysUser({
required this.id,
required this.username,
required this.realName,
required this.email,
required this.mobile,
required this.status,
required this.roleId,
required this.roleName,
required this.roleNames,
required this.orgId,
required this.orgName,
required this.createTime,
});
/// 用户 ID。
final String id;
/// 登录账号。
final String username;
/// 真实姓名。
final String realName;
/// 邮箱。
final String email;
/// 手机号。
final String mobile;
/// 状态(0=正常 1=禁用)。
final int status;
/// 角色 ID。
final String roleId;
/// 角色名称。
final String roleName;
/// 角色名称列表(后端部分接口返回 roleNames 数组)。
final List<String> roleNames;
/// 所属组织 ID。
final String orgId;
/// 所属组织名称(门店 / 渠道商 / 代理商名称)。
final String orgName;
/// 创建时间。
final DateTime? createTime;
factory SysUser.fromJson(Map<String, dynamic> json) {
final roleList = _parseStringList(json['roleIdList'] ?? json['roleIds']);
final roleNameFromObj = _extractNestedName(
json['roles'] ?? json['role'] ?? json['sysRoles'] ?? json['sysRole'],
const ['roleName', 'name', 'role'],
);
final roleIdFromObj = _extractNestedName(
json['roles'] ?? json['role'] ?? json['sysRoles'] ?? json['sysRole'],
const ['roleId', 'id'],
);
final roleNames = _parseStringList(
json['roleNames'] ?? json['roleName'] ?? roleNameFromObj,
);
final roleId = json['roleId']?.toString() ??
roleIdFromObj ??
(roleList.isNotEmpty ? roleList.first : '');
final orgNameFromObj = _extractNestedName(
json['org'] ??
json['dept'] ??
json['sysOrg'] ??
json['sysDept'] ??
json['organization'],
const ['orgName', 'name', 'deptName', 'org'],
);
final orgIdFromObj = _extractNestedName(
json['org'] ??
json['dept'] ??
json['sysOrg'] ??
json['sysDept'] ??
json['organization'],
const ['orgId', 'id', 'deptId'],
);
return SysUser(
id: json['id']?.toString() ?? json['userId']?.toString() ?? '',
username: json['username']?.toString() ?? '',
realName: json['realName']?.toString() ?? '',
email: json['email']?.toString() ?? '',
mobile: json['mobile']?.toString() ?? '',
status: _parseStatus(json['status']),
roleId: roleId,
roleName: json['roleName']?.toString() ??
(roleNames.isNotEmpty ? roleNames.first : ''),
roleNames: roleNames.isNotEmpty ? roleNames : roleList,
orgId: json['orgId']?.toString() ?? orgIdFromObj ?? '',
orgName: json['orgName']?.toString() ??
json['storeName']?.toString() ??
json['deptName']?.toString() ??
orgNameFromObj ??
'',
createTime: _parseDateTime(json['createTime']),
);
}
/// 从嵌套对象或对象列表中提取名称/ID 字段。
/// 用于兼容后端把角色/组织以对象/对象数组返回的情况。
static String? _extractNestedName(dynamic value, List<String> keys) {
if (value is Map) {
final map = value as Map<String, dynamic>;
for (final key in keys) {
final v = map[key]?.toString();
if (v != null && v.isNotEmpty) return v;
}
}
if (value is List && value.isNotEmpty) {
return _extractNestedName(value.first, keys);
}
return null;
}
/// 兼容 int / String / bool 三种状态表示:
/// - int: 1=正常, 0=禁用(新后台 /sys/user/list 返回 status=1 为启用)
/// - String: '0'/'正常'/'启用'/'true'=正常, 其他=禁用
/// - bool: true=正常, false=禁用
static int _parseStatus(dynamic value) {
if (value == null) return 0;
if (value is bool) return value ? 0 : 1;
if (value is int) {
// 新后台:1=启用/正常,0=禁用/停用。
return value == 1 ? 0 : 1;
}
final text = value.toString().trim().toLowerCase();
if (text.isEmpty ||
text == '0' ||
text == '正常' ||
text == '启用' ||
text == 'true' ||
text == 'ok' ||
text == 'enabled' ||
text == '1') {
return 0;
}
return 1;
}
static List<String> _parseStringList(dynamic value) {
if (value is List) {
return value.map((e) => e?.toString() ?? '').where((e) => e.isNotEmpty).toList();
}
if (value is String && value.isNotEmpty) {
return [value];
}
return const <String>[];
}
/// 兼容 `yyyy-MM-dd HH:mm:ss` 与 ISO 8601 两种时间格式。
static DateTime? _parseDateTime(dynamic value) {
if (value == null) return null;
final text = value.toString().trim();
if (text.isEmpty) return null;
return DateTime.tryParse(text) ??
DateTime.tryParse(text.replaceFirst(' ', 'T'));
}
Map<String, dynamic> toJson() {
return {
'id': id,
'username': username,
'realName': realName,
'email': email,
'mobile': mobile,
'status': status,
'roleId': roleId,
'roleName': roleName,
'roleNames': roleNames,
'orgId': orgId,
'orgName': orgName,
'createTime': createTime?.toIso8601String(),
};
}
/// 复制并覆盖指定字段,用于列表层批量补全角色信息(/_enrichRoles)。
SysUser copyWith({
String? id,
String? username,
String? realName,
String? email,
String? mobile,
int? status,
String? roleId,
String? roleName,
List<String>? roleNames,
String? orgId,
String? orgName,
DateTime? createTime,
}) {
return SysUser(
id: id ?? this.id,
username: username ?? this.username,
realName: realName ?? this.realName,
email: email ?? this.email,
mobile: mobile ?? this.mobile,
status: status ?? this.status,
roleId: roleId ?? this.roleId,
roleName: roleName ?? this.roleName,
roleNames: roleNames ?? this.roleNames,
orgId: orgId ?? this.orgId,
orgName: orgName ?? this.orgName,
createTime: createTime ?? this.createTime,
);
}
}

View File

@@ -0,0 +1,94 @@
/// 门店分组下的主题页面(对应 web 后台 /theme/page/org/{orgId}/photos)。
class ThemePage {
const ThemePage({
required this.themeId,
required this.name,
this.themePhoto,
this.active = false,
this.orderNo,
});
/// 主题 ID(tv_theme.id)。
final int themeId;
/// 主题名称。
final String name;
/// 主题预览图 URL。
final String? themePhoto;
/// 是否为当前分组激活主题。
final bool active;
/// 排序号。
final int? orderNo;
factory ThemePage.fromJson(Map<String, dynamic> json) {
final rawActive = json['active'];
return ThemePage(
themeId: (json['themeId'] ?? json['theme_id'] ?? json['id'] ?? 0) as int,
name: (json['name'] ?? json['themeName'] ?? '')?.toString() ?? '',
themePhoto: (json['themePhoto'] ?? json['theme_photo'])?.toString(),
active: rawActive == true ||
(rawActive is num && rawActive.toInt() == 1) ||
(rawActive is String && rawActive.toLowerCase() == 'true'),
orderNo: (json['orderNo'] ?? json['order_no']) is num
? ((json['orderNo'] ?? json['order_no']) as num).toInt()
: null,
);
}
}
/// 门店设备分组及其主题列表(界面配置)。
class ThemeGroup {
const ThemeGroup({
required this.groupId,
required this.groupName,
required this.themes,
this.themeLimit = 6,
});
/// 设备分组 ID。
final int groupId;
/// 设备分组名称。
final String groupName;
/// 分组下主题数量上限。
final int themeLimit;
/// 分组下已添加的主题列表。
final List<ThemePage> themes;
factory ThemeGroup.fromJson(Map<String, dynamic> json) {
final themesRaw = json['themes'];
final themes = themesRaw is List
? themesRaw
.whereType<Map<String, dynamic>>()
.map(ThemePage.fromJson)
.where((t) => t.themeId > 0)
.toList()
: <ThemePage>[];
themes.sort((a, b) => (a.orderNo ?? 0).compareTo(b.orderNo ?? 0));
final rawLimit = json['themeLimit'] ?? json['theme_limit'];
final themeLimit = rawLimit is num && rawLimit.toInt() > 0
? rawLimit.toInt()
: 6;
return ThemeGroup(
groupId: (json['groupId'] ?? json['group_id'] ?? 0) as int,
groupName: (json['groupName'] ?? json['group_name'] ?? '')?.toString() ??
'未命名分组',
themeLimit: themeLimit,
themes: themes,
);
}
/// 当前激活的主题名称(空表示未激活任何主题)。
String? get activeThemeName {
final active = themes.where((t) => t.active).toList();
if (active.isEmpty) return null;
return active.first.name;
}
}

View File

@@ -0,0 +1,55 @@
/// 主题模板(对应 web 后台 /theme/themetemplate/* 接口)。
///
/// 用于门店表单中「主题皮肤」选择;门店的 themeId 通常即为模板 id。
class ThemeTemplate {
const ThemeTemplate({
required this.id,
required this.name,
this.description,
this.coverUrl,
this.status = 'ENABLED',
});
/// 模板 ID(对应门店 themeId)。
final String id;
/// 模板名称。
final String name;
/// 模板描述。
final String? description;
/// 封面预览图 URL。
final String? coverUrl;
/// 状态:ENABLED / DISABLED。
final String status;
factory ThemeTemplate.fromJson(Map<String, dynamic> json) {
return ThemeTemplate(
id: json['id']?.toString() ??
json['templateId']?.toString() ??
json['themeTemplateId']?.toString() ??
'',
name: json['name']?.toString() ??
json['templateName']?.toString() ??
'',
description: json['description']?.toString(),
coverUrl: json['coverUrl']?.toString() ??
json['cover_url']?.toString(),
status: json['status']?.toString() ?? 'ENABLED',
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
if (description != null) 'description': description,
if (coverUrl != null) 'coverUrl': coverUrl,
'status': status,
};
}
bool get isEnabled => status.toUpperCase() == 'ENABLED';
}

80
lib/models/tv_app.dart Normal file
View File

@@ -0,0 +1,80 @@
/// TV 应用模型。
///
/// 对应后端 /theme/tvapp/* 接口的 TV 应用实体。
class TvApp {
const TvApp({
required this.id,
required this.appName,
required this.packageName,
required this.appCategory,
required this.iconUrl,
required this.orgId,
required this.groupId,
required this.status,
required this.createTime,
});
/// 应用 ID。
final String id;
/// 应用名称。
final String appName;
/// 包名。
final String packageName;
/// 应用分类。
final String appCategory;
/// 图标地址。
final String iconUrl;
/// 组织 ID。
final String orgId;
/// 设备分组 ID。
final String groupId;
/// 状态(0=正常 1=禁用)。
final int status;
/// 创建时间。
final DateTime? createTime;
factory TvApp.fromJson(Map<String, dynamic> json) {
return TvApp(
id: json['id']?.toString() ?? '',
appName: json['appName']?.toString() ?? '',
packageName: json['packageName']?.toString() ?? '',
appCategory: json['appCategory']?.toString() ?? '',
iconUrl: json['iconUrl']?.toString() ?? '',
orgId: json['orgId']?.toString() ?? '',
groupId: json['groupId']?.toString() ?? '',
status: int.tryParse(json['status']?.toString() ?? '0') ?? 0,
createTime: _parseDateTime(json['createTime']),
);
}
/// 兼容 `yyyy-MM-dd HH:mm:ss` 与 ISO 8601 两种时间格式。
static DateTime? _parseDateTime(dynamic value) {
if (value == null) return null;
final text = value.toString().trim();
if (text.isEmpty) return null;
return DateTime.tryParse(text) ??
DateTime.tryParse(text.replaceFirst(' ', 'T'));
}
Map<String, dynamic> toJson() {
return {
'id': id,
'appName': appName,
'packageName': packageName,
'appCategory': appCategory,
'iconUrl': iconUrl,
'orgId': orgId,
'groupId': groupId,
'status': status,
'createTime': createTime?.toIso8601String(),
};
}
}

76
lib/models/user.dart Normal file
View File

@@ -0,0 +1,76 @@
/// 用户模型。
///
/// 字段由 [AuthService] 从真实登录响应(/sys/login)与
/// 当前用户接口(/sys/user/info)映射而来。
class User {
const User({
required this.id,
required this.account,
required this.displayName,
required this.role,
required this.token,
this.orgId = '',
this.roleId = '',
this.orgType = 0,
this.superAdmin = false,
});
/// 用户唯一标识(真实模式下取 orgId,缺省回退为账号)。
final String id;
/// 登录账号。
final String account;
/// 显示名称。
final String displayName;
/// 角色(如:运维管理员;多个角色以 `/` 连接)。
final String role;
/// 登录令牌。
final String token;
/// 当前用户所属组织 ID(用于表单默认选中)。
final String orgId;
/// 当前用户角色 ID(用于表单默认选中)。
final String roleId;
/// 当前用户所属组织类型(来自登录响应 orgType):
/// 0=平台(管理员) 1=渠道(渠道商) 2=代理商。
/// 用于按账号类别显隐功能模块(用户管理 / 渠道商管理)。
final int orgType;
/// 是否超级管理员(来自登录响应 superAdmin);为 true 视为管理员。
final bool superAdmin;
/// 序列化为 JSON(用于本地持久化)。
Map<String, dynamic> toJson() => {
'id': id,
'account': account,
'displayName': displayName,
'role': role,
'token': token,
'orgId': orgId,
'roleId': roleId,
'orgType': orgType,
'superAdmin': superAdmin,
};
/// 从 JSON 反序列化。
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id']?.toString() ?? '',
account: json['account']?.toString() ?? '',
displayName: json['displayName']?.toString() ?? '',
role: json['role']?.toString() ?? '',
token: json['token']?.toString() ?? '',
orgId: json['orgId']?.toString() ?? '',
roleId: json['roleId']?.toString() ?? '',
orgType: json['orgType'] is int
? json['orgType'] as int
: int.tryParse(json['orgType']?.toString() ?? '') ?? 0,
superAdmin: json['superAdmin'] is bool
? json['superAdmin'] as bool
: false,
);
}

View File

@@ -0,0 +1,158 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/user.dart';
import '../services/api_client.dart';
import '../services/auth_service.dart';
import '../utils/credential_storage.dart';
import '../utils/session_org_scope.dart';
/// AuthService 提供者。
final authServiceProvider = Provider<AuthService>((ref) => AuthService());
/// 登录状态。
class AuthState {
const AuthState({
this.user,
this.isLoading = false,
this.errorMessage,
});
/// 当前登录用户;为 null 表示未登录。
final User? user;
/// 是否正在执行登录请求。
final bool isLoading;
/// 最近一次登录失败的错误信息。
final String? errorMessage;
/// 是否已登录。
bool get isLoggedIn => user != null;
AuthState copyWith({
User? user,
bool? isLoading,
String? errorMessage,
bool clearUser = false,
bool clearError = false,
}) {
return AuthState(
user: clearUser ? null : (user ?? this.user),
isLoading: isLoading ?? this.isLoading,
errorMessage: clearError ? null : (errorMessage ?? this.errorMessage),
);
}
}
/// 登录状态管理。
class AuthNotifier extends Notifier<AuthState> {
@override
AuthState build() {
// 注册会话失效回调:401 刷新失败 → 清空登录态(含本地持久化),
// 路由 redirect 监听本状态,自动跳转登录页。
ApiClient.instance.onUnauthorized = () {
SessionOrgScope.instance.clear();
unawaited(_clearPersistedSession());
state = const AuthState();
};
// 尝试从本地存储恢复登录态(免登录)。
return _tryRestoreSession();
}
/// 从本地存储恢复登录态。
AuthState _tryRestoreSession() {
final cs = CredentialStorage.instance;
final token = cs.token;
final userMap = cs.savedUserMap;
if (token != null && token.isNotEmpty && userMap != null) {
// 恢复 TokenStore(供 ApiInterceptor 使用)。
TokenStore.instance.save(
token: token,
refreshToken: cs.refreshToken,
);
try {
final user = User.fromJson(userMap);
// 恢复数据权限范围:仅当登录用户 orgId 非空时收敛。
SessionOrgScope.instance.setCurrentOrg(user.orgId);
return AuthState(user: user);
} catch (_) {
// JSON 解析失败,清除残留数据。
_clearPersistedSession();
}
}
return const AuthState();
}
/// 执行登录;成功返回 true,失败返回 false 并写入错误信息。
///
/// [captcha]/[uuid] 仅在后端要求验证码时传值;当前各环境已关闭登录验证码,
/// 调用方(登录页)可不传,由 [AuthService.login] 决定是否写入请求体。
Future<bool> login({
required String account,
required String password,
String captcha = '',
String uuid = '',
bool rememberPassword = false,
}) async {
state = state.copyWith(isLoading: true, clearError: true);
try {
final user = await ref.read(authServiceProvider).login(
account: account,
password: password,
captcha: captcha,
uuid: uuid,
);
// 持久化登录态(token + 用户信息),用于免登录恢复。
await CredentialStorage.instance.saveSession(
token: user.token,
refreshToken: TokenStore.instance.refreshToken,
userJson: jsonEncode(user.toJson()),
);
// 记住密码。
if (rememberPassword) {
await CredentialStorage.instance.saveCredentials(
account: account,
password: password,
);
} else {
await CredentialStorage.instance.clearCredentials();
}
state = AuthState(user: user, isLoading: false);
// 登录成功后写入数据权限范围,供 OrgService 客户端收敛组织/门店数据。
SessionOrgScope.instance.setCurrentOrg(user.orgId);
return true;
} on AuthException catch (e) {
state = state.copyWith(isLoading: false, errorMessage: e.message);
return false;
} catch (_) {
state = state.copyWith(isLoading: false, errorMessage: '登录失败,请稍后重试');
return false;
}
}
/// 登出并清空登录态。
Future<void> logout() async {
await ref.read(authServiceProvider).logout();
SessionOrgScope.instance.clear();
await _clearPersistedSession();
state = const AuthState();
}
/// 清除本地持久化的登录态。
Future<void> _clearPersistedSession() async {
await CredentialStorage.instance.clearSession();
}
}
/// 登录状态提供者。
final authProvider = NotifierProvider<AuthNotifier, AuthState>(
AuthNotifier.new,
);

View File

@@ -0,0 +1,106 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/device.dart';
import '../services/device_service.dart';
import '../services/org_service.dart';
import '../utils/session_org_scope.dart';
import 'devices_provider.dart';
/// 仪表盘统计数据。
class DashboardStats {
const DashboardStats({
this.deviceTotal = 0,
this.storeTotal = 0,
this.onlineCount = 0,
this.offlineCount = 0,
});
final int deviceTotal;
final int storeTotal;
final int onlineCount;
final int offlineCount;
DashboardStats copyWith({
int? deviceTotal,
int? storeTotal,
int? onlineCount,
int? offlineCount,
}) {
return DashboardStats(
deviceTotal: deviceTotal ?? this.deviceTotal,
storeTotal: storeTotal ?? this.storeTotal,
onlineCount: onlineCount ?? this.onlineCount,
offlineCount: offlineCount ?? this.offlineCount,
);
}
}
/// 仪表盘统计状态。
class DashboardStatsNotifier extends StateNotifier<DashboardStats> {
DashboardStatsNotifier(this._deviceService, this._orgService)
: super(const DashboardStats());
final DeviceService _deviceService;
final OrgService _orgService;
Future<void> load() async {
try {
int deviceTotal = 0;
int onlineCount = 0;
int offlineCount = 0;
if (SessionOrgScope.instance.enabled) {
// 受限账号:仅统计其组织子树内的设备。
// 设备接口可能忽略 orgId,故改为「拉取列表 + 按 storeId 在范围内收敛」,
// 杜绝跨组织设备越权统计(只会少计、不会多计或泄露其他组织数据)。
final ids = await _orgService.scopedOrgIdSet();
if (ids.isNotEmpty) {
final devices = await _deviceService.fetchDevices(limit: 1000);
final scoped =
devices.where((d) => ids.contains(d.storeId)).toList();
deviceTotal = scoped.length;
onlineCount =
scoped.where((d) => d.status == DeviceStatus.online).length;
offlineCount =
scoped.where((d) => d.status == DeviceStatus.offline).length;
}
} else {
deviceTotal = await _deviceService.fetchDeviceTotalCount();
// 若后端未返回 totalCount,则拉取一页列表用其长度兜底。
if (deviceTotal <= 0) {
final devices = await _deviceService.fetchDevices(limit: 100);
deviceTotal = devices.length;
}
// 在线/离线:分别按 status 拉 totalCount(O(1),无需遍历全量设备)。
final results = await Future.wait<int>([
_deviceService.fetchDeviceCountByStatus(status: 'online'),
_deviceService.fetchDeviceCountByStatus(status: 'offline'),
]);
onlineCount = results[0];
offlineCount = results[1];
}
// 门店总数:优先组织树,失败回退门店接口(fetchStoreCount 内部已含回退链)。
final storeTotal = await _orgService.fetchStoreCount();
state = state.copyWith(
deviceTotal: deviceTotal,
storeTotal: storeTotal,
onlineCount: onlineCount,
offlineCount: offlineCount,
);
} catch (_) {
// 统计失败不阻断页面,保持旧值或 0。
}
}
}
final orgServiceProvider = Provider<OrgService>((ref) => OrgService());
final dashboardStatsProvider =
StateNotifierProvider<DashboardStatsNotifier, DashboardStats>(
(ref) => DashboardStatsNotifier(
ref.watch(deviceServiceProvider),
ref.watch(orgServiceProvider),
),
);

View File

@@ -0,0 +1,33 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/alert.dart';
import '../models/device.dart';
import '../services/device_service.dart';
/// DeviceService 提供者。
final deviceServiceProvider =
Provider<DeviceService>((ref) => DeviceService());
/// 设备列表提供者(下拉刷新时调用 [ProviderRef.refresh] 即可)。
final devicesProvider = FutureProvider<List<Device>>((ref) {
return ref.watch(deviceServiceProvider).fetchDevices();
});
/// 按门店筛选的设备列表提供者(参数为门店 orgId,null 表示全部门店)。
final devicesByStoreProvider =
FutureProvider.family<List<Device>, String?>((ref, storeOrgId) {
return ref
.watch(deviceServiceProvider)
.fetchDevices(storeOrgId: storeOrgId);
});
/// 设备详情提供者(按设备 ID 参数化)。
final deviceDetailProvider =
FutureProvider.family<Device, String>((ref, deviceId) {
return ref.watch(deviceServiceProvider).fetchDeviceById(deviceId);
});
/// 告警列表提供者。
final alertsProvider = FutureProvider<List<Alert>>((ref) {
return ref.watch(deviceServiceProvider).fetchAlerts();
});

View File

@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// 显示方向偏好:跟随系统 / 竖屏 / 横屏。
///
/// 与主题、语言并列,作为「我的」页可手动切换的显示设置;切换后通过
/// [SystemChrome.setPreferredOrientations] 实时生效,并重渲染界面(横屏沿用
/// NavigationRail 布局,竖屏沿用 NavigationBar 布局,二者共用同一套橙白主题,
/// 视觉风格保持一致)。
enum DisplayOrientation {
/// 跟随系统(默认):允许全部四个方向,由设备物理朝向决定。
system,
/// 仅竖屏。
portrait,
/// 仅横屏。
landscape,
}
const String _kDisplayOrientation = 'display_orientation';
/// 将偏好映射为 [SystemChrome] 需要的设备方向列表。
List<DeviceOrientation> deviceOrientationsFor(DisplayOrientation mode) {
switch (mode) {
case DisplayOrientation.portrait:
return const [DeviceOrientation.portraitUp];
case DisplayOrientation.landscape:
return const [DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight];
case DisplayOrientation.system:
return const [
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
];
}
}
/// 立即应用显示方向(调用 [SystemChrome.setPreferredOrientations])。
void applyDisplayOrientation(DisplayOrientation mode) {
SystemChrome.setPreferredOrientations(deviceOrientationsFor(mode));
}
/// 从持久化读取已保存偏好;异常或无记录时返回 [DisplayOrientation.system]。
Future<DisplayOrientation> loadSavedOrientation() async {
try {
final prefs = await SharedPreferences.getInstance();
final v = prefs.getString(_kDisplayOrientation);
if (v != null) {
for (final e in DisplayOrientation.values) {
if (e.name == v) return e;
}
}
} catch (_) {
// 忽略:SharedPreferences 不可用时退化为系统。
}
return DisplayOrientation.system;
}
/// 显示方向偏好管理(持久化 + 实时生效)。
class DisplayOrientationNotifier extends Notifier<DisplayOrientation> {
@override
DisplayOrientation build() {
// 首选项在 main 启动期已应用;这里异步回读以保证 UI 选中态与持久值一致。
_load();
return DisplayOrientation.system;
}
Future<void> _load() async {
final saved = await loadSavedOrientation();
if (saved != state) {
state = saved;
applyDisplayOrientation(saved);
}
}
/// 切换显示方向:更新状态、即时生效并持久化。
Future<void> set(DisplayOrientation mode) async {
state = mode;
applyDisplayOrientation(mode);
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kDisplayOrientation, mode.name);
} catch (_) {
// 忽略持久化失败,运行时仍生效。
}
}
}
/// 显示方向偏好提供者。
final displayOrientationProvider =
NotifierProvider<DisplayOrientationNotifier, DisplayOrientation>(
DisplayOrientationNotifier.new,
);

View File

@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../utils/store_local_cache.dart';
/// 应用支持的语言。
enum AppLocale {
/// 简体中文(默认)。
zh,
/// 英语。
en,
}
/// [AppLocale] 的辅助方法。
extension AppLocaleX on AppLocale {
/// 标准 BCP-47 locale 标识。
Locale get locale {
switch (this) {
case AppLocale.zh:
return const Locale('zh', 'CN');
case AppLocale.en:
return const Locale('en', 'US');
}
}
/// 设置页展示的中文名。
String get label {
switch (this) {
case AppLocale.zh:
return '简体中文';
case AppLocale.en:
return 'English';
}
}
/// 设置页展示的英文名。
String get labelEn {
switch (this) {
case AppLocale.zh:
return 'Chinese (Simplified)';
case AppLocale.en:
return 'English';
}
}
/// 持久化存储用的字符串键。
String get storageKey {
switch (this) {
case AppLocale.zh:
return 'zh_CN';
case AppLocale.en:
return 'en_US';
}
}
}
/// 当前语言提供者(默认简体中文)。
///
/// 通过 [LocaleNotifier.setLocale] 切换,并持久化到本地缓存,
/// 下次启动自动恢复。
class LocaleNotifier extends Notifier<AppLocale> {
static const String _storageKey = 'app_locale';
@override
AppLocale build() {
final saved = StoreLocalCache.instance.getString(_storageKey);
if (saved == AppLocale.en.storageKey) {
return AppLocale.en;
}
return AppLocale.zh;
}
/// 设置语言并持久化。
Future<void> setLocale(AppLocale locale) async {
if (state == locale) return;
state = locale;
await StoreLocalCache.instance.setString(_storageKey, locale.storageKey);
}
}
/// 语言提供者实例。
final localeProvider = NotifierProvider<LocaleNotifier, AppLocale>(
LocaleNotifier.new,
);

View File

@@ -0,0 +1,51 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// 持久化键:是否显示设备详情页的运行指标(CPU / 内存 / 磁盘 / 网络)。
const String _kShowRuntimeMetrics = 'show_runtime_metrics';
/// 读取已保存的显示偏好;默认返回 false(即默认隐藏运行指标)。
Future<bool> loadSavedShowRuntimeMetrics() async {
try {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_kShowRuntimeMetrics) ?? false;
} catch (_) {
return false;
}
}
/// 设备详情页运行指标显示偏好管理。
///
/// - 默认关闭,首次进入设备详情时不展示 CPU/内存/磁盘/网络卡片。
/// - 用户可在「我的 → 设置」中手动开启。
/// - 状态持久化到 [SharedPreferences],切换账号/重启 App 后仍保持。
class ShowRuntimeMetricsNotifier extends Notifier<bool> {
@override
bool build() {
_load();
return false;
}
Future<void> _load() async {
final saved = await loadSavedShowRuntimeMetrics();
if (saved != state) {
state = saved;
}
}
/// 切换显示状态并持久化。
Future<void> setValue(bool value) async {
state = value;
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kShowRuntimeMetrics, value);
} catch (_) {
// 忽略持久化失败,运行时状态仍生效。
}
}
}
/// 运行指标显示偏好提供者。
final showRuntimeMetricsProvider = NotifierProvider<ShowRuntimeMetricsNotifier, bool>(
ShowRuntimeMetricsNotifier.new,
);

View File

@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 主题模式管理,默认跟随系统(logo 明度均值 245/255,「明亮轻盈」才是品牌主场)。
class ThemeModeNotifier extends Notifier<ThemeMode> {
@override
ThemeMode build() => ThemeMode.system;
/// 设置主题模式。
void setThemeMode(ThemeMode mode) {
state = mode;
}
/// 在浅色 / 深色之间切换。
void toggle() {
state = state == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark;
}
}
/// 主题模式提供者。
final themeModeProvider = NotifierProvider<ThemeModeNotifier, ThemeMode>(
ThemeModeNotifier.new,
);

View File

@@ -0,0 +1,122 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../providers/auth_provider.dart';
import '../../utils/i10n.dart';
import '../../utils/permissions.dart';
import '../../utils/responsive.dart';
/// 管理中心页(底部导航第 4 个 tab):各管理模块入口。
///
/// 模块可见性由 [SessionPermissions] 统一驱动,与 [router.dart] 的路由可达性
/// 保持一致:
/// - 用户管理:所有已登录账号可见(数据范围由后端按会话权限收敛);
/// - 渠道商管理:管理员 + 渠道商可见,代理商不可见;
/// - 其余模块(设备/激活码/公司介绍/滚动字幕)对所有账号可见。
class AdminHubPage extends ConsumerWidget {
const AdminHubPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final t = AppLocalizations.of(context);
// 注:按需求「设备分组」整合进设备管理功能(设备详情/设备列表内可改分组),
// 「应用管理」移除以简化管理页。故网格不再单列这两项。
final scheme = Theme.of(context).colorScheme;
final user = ref.watch(authProvider).user;
// 菜单可见性与路由可达性由同一套 SessionPermissions 谓词驱动:
// - 用户管理:所有已登录账号(各角色看自己范围内用户);
// - 渠道商管理:管理员 + 渠道商(代理商不可见,路由层亦会拦截其深链)。
final showUserManagement = SessionPermissions.canViewUserManagement(user);
final showChannelManagement =
SessionPermissions.canViewChannelManagement(user);
final items = [
_MenuItem(
title: t.deviceManagement,
icon: Icons.devices_outlined,
color: scheme.primary,
route: '/admin/device',
),
_MenuItem(
title: t.activationCode,
icon: Icons.vpn_key_outlined,
color: Colors.amber,
route: '/admin/activation',
),
if (showUserManagement)
_MenuItem(
title: t.userManagement,
icon: Icons.people_outlined,
color: scheme.outline,
route: '/admin/sys/user',
),
_MenuItem(
title: t.companyIntro,
icon: Icons.business_outlined,
color: Colors.teal,
route: '/admin/intro/store-list',
),
_MenuItem(
title: t.marqueeManagement,
icon: Icons.subtitles_outlined,
color: Colors.deepPurple,
route: '/admin/marquee',
),
if (showChannelManagement)
_MenuItem(
title: t.channelManagement,
icon: Icons.account_tree_outlined,
color: Colors.blue,
route: '/admin/channel',
),
];
return Scaffold(
appBar: AppBar(title: Text(t.admin)),
body: GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: context.responsive.gridCount(minTileWidth: 300),
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1.2,
),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return Card(
child: InkWell(
onTap: () => context.push(item.route),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(item.icon, size: 40, color: item.color),
const SizedBox(height: 8),
Text(
item.title,
style: Theme.of(context).textTheme.titleMedium,
),
],
),
),
);
},
),
);
}
}
class _MenuItem {
const _MenuItem({
required this.title,
required this.icon,
required this.color,
required this.route,
});
final String title;
final IconData icon;
final Color color;
final String route;
}

View File

@@ -0,0 +1,358 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/app_version.dart';
import '../../../services/app_service.dart';
import '../../../services/api_client.dart';
import '../../../utils/formatters.dart';
/// 应用版本管理页。
class AppVersionPage extends ConsumerStatefulWidget {
const AppVersionPage({super.key});
@override
ConsumerState<AppVersionPage> createState() => _AppVersionPageState();
}
class _AppVersionPageState extends ConsumerState<AppVersionPage> {
final _searchController = TextEditingController();
final _scrollController = ScrollController();
List<AppVersion> _items = [];
bool _loading = false;
bool _hasMore = true;
int _page = 1;
String? _error;
String _keyword = '';
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_loadData();
}
@override
void dispose() {
_searchController.dispose();
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200 &&
!_loading &&
_hasMore) {
_loadMore();
}
}
Future<void> _loadData() async {
setState(() {
_loading = true;
_error = null;
});
try {
final list = await AppService().getAppVersionList(page: 1, limit: 20);
if (!mounted) return;
setState(() {
_items = list;
_loading = false;
_hasMore = list.length >= 20;
_page = 1;
});
} catch (e) {
if (!mounted) return;
setState(() {
_loading = false;
_error = _friendlyError(e);
});
}
}
Future<void> _loadMore() async {
if (_loading) return;
setState(() => _loading = true);
try {
final list = await AppService().getAppVersionList(page: _page + 1, limit: 20);
if (!mounted) return;
setState(() {
_items.addAll(list);
_loading = false;
_hasMore = list.length >= 20;
_page++;
});
} catch (_) {
if (!mounted) return;
setState(() => _loading = false);
}
}
Future<void> _refresh() async {
_page = 1;
_hasMore = true;
await _loadData();
}
String _friendlyError(Object e) {
if (e is ApiException) return e.message;
final msg = e.toString();
if (msg.contains('404') ||
msg.contains('DioException') ||
msg.contains('SocketException')) {
return '应用版本接口暂不可用,请联系管理员确认权限或接口地址';
}
return '加载失败:$msg';
}
void _showForm({AppVersion? editing}) {
final isEdit = editing != null;
final appNameCtrl = TextEditingController(text: editing?.appName ?? '');
final packageCtrl = TextEditingController(text: editing?.packageName ?? '');
final versionNameCtrl = TextEditingController(text: editing?.versionName ?? '');
final versionCodeCtrl =
TextEditingController(text: editing?.versionCode.toString() ?? '');
final apkUrlCtrl = TextEditingController(text: editing?.apkUrl ?? '');
final orgIdCtrl = TextEditingController(text: editing?.orgId ?? '');
final groupIdCtrl = TextEditingController(text: editing?.groupId ?? '');
int statusValue = editing?.status ?? 0;
showModalBottomSheet(
useSafeArea: true,
context: context,
isScrollControlled: true,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setSheet) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(ctx).viewInsets.bottom,
),
child: Padding(
padding: const EdgeInsets.all(16),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(isEdit ? '编辑版本' : '新增版本',
style: Theme.of(ctx).textTheme.titleMedium),
const SizedBox(height: 16),
TextField(
controller: appNameCtrl,
decoration: const InputDecoration(
labelText: '应用名称', border: OutlineInputBorder())),
const SizedBox(height: 12),
TextField(
controller: packageCtrl,
decoration: const InputDecoration(
labelText: '包名', border: OutlineInputBorder())),
const SizedBox(height: 12),
TextField(
controller: versionNameCtrl,
decoration: const InputDecoration(
labelText: '版本名', border: OutlineInputBorder())),
const SizedBox(height: 12),
TextField(
controller: versionCodeCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '版本号', border: OutlineInputBorder())),
const SizedBox(height: 12),
TextField(
controller: apkUrlCtrl,
decoration: const InputDecoration(
labelText: 'APK下载地址', border: OutlineInputBorder())),
const SizedBox(height: 12),
TextField(
controller: orgIdCtrl,
decoration: const InputDecoration(
labelText: '组织ID', border: OutlineInputBorder())),
const SizedBox(height: 12),
TextField(
controller: groupIdCtrl,
decoration: const InputDecoration(
labelText: '分组ID', border: OutlineInputBorder())),
const SizedBox(height: 12),
DropdownButtonFormField<int>(
value: statusValue,
decoration: const InputDecoration(
labelText: '状态', border: OutlineInputBorder()),
items: const [
DropdownMenuItem(value: 0, child: Text('正常')),
DropdownMenuItem(value: 1, child: Text('禁用')),
],
onChanged: (v) {
if (v != null) setSheet(() => statusValue = v);
},
),
const SizedBox(height: 16),
FilledButton(
onPressed: () async {
final data = <String, dynamic>{
if (isEdit) 'id': editing!.id,
'appName': appNameCtrl.text,
'packageName': packageCtrl.text,
'versionName': versionNameCtrl.text,
'versionCode': int.tryParse(versionCodeCtrl.text) ?? 0,
'apkUrl': apkUrlCtrl.text,
'orgId': orgIdCtrl.text,
'groupId': groupIdCtrl.text,
'status': statusValue,
};
try {
if (isEdit) {
await AppService().updateAppVersion(data);
} else {
await AppService().saveAppVersion(data);
}
if (!ctx.mounted) return;
Navigator.pop(ctx);
_refresh();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('保存成功')),
);
} catch (e) {
if (!ctx.mounted) return;
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(content: Text(e is ApiException ? e.message : '保存失败')),
);
}
},
child: const Text('保存'),
),
],
),
),
),
),
),
);
}
Future<void> _delete(String id) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('确认删除'),
content: const Text('确定要删除此版本吗?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('删除')),
],
),
);
if (confirmed != true) return;
try {
await AppService().deleteAppVersion([int.tryParse(id) ?? 0]);
_refresh();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('删除成功')));
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e is ApiException ? e.message : '删除失败')),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(automaticallyImplyLeading: false, title: const Text('应用版本管理')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: '搜索应用名称',
prefixIcon: const Icon(Icons.search),
suffixIcon: _keyword.isEmpty
? null
: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() => _keyword = '');
},
),
),
onSubmitted: (v) => setState(() => _keyword = v),
),
),
Expanded(
child: _error != null
? Center(child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!),
const SizedBox(height: 8),
FilledButton.tonal(onPressed: _refresh, child: const Text('重试')),
],
))
: RefreshIndicator(
onRefresh: _refresh,
child: _items.isEmpty && !_loading
? const Center(child: Text('暂无数据'))
: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _items.length + (_loading ? 1 : 0),
itemBuilder: (ctx, i) {
if (i >= _items.length) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
final item = _items[i];
if (_keyword.isNotEmpty &&
!item.appName.toLowerCase().contains(_keyword.toLowerCase())) {
return const SizedBox.shrink();
}
return Card(
child: ListTile(
title: Text(item.appName,
style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text('包名: ${item.packageName}'),
Text('版本: ${item.versionName} (${item.versionCode})'),
if (item.createTime != null)
Text('创建: ${Formatters.formatDateTime(item.createTime!)}'),
const SizedBox(height: 4),
Chip(
label: Text(item.status == 0 ? '正常' : '禁用'),
visualDensity: VisualDensity.compact,
),
],
),
trailing: PopupMenuButton<String>(
onSelected: (v) {
if (v == 'edit') _showForm(editing: item);
if (v == 'delete') _delete(item.id);
},
itemBuilder: (_) => [
const PopupMenuItem(value: 'edit', child: Text('编辑')),
const PopupMenuItem(value: 'delete', child: Text('删除')),
],
),
),
);
},
),
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showForm(),
child: const Icon(Icons.add),
),
);
}
}

View File

@@ -0,0 +1,346 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/tv_app.dart';
import '../../../services/app_service.dart';
import '../../../services/api_client.dart';
/// TV端应用管理页。
class TvAppPage extends ConsumerStatefulWidget {
const TvAppPage({super.key});
@override
ConsumerState<TvAppPage> createState() => _TvAppPageState();
}
class _TvAppPageState extends ConsumerState<TvAppPage> {
final _searchController = TextEditingController();
final _scrollController = ScrollController();
List<TvApp> _items = [];
bool _loading = false;
bool _hasMore = true;
int _page = 1;
String? _error;
String _keyword = '';
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_loadData();
}
@override
void dispose() {
_searchController.dispose();
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200 &&
!_loading &&
_hasMore) {
_loadMore();
}
}
Future<void> _loadData() async {
setState(() {
_loading = true;
_error = null;
});
try {
final kw = _keyword.trim().isEmpty ? null : _keyword.trim();
final list = await AppService().getTvAppList(page: 1, limit: 20, appName: kw);
if (!mounted) return;
setState(() {
_items = list;
_loading = false;
_hasMore = list.length >= 20;
_page = 1;
});
} catch (e) {
if (!mounted) return;
setState(() {
_loading = false;
_error = e is ApiException ? e.message : '加载失败';
});
}
}
Future<void> _loadMore() async {
if (_loading) return;
setState(() => _loading = true);
try {
final kw = _keyword.trim().isEmpty ? null : _keyword.trim();
final list = await AppService().getTvAppList(page: _page + 1, limit: 20, appName: kw);
if (!mounted) return;
setState(() {
_items.addAll(list);
_loading = false;
_hasMore = list.length >= 20;
_page++;
});
} catch (_) {
if (!mounted) return;
setState(() => _loading = false);
}
}
Future<void> _refresh() async {
_page = 1;
_hasMore = true;
await _loadData();
}
void _showForm({TvApp? editing}) {
final isEdit = editing != null;
final nameCtrl = TextEditingController(text: editing?.appName ?? '');
final pkgCtrl = TextEditingController(text: editing?.packageName ?? '');
final catCtrl = TextEditingController(text: editing?.appCategory ?? '');
final iconCtrl = TextEditingController(text: editing?.iconUrl ?? '');
final orgIdCtrl = TextEditingController(text: editing?.orgId ?? '');
final groupIdCtrl = TextEditingController(text: editing?.groupId ?? '');
int statusValue = editing?.status ?? 0;
showModalBottomSheet(
useSafeArea: true,
context: context,
isScrollControlled: true,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setSheet) => Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(ctx).viewInsets.bottom),
child: Padding(
padding: const EdgeInsets.all(16),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(isEdit ? '编辑应用' : '新增应用',
style: Theme.of(ctx).textTheme.titleMedium),
const SizedBox(height: 16),
TextField(
controller: nameCtrl,
decoration: const InputDecoration(
labelText: '应用名称', border: OutlineInputBorder())),
const SizedBox(height: 12),
TextField(
controller: pkgCtrl,
decoration: const InputDecoration(
labelText: '包名', border: OutlineInputBorder())),
const SizedBox(height: 12),
TextField(
controller: catCtrl,
decoration: const InputDecoration(
labelText: '分类', border: OutlineInputBorder())),
const SizedBox(height: 12),
TextField(
controller: iconCtrl,
decoration: const InputDecoration(
labelText: '图标地址', border: OutlineInputBorder())),
const SizedBox(height: 12),
TextField(
controller: orgIdCtrl,
decoration: const InputDecoration(
labelText: '组织ID', border: OutlineInputBorder())),
const SizedBox(height: 12),
TextField(
controller: groupIdCtrl,
decoration: const InputDecoration(
labelText: '分组ID', border: OutlineInputBorder())),
const SizedBox(height: 12),
DropdownButtonFormField<int>(
value: statusValue,
decoration: const InputDecoration(
labelText: '状态', border: OutlineInputBorder()),
items: const [
DropdownMenuItem(value: 0, child: Text('正常')),
DropdownMenuItem(value: 1, child: Text('禁用')),
],
onChanged: (v) {
if (v != null) setSheet(() => statusValue = v);
},
),
const SizedBox(height: 16),
FilledButton(
onPressed: () async {
final data = <String, dynamic>{
if (isEdit) 'id': editing!.id,
'appName': nameCtrl.text,
'packageName': pkgCtrl.text,
'appCategory': catCtrl.text,
'iconUrl': iconCtrl.text,
'orgId': orgIdCtrl.text,
'groupId': groupIdCtrl.text,
'status': statusValue,
};
try {
await AppService().saveTvApp(data);
if (!ctx.mounted) return;
Navigator.pop(ctx);
_refresh();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('保存成功')),
);
} catch (e) {
if (!ctx.mounted) return;
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(content: Text(e is ApiException ? e.message : '保存失败')),
);
}
},
child: const Text('保存'),
),
],
),
),
),
),
),
);
}
Future<void> _delete(String id) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('确认删除'),
content: const Text('确定要删除此应用吗?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('删除')),
],
),
);
if (confirmed != true) return;
try {
await AppService().deleteTvApp([int.tryParse(id) ?? 0]);
_refresh();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('删除成功')));
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e is ApiException ? e.message : '删除失败')),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(automaticallyImplyLeading: false, title: const Text('TV端应用管理')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: '搜索应用名称',
prefixIcon: const Icon(Icons.search),
suffixIcon: _keyword.isEmpty
? null
: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() => _keyword = '');
_refresh();
},
),
border: const OutlineInputBorder(),
),
onSubmitted: (v) {
setState(() => _keyword = v);
_refresh();
},
),
),
Expanded(
child: _error != null
? Center(child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!),
const SizedBox(height: 8),
FilledButton.tonal(onPressed: _refresh, child: const Text('重试')),
],
))
: RefreshIndicator(
onRefresh: _refresh,
child: _items.isEmpty && !_loading
? const Center(child: Text('暂无数据'))
: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _items.length + (_loading ? 1 : 0),
itemBuilder: (ctx, i) {
if (i >= _items.length) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
final item = _items[i];
return Card(
child: ListTile(
leading: item.iconUrl.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
item.iconUrl,
width: 40,
height: 40,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
const Icon(Icons.apps, size: 40),
),
)
: const CircleAvatar(child: Icon(Icons.apps)),
title: Text(item.appName,
style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text('包名: ${item.packageName}'),
if (item.appCategory.isNotEmpty)
Text('分类: ${item.appCategory}'),
const SizedBox(height: 4),
Chip(
label: Text(item.status == 0 ? '正常' : '禁用'),
visualDensity: VisualDensity.compact,
),
],
),
trailing: PopupMenuButton<String>(
onSelected: (v) {
if (v == 'edit') _showForm(editing: item);
if (v == 'delete') _delete(item.id);
},
itemBuilder: (_) => [
const PopupMenuItem(value: 'edit', child: Text('编辑')),
const PopupMenuItem(value: 'delete', child: Text('删除')),
],
),
),
);
},
),
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showForm(),
child: const Icon(Icons.add),
),
);
}
}

View File

@@ -0,0 +1,894 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/org_node.dart';
import '../../../services/api_client.dart';
import '../../../services/org_service.dart';
import '../../../utils/formatters.dart';
import '../../../utils/i10n.dart';
import 'channel_version_manage_dialog.dart';
/// 渠道商管理页:渠道 → 代理/子代理 树形 CRUD。
///
/// 功能结构、布局与交互逻辑对齐 Web 后台 `org.vue`(screen-front
/// `views/modules/org/org.vue`):
/// - 列表:渠道 + 代理树形展示,默认展开顶层渠道;
/// - 搜索:按名称模糊匹配;
/// - 筛选:类型(全部/渠道/代理)+ 状态(全部/启用/禁用),与 Web 一致;
/// - 详情:点按行打开底部详情卡,展示全部字段;
/// - 新增/编辑/新增子级/删除:底部表单或确认弹窗;
/// - 删除守卫:存在子集时禁止删除(Web "存在子集时不允许删除");
/// - 移动端适配:点按区域放大、`Dismissible` 左滑删除手势。
///
/// 数据来自 [OrgService.getChannelTree](组合 /sys/org/channels 与
/// /sys/org/channelAgentTree),增删改复用既有组织接口
/// [OrgService.createOrg] / [updateOrg] / [deleteOrg]。
class ChannelManagementPage extends ConsumerStatefulWidget {
const ChannelManagementPage({super.key});
@override
ConsumerState<ChannelManagementPage> createState() =>
_ChannelManagementPageState();
}
class _ChannelManagementPageState
extends ConsumerState<ChannelManagementPage> {
List<OrgNode> _tree = [];
bool _loading = false;
String? _error;
final _searchController = TextEditingController();
String _keyword = '';
int? _typeFilter;
String? _statusFilter;
final Set<String> _expanded = {};
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _snack(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
try {
final tree = await OrgService().getChannelTree();
if (!mounted) return;
// 默认展开顶层渠道,便于一眼看到其下代理。
final autoExpand = tree.map((n) => n.id).toSet();
setState(() {
_tree = tree;
_loading = false;
_expanded.addAll(autoExpand);
});
} catch (e) {
if (!mounted) return;
setState(() {
_loading = false;
_error = formatApiError(e);
});
}
}
/// 计算当前应展示的扁平行(含层级 depth 与父级名称)。
///
/// - 无筛选(关键词 / 类型 / 状态都为空):按 [_expanded] 折叠/展开;
/// - 有筛选:忽略展开态,递归展开全部并仅显示命中节点(父未命中但子命中时
/// 子仍可见),确保筛选结果不被折叠隐藏。
List<_ChannelRow> _computeRows() {
final rows = <_ChannelRow>[];
final kw = _keyword.trim().toLowerCase();
final filterActive =
kw.isNotEmpty || _typeFilter != null || _statusFilter != null;
bool pass(OrgNode n) {
if (kw.isNotEmpty && !n.name.toLowerCase().contains(kw)) return false;
if (_typeFilter != null && n.type != _typeFilter) return false;
if (_statusFilter != null &&
n.status.toUpperCase() != _statusFilter) {
return false;
}
return true;
}
void walk(List<OrgNode> nodes, int depth, String? parentName) {
for (final n in nodes) {
if (filterActive ? pass(n) : true) {
rows.add(
_ChannelRow(node: n, depth: depth, parentName: parentName),
);
}
final childrenVisible =
n.children.isNotEmpty && (filterActive || _expanded.contains(n.id));
if (childrenVisible) walk(n.children, depth + 1, n.name);
}
}
walk(_tree, 0, null);
return rows;
}
void _toggleExpand(OrgNode node) {
if (node.children.isEmpty) return;
setState(() {
if (_expanded.contains(node.id)) {
_expanded.remove(node.id);
} else {
_expanded.add(node.id);
}
});
}
/// 渠道/代理可新增子级(与 Web [canShowAddChildForRow] 对齐:CHANNEL/SERVICE_PROVIDER)。
bool _canAddChild(OrgNode n) => n.type == 1 || n.type == 2;
/// 可下钻版本管理(与 Web [canShowVersionForRow] 对齐:CHANNEL/SERVICE_PROVIDER)。
bool _canShowVersion(OrgNode n) => n.type == 1 || n.type == 2;
Color _typeColor(BuildContext context, int type) {
if (type == 1) return Colors.green;
if (type == 2) return Colors.orange;
return Theme.of(context).colorScheme.primary;
}
/// 删除节点(含确认弹窗)。存在子集时直接拦截并提示,与 Web 一致。
///
/// 返回是否删除成功;[skipConfirm] 用于滑动手势等已显式触发的场景。
Future<bool> _delete(OrgNode node, {bool skipConfirm = false}) async {
final t = AppLocalizations.of(context);
if (node.children.isNotEmpty) {
_snack(t.channelCannotDeleteWithChildren);
return false;
}
if (!skipConfirm) {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(t.confirmDelete),
content: Text(t.deleteConfirmContent(node.name)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(t.cancel),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
onPressed: () => Navigator.pop(ctx, true),
child: Text(t.delete),
),
],
),
);
if (confirmed != true) return false;
}
try {
await OrgService().deleteOrg(node.id);
if (!mounted) return false;
_load();
_snack(t.deleted);
return true;
} catch (e) {
if (!mounted) return false;
_snack(e is ApiException ? e.message : t.deleteFailed);
return false;
}
}
/// 底部详情卡:展示节点全部字段 + 操作入口(新增子级/编辑/删除)。
void _showDetail(OrgNode node, String? parentName) {
final t = AppLocalizations.of(context);
final hasChildren = node.children.isNotEmpty;
showModalBottomSheet(
useSafeArea: true,
context: context,
isScrollControlled: true,
builder: (ctx) => DraggableScrollableSheet(
initialChildSize: 0.6,
maxChildSize: 0.9,
expand: false,
builder: (sheetCtx, scrollCtrl) => Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Row(
children: [
Expanded(
child: Text(
node.name,
style: Theme.of(ctx).textTheme.titleLarge,
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(ctx),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
_DetailChip(label: node.typeLabel, color: _typeColor(ctx, node.type)),
const SizedBox(width: 8),
_DetailChip(
label: node.isDisabled ? t.channelDisabled : t.channelEnabled,
color: node.isDisabled ? Colors.red : Colors.green,
),
],
),
),
const Divider(height: 20),
Expanded(
child: ListView(
controller: scrollCtrl,
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
_DetailRow(t.channelOrgCode, node.orgCodeLabel),
_DetailRow(t.channelSort, node.orderNo.toString()),
_DetailRow(t.channelParent, parentName ?? '-'),
_DetailRow(
t.channelContactPhone,
node.contactPhone.isEmpty ? '-' : node.contactPhone,
),
_DetailRow(
t.channelAddress,
node.address.isEmpty ? '-' : node.address,
),
if (node.createdAt != null)
_DetailRow(
t.createTime,
Formatters.formatDateTime(node.createdAt!),
),
if (node.expireTime != null)
_DetailRow(
t.channelExpireTime,
Formatters.formatDate(node.expireTime!),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: _canShowVersion(node)
? SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () {
Navigator.pop(ctx);
showChannelVersionManageDialog(context, node: node);
},
icon: const Icon(Icons.system_update_alt),
label: Text(t.channelVersionManage),
),
)
: const SizedBox.shrink(),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Row(
children: [
if (_canAddChild(node))
Expanded(
child: OutlinedButton.icon(
onPressed: () {
Navigator.pop(ctx);
_showForm(parent: node);
},
icon: const Icon(Icons.add),
label: Text(t.addSubChannel),
),
),
if (_canAddChild(node)) const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: () {
Navigator.pop(ctx);
_showForm(editing: node);
},
icon: const Icon(Icons.edit),
label: Text(t.edit),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor:
Theme.of(ctx).colorScheme.errorContainer,
foregroundColor:
Theme.of(ctx).colorScheme.onErrorContainer,
),
onPressed: hasChildren
? null
: () {
Navigator.pop(ctx);
_delete(node);
},
icon: const Icon(Icons.delete),
label: Text(t.delete),
),
),
],
),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
final rows = _computeRows();
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
title: Text(t.channelManagement),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(168),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Column(
children: [
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: t.searchChannelHint,
prefixIcon: const Icon(Icons.search),
isDense: true,
suffixIcon: _keyword.isEmpty
? null
: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() => _keyword = '');
},
),
),
onChanged: (v) => setState(() => _keyword = v),
),
const SizedBox(height: 8),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
spacing: 8,
children: [
SegmentedButton<int?>(
segments: [
ButtonSegment(value: null, label: Text(t.all)),
ButtonSegment(
value: 1,
label: Text(t.channelTypeChannel),
),
ButtonSegment(
value: 2,
label: Text(t.channelTypeAgent),
),
],
selected: {_typeFilter},
onSelectionChanged: (set) =>
setState(() => _typeFilter = set.first),
),
SegmentedButton<String?>(
segments: [
ButtonSegment(value: null, label: Text(t.all)),
ButtonSegment(
value: 'ACTIVE',
label: Text(t.channelEnabled),
),
ButtonSegment(
value: 'DISABLED',
label: Text(t.channelDisabled),
),
],
selected: {_statusFilter},
onSelectionChanged: (set) =>
setState(() => _statusFilter = set.first),
),
],
),
),
],
),
),
),
),
body: _error != null
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!),
const SizedBox(height: 8),
FilledButton.tonal(onPressed: _load, child: Text(t.retry)),
],
),
)
: _loading && _tree.isEmpty
? const Center(child: CircularProgressIndicator())
: rows.isEmpty
? Center(child: Text(t.noData))
: RefreshIndicator(
onRefresh: _load,
child: ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 8),
physics: const AlwaysScrollableScrollPhysics(),
itemCount: rows.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (ctx, i) {
final row = rows[i];
return Dismissible(
key: Key(row.node.id),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 24),
color: Theme.of(context).colorScheme.error,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
const Icon(Icons.delete, color: Colors.white),
const SizedBox(width: 8),
Text(
t.channelSwipeToDelete,
style: const TextStyle(color: Colors.white),
),
],
),
),
confirmDismiss: (_) => _delete(
row.node,
skipConfirm: true,
),
child: _ChannelRowTile(
row: row,
isExpanded: _expanded.contains(row.node.id),
onToggle: _toggleExpand,
onDetail: _showDetail,
onEdit: (n) => _showForm(editing: n),
onAddChild: (n) => _showForm(parent: n),
onVersionManage: (n) =>
showChannelVersionManageDialog(
context,
node: n,
),
onDelete: _delete,
),
);
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showForm(),
tooltip: t.addChannel,
child: const Icon(Icons.add),
),
);
}
/// 新增/编辑/新增子级 的底部表单。
void _showForm({OrgNode? editing, OrgNode? parent}) {
final t = AppLocalizations.of(context);
final isEdit = editing != null;
final isChild = parent != null && !isEdit;
// 类型:编辑沿用原类型;新增子级默认代理(SERVICE_PROVIDER);
// 新增顶层渠道默认渠道(CHANNEL)。
int typeValue = editing?.type ?? (isChild ? 2 : 1);
final nameCtrl = TextEditingController(text: editing?.name ?? '');
final orderCtrl = TextEditingController(
text: editing?.orderNo.toString() ?? '0',
);
final phoneCtrl =
TextEditingController(text: editing?.contactPhone ?? '');
final addressCtrl = TextEditingController(text: editing?.address ?? '');
int statusValue = editing?.isDisabled == true ? 1 : 0;
showModalBottomSheet(
useSafeArea: true,
context: context,
isScrollControlled: true,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setSheet) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(ctx).viewInsets.bottom,
),
child: Padding(
padding: const EdgeInsets.all(16),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isEdit
? t.editChannel
: isChild
? t.addSubChannel
: t.addChannel,
style: Theme.of(ctx).textTheme.titleMedium,
),
const SizedBox(height: 16),
if (isChild)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
'${t.channelParent}:${parent!.name}',
style: Theme.of(ctx)
.textTheme
.bodySmall
?.copyWith(color: Theme.of(ctx).hintColor),
),
),
TextField(
controller: nameCtrl,
decoration: InputDecoration(
labelText: t.channelName,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
DropdownButtonFormField<int>(
value: typeValue,
decoration: InputDecoration(
labelText: t.channelType,
border: const OutlineInputBorder(),
),
items: const [
DropdownMenuItem(value: 1, child: Text('渠道')),
DropdownMenuItem(value: 2, child: Text('代理')),
],
onChanged: (v) {
if (v != null) setSheet(() => typeValue = v);
},
),
const SizedBox(height: 12),
TextField(
controller: orderCtrl,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: t.channelSort,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
DropdownButtonFormField<int>(
value: statusValue,
decoration: InputDecoration(
labelText: t.channelStatus,
border: const OutlineInputBorder(),
),
items: [
DropdownMenuItem(value: 0, child: Text(t.channelEnabled)),
DropdownMenuItem(value: 1, child: Text(t.channelDisabled)),
],
onChanged: (v) {
if (v != null) setSheet(() => statusValue = v);
},
),
const SizedBox(height: 12),
TextField(
controller: phoneCtrl,
keyboardType: TextInputType.phone,
decoration: InputDecoration(
labelText: t.channelContactPhone,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: addressCtrl,
decoration: InputDecoration(
labelText: t.channelAddress,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.pop(ctx),
child: Text(t.cancel),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: () async {
final name = nameCtrl.text.trim();
if (name.isEmpty) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(content: Text(t.channelNameRequired)),
);
return;
}
final typeString =
typeValue == 1 ? 'CHANNEL' : 'SERVICE_PROVIDER';
final statusString =
statusValue == 0 ? 'ACTIVE' : 'DISABLED';
final data = <String, dynamic>{
'name': name,
'type': typeString,
'orderNo':
int.tryParse(orderCtrl.text.trim()) ?? 0,
'status': statusString,
'contactPhone': phoneCtrl.text.trim(),
'address': addressCtrl.text.trim(),
if (isEdit) 'id': editing?.id,
if (isChild) 'parentId': parent?.id,
};
try {
if (isEdit) {
await OrgService().updateOrg(data);
} else {
await OrgService().createOrg(data);
}
if (!ctx.mounted) return;
Navigator.pop(ctx);
_load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.saved)),
);
} catch (e) {
if (!ctx.mounted) return;
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
content: Text(
e is ApiException ? e.message : t.saveFailed,
),
),
);
}
},
child: Text(t.save),
),
),
],
),
],
),
),
),
),
),
);
}
}
/// 渠道树的一行(节点 + 层级深度 + 父级名称)。
class _ChannelRow {
const _ChannelRow({
required this.node,
required this.depth,
this.parentName,
});
final OrgNode node;
final int depth;
final String? parentName;
}
/// 渠道行卡片:层级缩进 + 展开按钮 + 类型/状态徽章 + 点按查看详情 + 操作菜单。
class _ChannelRowTile extends StatelessWidget {
const _ChannelRowTile({
required this.row,
required this.isExpanded,
required this.onToggle,
required this.onDetail,
required this.onEdit,
required this.onAddChild,
required this.onVersionManage,
required this.onDelete,
});
final _ChannelRow row;
final bool isExpanded;
final void Function(OrgNode) onToggle;
final void Function(OrgNode, String?) onDetail;
final void Function(OrgNode) onEdit;
final void Function(OrgNode) onAddChild;
final void Function(OrgNode) onVersionManage;
final Future<bool> Function(OrgNode, {bool skipConfirm}) onDelete;
Color _typeColor(BuildContext context, int type) {
if (type == 1) return Colors.green;
if (type == 2) return Colors.orange;
return Theme.of(context).colorScheme.primary;
}
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
final node = row.node;
final hasChildren = node.children.isNotEmpty;
return Row(
children: [
if (hasChildren)
IconButton(
icon: Icon(isExpanded ? Icons.expand_less : Icons.expand_more),
visualDensity: VisualDensity.compact,
onPressed: () => onToggle(node),
)
else
const SizedBox(width: 40),
Expanded(
child: InkWell(
// 点按行主体打开详情(清晰的大点按区域,触控友好)。
onTap: () => onDetail(node, row.parentName),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
node.name,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 15,
),
),
),
Chip(
label: Text(node.typeLabel),
visualDensity: VisualDensity.compact,
backgroundColor:
_typeColor(context, node.type).withOpacity(0.15),
labelStyle: TextStyle(
color: _typeColor(context, node.type),
fontSize: 12,
),
),
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: node.isDisabled
? Colors.red.withOpacity(0.15)
: Colors.green.withOpacity(0.15),
borderRadius: BorderRadius.circular(12),
),
child: Text(
node.isDisabled
? t.channelDisabled
: t.channelEnabled,
style: TextStyle(
fontSize: 12,
color: node.isDisabled ? Colors.red : Colors.green,
),
),
),
],
),
const SizedBox(height: 4),
Text(
'${t.channelOrgCode}:${node.orgCodeLabel} · '
'${t.channelSort}:${node.orderNo}'
'${node.createdAt != null ? ' · ${t.createTime}:${Formatters.formatDateTime(node.createdAt!)}' : ''}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
),
PopupMenuButton<String>(
onSelected: (v) {
if (v == 'add') onAddChild(node);
if (v == 'edit') onEdit(node);
if (v == 'version') onVersionManage(node);
if (v == 'delete') onDelete(node);
},
itemBuilder: (_) => [
if (node.type == 1 || node.type == 2)
PopupMenuItem(value: 'add', child: Text(t.addSubChannel)),
PopupMenuItem(value: 'edit', child: Text(t.edit)),
if (node.type == 1 || node.type == 2)
PopupMenuItem(
value: 'version',
child: Text(t.channelVersionManage),
),
PopupMenuItem(
value: 'delete',
enabled: !hasChildren,
child: Text(
hasChildren ? '${t.delete}(有子集)' : t.delete,
style: TextStyle(
color: hasChildren
? Theme.of(context).disabledColor
: Theme.of(context).colorScheme.error,
),
),
),
],
),
],
);
}
}
/// 详情卡:字段行。
class _DetailRow extends StatelessWidget {
const _DetailRow(this.label, this.value);
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 96,
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
Expanded(
child: Text(
value,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
}
/// 详情卡:类型/状态徽章。
class _DetailChip extends StatelessWidget {
const _DetailChip({required this.label, required this.color});
final String label;
final Color color;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.15),
borderRadius: BorderRadius.circular(12),
),
child: Text(
label,
style: TextStyle(fontSize: 12, color: color),
),
);
}
}

View File

@@ -0,0 +1,435 @@
import 'package:flutter/material.dart';
import '../../../models/org_node.dart';
import '../../../services/api_client.dart';
import '../../../services/app_service.dart';
import '../../../services/upload_service.dart';
import '../../../utils/i10n.dart';
/// 渠道 / 代理节点的 App 版本管理对话框。
///
/// 对齐 Web 后台 `app-version-manage-dialog.vue`:每个组织(scope=ORG)
/// 至多维护一条版本配置,进入时按 [OrgNode.id] 拉取当前版本——
/// 有则进入「编辑」流程(含删除按钮),无则进入「新增」流程。
///
/// 字段与 Web 完全一致:版本名 / 版本号 / 下载地址 / 强制更新 / 弹窗提示;
/// 下载地址支持手动录入,或点击「上传文件」按钮直传 APK(对齐 Web 后台的 el-upload,
/// 经 /theme/upload/credential 取 STS 凭证直传 COS),上传成功后自动回填 URL。
///
/// 用法:
/// ```dart
/// showChannelVersionManageDialog(context, node: node);
/// ```
Future<void> showChannelVersionManageDialog(
BuildContext context, {
required OrgNode node,
}) {
return showModalBottomSheet<void>(
context: context,
useSafeArea: true,
isScrollControlled: true,
builder: (_) => _ChannelVersionManageSheet(node: node),
);
}
class _ChannelVersionManageSheet extends StatefulWidget {
const _ChannelVersionManageSheet({required this.node});
final OrgNode node;
@override
State<_ChannelVersionManageSheet> createState() =>
_ChannelVersionManageSheetState();
}
class _ChannelVersionManageSheetState
extends State<_ChannelVersionManageSheet> {
final _nameCtrl = TextEditingController();
final _codeCtrl = TextEditingController();
final _urlCtrl = TextEditingController();
/// 当前版本 ID(非空表示编辑态)。
String? _versionId;
bool _forceUpdate = false;
bool _showPopup = true;
bool _loading = true;
bool _submitting = false;
/// APK 上传中标记,上传期间禁用 URL 手动输入并禁用上传按钮。
bool _uploading = false;
/// APK 上传进度(0~100),仅在 [_uploading] 为 true 时展示。
int _uploadProgress = 0;
AppLocalizations get t => AppLocalizations.of(context);
@override
void initState() {
super.initState();
_loadCurrent();
}
@override
void dispose() {
_nameCtrl.dispose();
_codeCtrl.dispose();
_urlCtrl.dispose();
super.dispose();
}
/// 进入时拉取该组织的当前版本配置,有则回填表单(编辑态),无则空表单(新增态)。
Future<void> _loadCurrent() async {
try {
final data = await AppService().searchAppVersion(orgId: widget.node.id);
if (!mounted) return;
if (data != null) {
_versionId = data['id']?.toString();
_nameCtrl.text = data['versionName']?.toString() ?? '';
final rawCode = data['versionCode'] ?? data['version_code'];
_codeCtrl.text = rawCode?.toString() ?? '';
_urlCtrl.text = (data['downloadUrl'] ?? data['download_url'] ?? data['apkUrl'])
?.toString() ??
'';
_forceUpdate = _toBool(data['forceUpdate'] ?? data['force_update']);
_showPopup = _toBool(data['showPopup'] ?? data['show_popup'], fallback: true);
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(_errText(e))),
);
} finally {
if (mounted) setState(() => _loading = false);
}
}
bool _toBool(dynamic v, {bool fallback = false}) {
if (v == null) return fallback;
final n = int.tryParse(v.toString()) ?? 0;
return n == 1;
}
String _errText(Object? e) =>
e is ApiException ? e.message : (t.loadFailed);
String get _title {
final name = widget.node.name.trim();
final action = _versionId != null ? t.editVersion : t.addVersion;
return name.isNotEmpty ? '$action - $name' : action;
}
Future<void> _submit() async {
final name = _nameCtrl.text.trim();
final codeText = _codeCtrl.text.trim();
final url = _urlCtrl.text.trim();
if (name.isEmpty) {
_snack(t.versionNameRequired);
return;
}
final code = int.tryParse(codeText);
if (code == null || code <= 0) {
_snack(t.versionCodeRequired);
return;
}
if (url.isEmpty) {
_snack(t.versionUrlRequired);
return;
}
if (!url.startsWith('http://') && !url.startsWith('https://')) {
_snack(t.versionUrlInvalid);
return;
}
setState(() => _submitting = true);
final payload = <String, dynamic>{
'scope': 'ORG',
'orgId': widget.node.id,
'versionName': name,
'versionCode': code,
'downloadUrl': url,
'forceUpdate': _forceUpdate ? 1 : 0,
'showPopup': _showPopup ? 1 : 0,
if (_versionId != null) 'id': _versionId,
};
try {
if (_versionId != null) {
await AppService().updateAppVersion(payload);
} else {
await AppService().saveAppVersion(payload);
}
if (!mounted) return;
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.saved)),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(_errText(e))),
);
} finally {
if (mounted) setState(() => _submitting = false);
}
}
Future<void> _delete() async {
final id = _versionId;
if (id == null) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(t.confirmDelete),
content: Text(t.versionDeleteConfirm),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(t.cancel),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
onPressed: () => Navigator.pop(ctx, true),
child: Text(t.delete),
),
],
),
);
if (confirmed != true) return;
setState(() => _submitting = true);
try {
await AppService().deleteAppVersion([int.parse(id)]);
if (!mounted) return;
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.versionDeleted)),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(_errText(e))),
);
} finally {
if (mounted) setState(() => _submitting = false);
}
}
void _snack(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
}
/// 选择 .apk 文件并直传腾讯云 COS,成功后把返回 URL 填回下载地址输入框。
///
/// 对齐 Web 后台 `app-version-manage-dialog.vue` 的「上传文件」按钮:
/// 点击 → 系统文件选择器(仅 .apk)→ 选完即传 → 回填 downloadUrl。
Future<void> _pickAndUpload() async {
// 1. 打开文件选择器,仅允许 .apk;用户取消返回 null。
final picked = await UploadService.pickApkFile();
if (!mounted) return;
if (picked == null) return;
// 2. 校验扩展名(选择器已限制,这里兜底)。
if (!picked.name.toLowerCase().endsWith('.apk')) {
_snack(t.pleaseSelectApk);
return;
}
// 3. 进入上传态,统一在 finally 复位,避免 async 后 setState 崩溃。
setState(() => _uploading = true);
try {
final url = await UploadService().uploadApkByOriginalName(
picked,
onProgress: (p) {
if (mounted) setState(() => _uploadProgress = p);
},
);
if (!mounted) return;
// 4. 成功:回填下载地址并提示。
_urlCtrl.text = url;
_snack(t.uploadSuccess);
} catch (e) {
if (!mounted) return;
_snack('${t.uploadFailed}:${_errText(e)}');
} finally {
if (mounted) {
setState(() {
_uploading = false;
_uploadProgress = 0;
});
}
}
}
@override
Widget build(BuildContext context) {
final isEdit = _versionId != null;
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
_title,
style: Theme.of(context).textTheme.titleMedium,
),
),
const Divider(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: _loading
? const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: CircularProgressIndicator(),
),
)
: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _nameCtrl,
decoration: InputDecoration(
labelText: t.versionName,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _codeCtrl,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: t.versionCode,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _urlCtrl,
keyboardType: TextInputType.url,
enabled: !_uploading,
decoration: InputDecoration(
labelText: t.downloadUrlHint,
border: const OutlineInputBorder(),
suffixIcon: TextButton.icon(
onPressed: _uploading ? null : _pickAndUpload,
icon: const Icon(Icons.upload),
label: _uploading
? Text('${t.uploading} $_uploadProgress%')
: Text(t.uploadFile),
),
),
),
const SizedBox(height: 16),
_SwitchRow(
label: t.forceUpdate,
value: _forceUpdate,
onChanged: (v) => setState(() => _forceUpdate = v),
),
const SizedBox(height: 8),
_SwitchRow(
label: t.showPopup,
value: _showPopup,
onChanged: (v) => setState(() => _showPopup = v),
),
const SizedBox(height: 20),
Row(
children: [
if (isEdit)
Expanded(
child: OutlinedButton.icon(
onPressed: _submitting ? null : _delete,
icon: const Icon(Icons.delete),
style: OutlinedButton.styleFrom(
foregroundColor:
Theme.of(context).colorScheme.error,
),
label: Text(t.delete),
),
),
if (isEdit) const SizedBox(width: 12),
Expanded(
child: OutlinedButton(
onPressed: _submitting
? null
: () => Navigator.pop(context),
child: Text(t.cancel),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: Text(isEdit ? t.save : t.add),
),
),
],
),
const SizedBox(height: 16),
],
),
),
],
),
),
);
}
}
/// 简易「是/否」开关行(对齐 Web 的 radio 否/是)。
class _SwitchRow extends StatelessWidget {
const _SwitchRow({
required this.label,
required this.value,
required this.onChanged,
});
final String label;
final bool value;
final ValueChanged<bool> onChanged;
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
return Row(
children: [
SizedBox(
width: 84,
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
SegmentedButton<bool>(
segments: [
ButtonSegment(value: false, label: Text(t.forceUpdateNo)),
ButtonSegment(value: true, label: Text(t.forceUpdateYes)),
],
selected: {value},
onSelectionChanged: (set) => onChanged(set.first),
),
],
);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,258 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../models/store_info.dart';
import '../../../services/api_client.dart';
import '../../../services/org_service.dart';
import '../../../utils/formatters.dart';
import '../../../utils/i10n.dart';
/// 公司介绍 - 门店列表入口页。
///
/// 以门店列表形式展示各门店,支持按关键字搜索门店;点击门店进入该门店的
/// 公司介绍内容页(按内容类型分类呈现)。
class IntroStoreListPage extends ConsumerStatefulWidget {
const IntroStoreListPage({super.key});
@override
ConsumerState<IntroStoreListPage> createState() => _IntroStoreListPageState();
}
class _IntroStoreListPageState extends ConsumerState<IntroStoreListPage> {
final _orgService = OrgService();
final _searchController = TextEditingController();
List<StoreInfo> _allStores = [];
List<StoreInfo> _stores = [];
bool _loading = false;
String? _error;
String _keyword = '';
@override
void initState() {
super.initState();
_loadData();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _loadData() async {
setState(() {
_loading = true;
_error = null;
});
try {
final items = await _orgService.getMyStores();
if (!mounted) return;
_allStores = items;
_applyFilter();
setState(() => _loading = false);
} catch (e) {
if (!mounted) return;
setState(() {
_error = formatApiError(e, fallback: '门店加载失败,请稍后重试');
_loading = false;
});
}
}
void _applyFilter() {
final keyword = _keyword.trim().toLowerCase();
if (keyword.isEmpty) {
_stores = List.of(_allStores);
} else {
_stores = _allStores
.where((s) => s.name.toLowerCase().contains(keyword))
.toList();
}
// 与门店管理页保持一致:按创建时间倒序(最新的排最前面)。
_stores.sort((a, b) {
if (a.createTime != null && b.createTime != null) {
return b.createTime!.compareTo(a.createTime!);
}
if (a.createTime == null && b.createTime != null) return 1;
if (a.createTime != null && b.createTime == null) return -1;
return b.orgId.compareTo(a.orgId);
});
}
void _onSearch() {
setState(() {
_keyword = _searchController.text;
_applyFilter();
});
}
void _onStoreTap(StoreInfo store) {
context.push('/admin/intro/store-content/${store.orgId}', extra: store);
}
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(title: Text(t.introCategoryTitle)),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: t.searchStoreHint,
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isEmpty
? null
: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() {
_keyword = '';
_applyFilter();
});
},
),
),
onSubmitted: (_) => _onSearch(),
),
),
Expanded(child: _buildBody()),
],
),
);
}
Widget _buildBody() {
final t = AppLocalizations.of(context);
if (_loading && _stores.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null && _stores.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('${t.loadFailed}:$_error'),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: _loadData,
child: Text(t.retry),
),
],
),
);
}
if (_stores.isEmpty) {
return RefreshIndicator(
onRefresh: _loadData,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
const SizedBox(height: 200),
Center(child: Text(t.noData)),
],
),
);
}
return RefreshIndicator(
onRefresh: _loadData,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _stores.length,
itemBuilder: (context, index) {
final store = _stores[index];
return _StoreListTile(
store: store,
onTap: () => _onStoreTap(store),
);
},
),
);
}
}
/// 门店列表项:复用门店管理页的视觉风格。
class _StoreListTile extends StatelessWidget {
const _StoreListTile({required this.store, required this.onTap});
final StoreInfo store;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final t = AppLocalizations.of(context);
final isDisabled = store.status == 1;
final statusColor = isDisabled ? Colors.grey : Colors.green;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ListTile(
onTap: onTap,
title: Row(
children: [
Expanded(
child: Text(
store.name,
style: theme.textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.bold),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: statusColor.withOpacity(0.15),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: statusColor.withOpacity(0.6)),
),
child: Text(
isDisabled ? t.statusDisabled : t.statusNormal,
style: TextStyle(
color: statusColor,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
if (store.address.isNotEmpty)
_infoLine(context, Icons.location_on, store.address),
if (store.contactPhone.isNotEmpty)
_infoLine(context, Icons.phone, store.contactPhone),
if (store.expireTime != null)
_infoLine(
context,
Icons.event,
'${t.expireTime}:${Formatters.formatDateTime(store.expireTime!)}',
),
],
),
trailing: const Icon(Icons.chevron_right),
),
);
}
Widget _infoLine(BuildContext context, IconData icon, String text) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
children: [
Icon(icon, size: 14, color: Theme.of(context).colorScheme.outline),
const SizedBox(width: 4),
Expanded(child: Text(text, style: const TextStyle(fontSize: 13))),
],
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,391 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/media_asset.dart';
import '../../../services/media_service.dart';
import '../../../services/api_client.dart';
/// 媒资管理页。
class MediaListPage extends ConsumerStatefulWidget {
const MediaListPage({super.key});
@override
ConsumerState<MediaListPage> createState() => _MediaListPageState();
}
class _MediaListPageState extends ConsumerState<MediaListPage> {
final _scrollController = ScrollController();
final _searchController = TextEditingController();
List<MediaAsset> _items = [];
bool _loading = false;
bool _hasMore = true;
int _page = 1;
String? _error;
String _keyword = '';
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_loadData();
}
@override
void dispose() {
_searchController.dispose();
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200 &&
!_loading &&
_hasMore) {
_loadMore();
}
}
Future<void> _loadData() async {
setState(() {
_loading = true;
_error = null;
});
try {
final list = await MediaService().getMediaAssetList(page: 1, limit: 20);
if (!mounted) return;
setState(() {
_items = list;
_loading = false;
_hasMore = list.length >= 20;
_page = 1;
});
} catch (e) {
if (!mounted) return;
setState(() {
_loading = false;
_error = e is ApiException ? e.message : '加载失败';
});
}
}
Future<void> _loadMore() async {
if (_loading) return;
setState(() => _loading = true);
try {
final list = await MediaService().getMediaAssetList(page: _page + 1, limit: 20);
if (!mounted) return;
setState(() {
_items.addAll(list);
_loading = false;
_hasMore = list.length >= 20;
_page++;
});
} catch (_) {
if (!mounted) return;
setState(() => _loading = false);
}
}
Future<void> _refresh() async {
_page = 1;
_hasMore = true;
await _loadData();
}
void _showForm({MediaAsset? editing}) {
final isEdit = editing != null;
final nameCtrl = TextEditingController(text: editing?.name ?? '');
final codeCtrl = TextEditingController(text: editing?.code ?? '');
final coverCtrl = TextEditingController(text: editing?.coverUrl ?? '');
final playCtrl = TextEditingController(text: editing?.playUrl ?? '');
final durationCtrl =
TextEditingController(text: editing?.duration.toString() ?? '');
String typeValue = editing?.type.isNotEmpty == true ? editing!.type : 'video';
int statusValue = editing?.status ?? 0;
showModalBottomSheet(
useSafeArea: true,
context: context,
isScrollControlled: true,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setSheet) => Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(ctx).viewInsets.bottom),
child: Padding(
padding: const EdgeInsets.all(16),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(isEdit ? '编辑媒资' : '新增媒资',
style: Theme.of(ctx).textTheme.titleMedium),
const SizedBox(height: 16),
TextField(
controller: nameCtrl,
decoration: const InputDecoration(
labelText: '名称', border: OutlineInputBorder())),
const SizedBox(height: 12),
TextField(
controller: codeCtrl,
decoration: const InputDecoration(
labelText: '编码', border: OutlineInputBorder())),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
value: typeValue,
decoration: const InputDecoration(
labelText: '类型', border: OutlineInputBorder()),
items: const [
DropdownMenuItem(value: 'video', child: Text('影视')),
DropdownMenuItem(value: 'audio', child: Text('音乐')),
DropdownMenuItem(value: 'image', child: Text('图片')),
],
onChanged: (v) {
if (v != null) setSheet(() => typeValue = v);
},
),
const SizedBox(height: 12),
TextField(
controller: coverCtrl,
decoration: const InputDecoration(
labelText: '封面地址', border: OutlineInputBorder())),
const SizedBox(height: 12),
TextField(
controller: playCtrl,
decoration: const InputDecoration(
labelText: '播放地址', border: OutlineInputBorder())),
const SizedBox(height: 12),
TextField(
controller: durationCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '时长(秒)', border: OutlineInputBorder())),
const SizedBox(height: 12),
DropdownButtonFormField<int>(
value: statusValue,
decoration: const InputDecoration(
labelText: '状态', border: OutlineInputBorder()),
items: const [
DropdownMenuItem(value: 0, child: Text('正常')),
DropdownMenuItem(value: 1, child: Text('禁用')),
],
onChanged: (v) {
if (v != null) setSheet(() => statusValue = v);
},
),
const SizedBox(height: 16),
FilledButton(
onPressed: () async {
final data = <String, dynamic>{
if (isEdit) 'id': editing!.id,
'name': nameCtrl.text,
'code': codeCtrl.text,
'type': typeValue,
'coverUrl': coverCtrl.text,
'playUrl': playCtrl.text,
'duration': int.tryParse(durationCtrl.text) ?? 0,
'status': statusValue,
};
try {
await MediaService().saveMediaAsset(data);
if (!ctx.mounted) return;
Navigator.pop(ctx);
_refresh();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('保存成功')),
);
} catch (e) {
if (!ctx.mounted) return;
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(content: Text(e is ApiException ? e.message : '保存失败')),
);
}
},
child: const Text('保存'),
),
],
),
),
),
),
),
);
}
Future<void> _delete(String id) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('确认删除'),
content: const Text('确定要删除此媒资吗?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('删除')),
],
),
);
if (confirmed != true) return;
try {
await MediaService().deleteMediaAsset([int.tryParse(id) ?? 0]);
_refresh();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('删除成功')));
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e is ApiException ? e.message : '删除失败')),
);
}
}
String _typeLabel(String type) {
switch (type.toLowerCase()) {
case 'video':
return '影视';
case 'audio':
return '音乐';
case 'image':
return '图片';
default:
return type;
}
}
String _formatDuration(int seconds) {
final h = seconds ~/ 3600;
final m = (seconds % 3600) ~/ 60;
final s = seconds % 60;
if (h > 0) return '${h}h${m.toString().padLeft(2, '0')}m';
return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
final filtered = _keyword.isEmpty
? _items
: _items
.where((e) => e.name.toLowerCase().contains(_keyword.toLowerCase()))
.toList();
return Scaffold(
appBar: AppBar(automaticallyImplyLeading: false, title: const Text('媒资管理')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: '搜索媒资名称',
prefixIcon: const Icon(Icons.search),
suffixIcon: _keyword.isEmpty
? null
: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() => _keyword = '');
},
),
border: const OutlineInputBorder(),
),
onChanged: (v) => setState(() => _keyword = v),
),
),
Expanded(
child: _error != null
? Center(child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!),
const SizedBox(height: 8),
FilledButton.tonal(onPressed: _refresh, child: const Text('重试')),
],
))
: RefreshIndicator(
onRefresh: _refresh,
child: filtered.isEmpty && !_loading
? const Center(child: Text('暂无数据'))
: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
itemCount: filtered.length + (_loading ? 1 : 0),
itemBuilder: (ctx, i) {
if (i >= filtered.length) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
final item = filtered[i];
return Card(
child: ListTile(
leading: item.coverUrl.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.network(
item.coverUrl,
width: 50,
height: 70,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
Container(
width: 50,
height: 70,
color: Colors.grey.shade300,
child: const Icon(Icons.video_library, size: 24),
),
),
)
: Container(
width: 50,
height: 70,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6),
),
child: const Icon(Icons.video_library, size: 24),
),
title: Text(item.name,
style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text('编码: ${item.code}'),
Text('类型: ${_typeLabel(item.type)}'),
if (item.duration > 0)
Text('时长: ${_formatDuration(item.duration)}'),
const SizedBox(height: 4),
Chip(
label: Text(item.status == 0 ? '正常' : '禁用'),
visualDensity: VisualDensity.compact,
),
],
),
trailing: PopupMenuButton<String>(
onSelected: (v) {
if (v == 'edit') _showForm(editing: item);
if (v == 'delete') _delete(item.id);
},
itemBuilder: (_) => [
const PopupMenuItem(value: 'edit', child: Text('编辑')),
const PopupMenuItem(value: 'delete', child: Text('删除')),
],
),
),
);
},
),
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showForm(),
child: const Icon(Icons.add),
),
);
}
}

View File

@@ -0,0 +1,368 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/media_asset.dart';
import '../../../services/media_service.dart';
import '../../../services/api_client.dart';
/// 媒资排行榜 Top10 管理页。
class MediaRankPage extends ConsumerStatefulWidget {
const MediaRankPage({super.key});
@override
ConsumerState<MediaRankPage> createState() => _MediaRankPageState();
}
class _MediaRankPageState extends ConsumerState<MediaRankPage> {
List<MediaRankItem> _items = [];
bool _loading = false;
String? _error;
@override
void initState() {
super.initState();
_loadData();
}
Future<void> _loadData() async {
setState(() {
_loading = true;
_error = null;
});
try {
final list = await MediaService().getMediaAssetRankTop10();
if (!mounted) return;
setState(() {
_items = list;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_loading = false;
_error = e is ApiException ? e.message : '加载失败';
});
}
}
Future<void> _moveUp(String code) async {
try {
await MediaService().moveUpMediaAssetRank(code);
_loadData();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e is ApiException ? e.message : '操作失败')),
);
}
}
Future<void> _moveDown(String code) async {
try {
await MediaService().moveDownMediaAssetRank(code);
_loadData();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e is ApiException ? e.message : '操作失败')),
);
}
}
Future<void> _remove(String code) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('确认移除'),
content: const Text('确定要从排行榜移除此媒资吗?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('移除')),
],
),
);
if (confirmed != true) return;
try {
await MediaService().removeMediaAssetRank(code);
_loadData();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('移除成功')));
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e is ApiException ? e.message : '移除失败')),
);
}
}
Future<void> _clearAll() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('确认清空'),
content: const Text('确定要清空整个排行榜吗?此操作不可撤销。'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('清空')),
],
),
);
if (confirmed != true) return;
try {
await MediaService().clearMediaAssetRank();
_loadData();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已清空')));
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e is ApiException ? e.message : '操作失败')),
);
}
}
void _showAddDialog() {
final searchCtrl = TextEditingController();
List<MediaAsset> allMedia = [];
bool searching = false;
showModalBottomSheet<void>(
useSafeArea: true,
context: context,
isScrollControlled: true,
builder: (sheetCtx) {
return StatefulBuilder(
builder: (ctx, setSheet) {
Future<void> doSearch() async {
setSheet(() => searching = true);
try {
allMedia = await MediaService().getMediaAssetList(page: 1, limit: 50);
} catch (_) {
allMedia = [];
} finally {
setSheet(() => searching = false);
}
}
if (allMedia.isEmpty && !searching) {
doSearch();
}
final kw = searchCtrl.text.trim().toLowerCase();
final filtered = kw.isEmpty
? allMedia
: allMedia.where((m) => m.name.toLowerCase().contains(kw)).toList();
final existingCodes = _items.map((e) => e.code).toSet();
final available = filtered.where((m) => !existingCodes.contains(m.code)).toList();
return SizedBox(
height: MediaQuery.of(ctx).size.height * 0.7,
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Text('选择媒资', style: Theme.of(ctx).textTheme.titleLarge),
const Spacer(),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(ctx).pop(),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: TextField(
controller: searchCtrl,
decoration: const InputDecoration(
hintText: '搜索媒资名称',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
),
onChanged: (_) => setSheet(() {}),
),
),
const SizedBox(height: 8),
Expanded(
child: searching
? const Center(child: CircularProgressIndicator())
: available.isEmpty
? const Center(child: Text('无可选媒资'))
: ListView.builder(
itemCount: available.length,
itemBuilder: (_, index) {
final media = available[index];
return ListTile(
leading: media.coverUrl.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.network(
media.coverUrl,
width: 40,
height: 56,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
const SizedBox(
width: 40,
height: 56,
child: Icon(Icons.video_library)),
),
)
: const SizedBox(
width: 40,
height: 56,
child: Icon(Icons.video_library),
),
title: Text(media.name),
subtitle: Text(media.code),
trailing: FilledButton.tonal(
onPressed: () => _addToRank(ctx, media.code, media.name),
child: const Text('添加'),
),
);
},
),
),
],
),
);
},
);
},
);
}
Future<void> _addToRank(BuildContext sheetCtx, String code, String name) async {
try {
await MediaService().setMediaAssetRank([code]);
if (!mounted) return;
Navigator.of(sheetCtx).pop();
_loadData();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('已添加「$name」到排行榜')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e is ApiException ? e.message : '添加失败')),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
title: const Text('媒资排行榜 Top10'),
actions: [
if (_items.isNotEmpty)
IconButton(
icon: const Icon(Icons.delete_sweep),
onPressed: _clearAll,
tooltip: '清空排行榜',
),
],
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!),
const SizedBox(height: 8),
FilledButton.tonal(onPressed: _loadData, child: const Text('重试')),
],
))
: _items.isEmpty
? const Center(child: Text('排行榜为空,点击+添加'))
: ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: _items.length,
itemBuilder: (ctx, i) {
final item = _items[i];
final rank = i + 1;
return Card(
child: ListTile(
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 36,
height: 48,
alignment: Alignment.center,
decoration: BoxDecoration(
color: rank <= 3
? [Colors.amber, Colors.grey, Colors.brown][rank - 1]
: Theme.of(ctx).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'$rank',
style: TextStyle(
fontWeight: FontWeight.bold,
color: rank <= 3 ? Colors.white : null,
),
),
),
const SizedBox(width: 8),
if (item.coverUrl.isNotEmpty)
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.network(
item.coverUrl,
width: 36,
height: 48,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
const SizedBox(
width: 36,
height: 48,
child: Icon(Icons.video_library, size: 20)),
),
)
else
const SizedBox(
width: 36,
height: 48,
child: Icon(Icons.video_library, size: 20),
),
],
),
title: Text(item.mediaName.isNotEmpty
? item.mediaName
: item.code),
subtitle: Text(item.code),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.arrow_upward),
onPressed: i > 0 ? () => _moveUp(item.code) : null,
),
IconButton(
icon: const Icon(Icons.arrow_downward),
onPressed: i < _items.length - 1
? () => _moveDown(item.code)
: null,
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => _remove(item.code),
),
],
),
),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: _showAddDialog,
child: const Icon(Icons.add),
),
);
}
}

View File

@@ -0,0 +1,404 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/sys_config.dart';
import '../../../services/api_client.dart';
import '../../../services/sys_service.dart';
/// 参数配置页。
class ConfigPage extends ConsumerStatefulWidget {
const ConfigPage({super.key});
@override
ConsumerState<ConfigPage> createState() => _ConfigPageState();
}
class _ConfigPageState extends ConsumerState<ConfigPage> {
final SystemService _service = SystemService();
final List<SysConfig> _items = [];
bool _loading = false;
int _page = 1;
bool _hasMore = true;
String? _error;
final TextEditingController _searchController = TextEditingController();
String _searchKeyword = '';
final ScrollController _scrollController = ScrollController();
final int _limit = 20;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_loadData();
}
@override
void dispose() {
_searchController.dispose();
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
_loadMore();
}
}
Future<void> _loadData({bool reset = false}) async {
if (_loading) return;
if (reset) {
_page = 1;
_hasMore = true;
_items.clear();
}
setState(() {
_loading = true;
_error = null;
});
try {
final list = await _service.getConfigList(page: _page, limit: _limit);
_items.addAll(list);
_hasMore = list.length >= _limit;
} on ApiException catch (e) {
_error = e.message;
} catch (e) {
_error = e.toString();
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _loadMore() async {
if (_loading || !_hasMore) return;
_page++;
await _loadData();
}
Future<void> _refresh() async {
await _loadData(reset: true);
}
void _onSearchChanged(String value) {
_searchKeyword = value;
// 本地过滤,因为 getConfigList 不支持搜索参数
setState(() {});
}
List<SysConfig> get _filteredItems {
if (_searchKeyword.isEmpty) return _items;
return _items
.where((c) => c.paramKey.toLowerCase().contains(
_searchKeyword.toLowerCase(),
))
.toList();
}
Future<void> _showForm({SysConfig? config}) async {
final result = await showModalBottomSheet<Map<String, dynamic>>(
context: context,
isScrollControlled: true,
builder: (context) => _ConfigForm(config: config),
);
if (result == null) return;
try {
await _service.saveConfig(result);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('保存成功')),
);
_refresh();
}
} on ApiException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message)),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('保存失败:$e')),
);
}
}
}
Future<void> _deleteConfig(SysConfig config) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('确认删除'),
content: Text('确定要删除参数「${config.paramKey}」吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('删除'),
),
],
),
);
if (confirmed != true) return;
try {
await _service.deleteConfig([int.tryParse(config.id) ?? 0]);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('删除成功')),
);
_refresh();
}
} on ApiException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message)),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('删除失败:$e')),
);
}
}
}
@override
Widget build(BuildContext context) {
final filtered = _filteredItems;
return Scaffold(
appBar: AppBar(automaticallyImplyLeading: false, title: const Text('参数配置')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: '搜索参数键名',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchKeyword.isEmpty
? null
: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
_onSearchChanged('');
},
),
border: const OutlineInputBorder(),
),
onChanged: _onSearchChanged,
),
),
Expanded(child: _buildBody(filtered)),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showForm(),
child: const Icon(Icons.add),
),
);
}
Widget _buildBody(List<SysConfig> items) {
if (_error != null && _items.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: _refresh,
child: const Text('重试'),
),
],
),
);
}
if (items.isEmpty && !_loading) {
return const Center(child: Text('暂无数据'));
}
return RefreshIndicator(
onRefresh: _refresh,
child: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
itemCount: items.length + 1,
itemBuilder: (context, index) {
if (index == items.length) {
if (_loading) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
if (!_hasMore && _searchKeyword.isEmpty) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: Text('没有更多数据')),
);
}
return const SizedBox.shrink();
}
final config = items[index];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ListTile(
title: Text(config.paramKey),
subtitle: Text(
'值:${config.paramValue.isEmpty ? "(空)" : config.paramValue}'
'${config.remark.isNotEmpty ? "\n备注:${config.remark}" : ""}',
),
trailing: PopupMenuButton<String>(
onSelected: (value) {
if (value == 'edit') {
_showForm(config: config);
} else if (value == 'delete') {
_deleteConfig(config);
}
},
itemBuilder: (context) => [
const PopupMenuItem(value: 'edit', child: Text('编辑')),
const PopupMenuItem(value: 'delete', child: Text('删除')),
],
),
),
);
},
),
);
}
}
/// 参数配置表单。
class _ConfigForm extends StatefulWidget {
const _ConfigForm({this.config});
final SysConfig? config;
@override
State<_ConfigForm> createState() => _ConfigFormState();
}
class _ConfigFormState extends State<_ConfigForm> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _paramKeyCtrl;
late TextEditingController _paramValueCtrl;
late TextEditingController _remarkCtrl;
@override
void initState() {
super.initState();
_paramKeyCtrl = TextEditingController(text: widget.config?.paramKey ?? '');
_paramValueCtrl =
TextEditingController(text: widget.config?.paramValue ?? '');
_remarkCtrl = TextEditingController(text: widget.config?.remark ?? '');
}
@override
void dispose() {
_paramKeyCtrl.dispose();
_paramValueCtrl.dispose();
_remarkCtrl.dispose();
super.dispose();
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
final data = <String, dynamic>{
'paramKey': _paramKeyCtrl.text.trim(),
'paramValue': _paramValueCtrl.text.trim(),
'remark': _remarkCtrl.text.trim(),
};
if (widget.config != null) {
data['id'] = widget.config!.id;
}
Navigator.pop(context, data);
}
@override
Widget build(BuildContext context) {
final isEdit = widget.config != null;
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
isEdit ? '编辑参数' : '新增参数',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
TextFormField(
controller: _paramKeyCtrl,
decoration: const InputDecoration(
labelText: '参数键名',
border: OutlineInputBorder(),
),
validator: (v) =>
(v == null || v.trim().isEmpty) ? '请输入参数键名' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _paramValueCtrl,
maxLines: 3,
decoration: const InputDecoration(
labelText: '参数值',
border: OutlineInputBorder(),
),
validator: (v) =>
(v == null || v.trim().isEmpty) ? '请输入参数值' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _remarkCtrl,
maxLines: 2,
decoration: const InputDecoration(
labelText: '备注',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _submit,
child: const Text('保存'),
),
],
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,261 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/sys_log.dart';
import '../../../services/api_client.dart';
import '../../../services/sys_service.dart';
/// 系统日志页(TabBar:系统日志 / 审计日志 / JST调用日志)。
class LogPage extends ConsumerStatefulWidget {
const LogPage({super.key});
@override
ConsumerState<LogPage> createState() => _LogPageState();
}
class _LogPageState extends ConsumerState<LogPage>
with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
title: const Text('系统日志'),
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(text: '系统日志'),
Tab(text: '审计日志'),
Tab(text: 'JST调用日志'),
],
),
),
body: TabBarView(
controller: _tabController,
children: const [
_LogTab(title: '系统日志', logType: _LogType.system),
_LogTab(title: '审计日志', logType: _LogType.audit),
_LogTab(title: 'JST调用日志', logType: _LogType.jst),
],
),
);
}
}
/// 日志类型枚举。
enum _LogType { system, audit, jst }
/// 日志列表 Tab。
class _LogTab extends StatefulWidget {
const _LogTab({
required this.title,
required this.logType,
});
final String title;
final _LogType logType;
@override
State<_LogTab> createState() => _LogTabState();
}
class _LogTabState extends State<_LogTab>
with AutomaticKeepAliveClientMixin {
final SystemService _service = SystemService();
final List<SysLog> _items = [];
bool _loading = false;
int _page = 1;
bool _hasMore = true;
String? _error;
final ScrollController _scrollController = ScrollController();
final int _limit = 20;
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_loadData();
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
_loadMore();
}
}
Future<List<SysLog>> _fetch({int page = 1, int limit = 20}) {
switch (widget.logType) {
case _LogType.system:
return _service.getLogList(page: page, limit: limit);
case _LogType.audit:
return _service.getAuditLogList(page: page, limit: limit);
case _LogType.jst:
return _service.getApiJstCallLogList(page: page, limit: limit);
}
}
Future<void> _loadData({bool reset = false}) async {
if (_loading) return;
if (reset) {
_page = 1;
_hasMore = true;
_items.clear();
}
setState(() {
_loading = true;
_error = null;
});
try {
final list = await _fetch(page: _page, limit: _limit);
_items.addAll(list);
_hasMore = list.length >= _limit;
} on ApiException catch (e) {
_error = e.message;
} catch (e) {
_error = e.toString();
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _loadMore() async {
if (_loading || !_hasMore) return;
_page++;
await _loadData();
}
Future<void> _refresh() async {
await _loadData(reset: true);
}
String _formatDate(DateTime? dt) {
if (dt == null) return '';
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-'
'${dt.day.toString().padLeft(2, '0')} '
'${dt.hour.toString().padLeft(2, '0')}:'
'${dt.minute.toString().padLeft(2, '0')}:'
'${dt.second.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
super.build(context);
if (_error != null && _items.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: _refresh,
child: const Text('重试'),
),
],
),
);
}
if (_items.isEmpty && !_loading) {
return const Center(child: Text('暂无数据'));
}
return RefreshIndicator(
onRefresh: _refresh,
child: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _items.length + 1,
itemBuilder: (context, index) {
if (index == _items.length) {
if (_loading) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
if (!_hasMore) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: Text('没有更多数据')),
);
}
return const SizedBox.shrink();
}
final log = _items[index];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
log.operation.isEmpty
? '(无操作描述)'
: log.operation,
style: Theme.of(context).textTheme.titleSmall,
),
),
Text(
'${log.time}ms',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
const SizedBox(height: 4),
Text('用户:${log.username}'),
const SizedBox(height: 2),
Text(
'方法:${log.method}',
style: Theme.of(context).textTheme.bodySmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text('IP:${log.ip}'),
const SizedBox(height: 2),
Text(
'时间:${_formatDate(log.createDate)}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
);
},
),
);
}
}

View File

@@ -0,0 +1,511 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/sys_menu.dart';
import '../../../services/api_client.dart';
import '../../../services/sys_service.dart';
/// 菜单管理页(树形展示)。
class MenuPage extends ConsumerStatefulWidget {
const MenuPage({super.key});
@override
ConsumerState<MenuPage> createState() => _MenuPageState();
}
class _MenuPageState extends ConsumerState<MenuPage> {
final SystemService _service = SystemService();
List<SysMenu> _items = [];
bool _loading = false;
String? _error;
@override
void initState() {
super.initState();
_loadData();
}
Future<void> _loadData() async {
setState(() {
_loading = true;
_error = null;
});
try {
_items = await _service.getMenuList();
} on ApiException catch (e) {
_error = e.message;
} catch (e) {
_error = e.toString();
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _refresh() async {
await _loadData();
}
/// 构建菜单树:parentId 为空或 "0" 的为根节点。
List<_MenuNode> _buildTree(List<SysMenu> menus) {
final nodeMap = <String, _MenuNode>{};
for (final menu in menus) {
nodeMap[menu.menuId] = _MenuNode(menu: menu);
}
final roots = <_MenuNode>[];
for (final menu in menus) {
final node = nodeMap[menu.menuId]!;
final parentId = menu.parentId.isEmpty || menu.parentId == '0'
? null
: menu.parentId;
if (parentId == null || !nodeMap.containsKey(parentId)) {
roots.add(node);
} else {
nodeMap[parentId]!.children.add(node);
}
}
// 排序
void sortNodes(List<_MenuNode> nodes) {
nodes.sort((a, b) => a.menu.orderNum.compareTo(b.menu.orderNum));
for (final node in nodes) {
sortNodes(node.children);
}
}
sortNodes(roots);
return roots;
}
Future<void> _showForm({SysMenu? menu}) async {
final result = await showModalBottomSheet<Map<String, dynamic>>(
context: context,
isScrollControlled: true,
builder: (context) => _MenuForm(menu: menu, allMenus: _items),
);
if (result == null) return;
try {
await _service.saveMenu(result);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('保存成功')),
);
_refresh();
}
} on ApiException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message)),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('保存失败:$e')),
);
}
}
}
Future<void> _deleteMenu(SysMenu menu) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('确认删除'),
content: Text('确定要删除菜单「${menu.name}」吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('删除'),
),
],
),
);
if (confirmed != true) return;
try {
await _service.deleteMenu(menu.menuId);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('删除成功')),
);
_refresh();
}
} on ApiException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message)),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('删除失败:$e')),
);
}
}
}
String _typeLabel(int type) {
switch (type) {
case 0:
return '目录';
case 1:
return '菜单';
case 2:
return '按钮';
default:
return '未知';
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(automaticallyImplyLeading: false, title: const Text('菜单管理')),
body: _buildBody(),
floatingActionButton: FloatingActionButton(
onPressed: () => _showForm(),
child: const Icon(Icons.add),
),
);
}
Widget _buildBody() {
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: _refresh,
child: const Text('重试'),
),
],
),
);
}
if (_items.isEmpty) {
return const Center(child: Text('暂无数据'));
}
final tree = _buildTree(_items);
return RefreshIndicator(
onRefresh: _refresh,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: tree.length,
itemBuilder: (context, index) {
return _buildTreeNode(tree[index]);
},
),
);
}
Widget _buildTreeNode(_MenuNode node) {
final menu = node.menu;
final hasChildren = node.children.isNotEmpty;
if (hasChildren) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ExpansionTile(
leading: Icon(_iconData(menu.icon)),
title: Text(menu.name),
subtitle: Text(
'${_typeLabel(menu.type)} | ${menu.url.isEmpty ? "无路由" : menu.url}',
),
trailing: PopupMenuButton<String>(
onSelected: (value) {
if (value == 'edit') {
_showForm(menu: menu);
} else if (value == 'delete') {
_deleteMenu(menu);
}
},
itemBuilder: (context) => [
const PopupMenuItem(value: 'edit', child: Text('编辑')),
const PopupMenuItem(value: 'delete', child: Text('删除')),
],
),
children: node.children.map((child) => _buildTreeNode(child)).toList(),
),
);
}
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ListTile(
leading: Icon(_iconData(menu.icon)),
title: Text(menu.name),
subtitle: Text(
'${_typeLabel(menu.type)} | ${menu.url.isEmpty ? "无路由" : menu.url}',
),
trailing: PopupMenuButton<String>(
onSelected: (value) {
if (value == 'edit') {
_showForm(menu: menu);
} else if (value == 'delete') {
_deleteMenu(menu);
}
},
itemBuilder: (context) => [
const PopupMenuItem(value: 'edit', child: Text('编辑')),
const PopupMenuItem(value: 'delete', child: Text('删除')),
],
),
),
);
}
IconData _iconData(String icon) {
switch (icon) {
case 'home':
return Icons.home;
case 'settings':
return Icons.settings;
case 'person':
return Icons.person;
case 'people':
return Icons.people;
case 'menu':
return Icons.menu;
case 'list':
return Icons.list;
case 'dashboard':
return Icons.dashboard;
case 'devices':
return Icons.devices;
case 'notifications':
return Icons.notifications;
default:
return Icons.folder;
}
}
}
/// 菜单树节点辅助类。
class _MenuNode {
_MenuNode({required this.menu});
final SysMenu menu;
final List<_MenuNode> children = [];
}
/// 菜单表单。
class _MenuForm extends StatefulWidget {
const _MenuForm({this.menu, this.allMenus = const []});
final SysMenu? menu;
final List<SysMenu> allMenus;
@override
State<_MenuForm> createState() => _MenuFormState();
}
class _MenuFormState extends State<_MenuForm> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _nameCtrl;
late TextEditingController _urlCtrl;
late TextEditingController _iconCtrl;
late TextEditingController _orderNumCtrl;
late TextEditingController _permsCtrl;
late String _parentId;
late int _type;
late bool _show;
@override
void initState() {
super.initState();
final m = widget.menu;
_nameCtrl = TextEditingController(text: m?.name ?? '');
_urlCtrl = TextEditingController(text: m?.url ?? '');
_iconCtrl = TextEditingController(text: m?.icon ?? '');
_orderNumCtrl = TextEditingController(text: (m?.orderNum ?? 0).toString());
_permsCtrl = TextEditingController(text: m?.perms ?? '');
_parentId = m?.parentId ?? '0';
_type = m?.type ?? 0;
_show = (m?.show ?? 0) == 0;
}
@override
void dispose() {
_nameCtrl.dispose();
_urlCtrl.dispose();
_iconCtrl.dispose();
_orderNumCtrl.dispose();
_permsCtrl.dispose();
super.dispose();
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
final data = <String, dynamic>{
'name': _nameCtrl.text.trim(),
'parentId': _parentId,
'type': _type,
'url': _urlCtrl.text.trim(),
'icon': _iconCtrl.text.trim(),
'orderNum': int.tryParse(_orderNumCtrl.text.trim()) ?? 0,
'perms': _permsCtrl.text.trim(),
'show': _show ? 0 : 1,
};
if (widget.menu != null) {
data['menuId'] = widget.menu!.menuId;
}
Navigator.pop(context, data);
}
@override
Widget build(BuildContext context) {
final isEdit = widget.menu != null;
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
isEdit ? '编辑菜单' : '新增菜单',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
TextFormField(
controller: _nameCtrl,
decoration: const InputDecoration(
labelText: '菜单名称',
border: OutlineInputBorder(),
),
validator: (v) =>
(v == null || v.trim().isEmpty) ? '请输入菜单名称' : null,
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
value: _parentId,
decoration: const InputDecoration(
labelText: '父级菜单',
border: OutlineInputBorder(),
),
items: [
const DropdownMenuItem(value: '0', child: Text('一级菜单')),
...widget.allMenus.map((m) => DropdownMenuItem(
value: m.menuId,
child: Text(m.name),
)),
],
onChanged: (v) => setState(() => _parentId = v ?? '0'),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: RadioListTile<int>(
title: const Text('目录'),
value: 0,
groupValue: _type,
onChanged: (v) => setState(() => _type = v ?? 0),
contentPadding: EdgeInsets.zero,
),
),
Expanded(
child: RadioListTile<int>(
title: const Text('菜单'),
value: 1,
groupValue: _type,
onChanged: (v) => setState(() => _type = v ?? 1),
contentPadding: EdgeInsets.zero,
),
),
Expanded(
child: RadioListTile<int>(
title: const Text('按钮'),
value: 2,
groupValue: _type,
onChanged: (v) => setState(() => _type = v ?? 2),
contentPadding: EdgeInsets.zero,
),
),
],
),
const SizedBox(height: 12),
TextFormField(
controller: _urlCtrl,
decoration: const InputDecoration(
labelText: '路由地址',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextFormField(
controller: _iconCtrl,
decoration: const InputDecoration(
labelText: '图标',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextFormField(
controller: _orderNumCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '排序号',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextFormField(
controller: _permsCtrl,
decoration: const InputDecoration(
labelText: '权限标识',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
SwitchListTile(
title: const Text('是否显示'),
value: _show,
onChanged: (v) => setState(() => _show = v),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _submit,
child: const Text('保存'),
),
],
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,340 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/sys_role.dart';
import '../../../services/api_client.dart';
import '../../../services/sys_service.dart';
/// 角色管理页。
class RolePage extends ConsumerStatefulWidget {
const RolePage({super.key});
@override
ConsumerState<RolePage> createState() => _RolePageState();
}
class _RolePageState extends ConsumerState<RolePage> {
final SystemService _service = SystemService();
final List<SysRole> _items = [];
bool _loading = false;
int _page = 1;
bool _hasMore = true;
String? _error;
final ScrollController _scrollController = ScrollController();
final int _limit = 20;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_loadData();
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
_loadMore();
}
}
Future<void> _loadData({bool reset = false}) async {
if (_loading) return;
if (reset) {
_page = 1;
_hasMore = true;
_items.clear();
}
setState(() {
_loading = true;
_error = null;
});
try {
final list = await _service.getRoleList(page: _page, limit: _limit);
_items.addAll(list);
_hasMore = list.length >= _limit;
} on ApiException catch (e) {
_error = e.message;
} catch (e) {
_error = e.toString();
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _loadMore() async {
if (_loading || !_hasMore) return;
_page++;
await _loadData();
}
Future<void> _refresh() async {
await _loadData(reset: true);
}
Future<void> _showForm({SysRole? role}) async {
final result = await showModalBottomSheet<Map<String, dynamic>>(
context: context,
isScrollControlled: true,
builder: (context) => _RoleForm(role: role),
);
if (result == null) return;
try {
await _service.saveRole(result);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('保存成功')),
);
_refresh();
}
} on ApiException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message)),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('保存失败:$e')),
);
}
}
}
Future<void> _deleteRole(SysRole role) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('确认删除'),
content: Text('确定要删除角色「${role.roleName}」吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('删除'),
),
],
),
);
if (confirmed != true) return;
try {
await _service.deleteRole([int.tryParse(role.roleId) ?? 0]);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('删除成功')),
);
_refresh();
}
} on ApiException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message)),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('删除失败:$e')),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(automaticallyImplyLeading: false, title: const Text('角色管理')),
body: _buildBody(),
floatingActionButton: FloatingActionButton(
onPressed: () => _showForm(),
child: const Icon(Icons.add),
),
);
}
Widget _buildBody() {
if (_error != null && _items.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: _refresh,
child: const Text('重试'),
),
],
),
);
}
if (_items.isEmpty && !_loading) {
return const Center(child: Text('暂无数据'));
}
return RefreshIndicator(
onRefresh: _refresh,
child: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _items.length + 1,
itemBuilder: (context, index) {
if (index == _items.length) {
if (_loading) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
if (!_hasMore) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: Text('没有更多数据')),
);
}
return const SizedBox.shrink();
}
final role = _items[index];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ListTile(
title: Text(role.roleName),
subtitle: Text(role.remark.isEmpty ? '无备注' : role.remark),
trailing: PopupMenuButton<String>(
onSelected: (value) {
if (value == 'edit') {
_showForm(role: role);
} else if (value == 'delete') {
_deleteRole(role);
}
},
itemBuilder: (context) => [
const PopupMenuItem(value: 'edit', child: Text('编辑')),
const PopupMenuItem(value: 'delete', child: Text('删除')),
],
),
),
);
},
),
);
}
}
/// 角色表单。
class _RoleForm extends StatefulWidget {
const _RoleForm({this.role});
final SysRole? role;
@override
State<_RoleForm> createState() => _RoleFormState();
}
class _RoleFormState extends State<_RoleForm> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _roleNameCtrl;
late TextEditingController _remarkCtrl;
@override
void initState() {
super.initState();
_roleNameCtrl = TextEditingController(text: widget.role?.roleName ?? '');
_remarkCtrl = TextEditingController(text: widget.role?.remark ?? '');
}
@override
void dispose() {
_roleNameCtrl.dispose();
_remarkCtrl.dispose();
super.dispose();
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
final data = <String, dynamic>{
'roleName': _roleNameCtrl.text.trim(),
'remark': _remarkCtrl.text.trim(),
};
if (widget.role != null) {
data['roleId'] = widget.role!.roleId;
}
Navigator.pop(context, data);
}
@override
Widget build(BuildContext context) {
final isEdit = widget.role != null;
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
isEdit ? '编辑角色' : '新增角色',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
TextFormField(
controller: _roleNameCtrl,
decoration: const InputDecoration(
labelText: '角色名称',
border: OutlineInputBorder(),
),
validator: (v) =>
(v == null || v.trim().isEmpty) ? '请输入角色名称' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _remarkCtrl,
maxLines: 3,
decoration: const InputDecoration(
labelText: '备注',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _submit,
child: const Text('保存'),
),
],
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,624 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/schedule.dart';
import '../../../models/sys_log.dart';
import '../../../services/api_client.dart';
import '../../../services/sys_service.dart';
/// 定时任务管理页。
class SchedulePage extends ConsumerStatefulWidget {
const SchedulePage({super.key});
@override
ConsumerState<SchedulePage> createState() => _SchedulePageState();
}
class _SchedulePageState extends ConsumerState<SchedulePage> {
final SystemService _service = SystemService();
final List<Schedule> _items = [];
bool _loading = false;
int _page = 1;
bool _hasMore = true;
String? _error;
final ScrollController _scrollController = ScrollController();
final int _limit = 20;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_loadData();
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
_loadMore();
}
}
Future<void> _loadData({bool reset = false}) async {
if (_loading) return;
if (reset) {
_page = 1;
_hasMore = true;
_items.clear();
}
setState(() {
_loading = true;
_error = null;
});
try {
final list = await _service.getScheduleList(page: _page, limit: _limit);
_items.addAll(list);
_hasMore = list.length >= _limit;
} on ApiException catch (e) {
_error = e.message;
} catch (e) {
_error = e.toString();
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _loadMore() async {
if (_loading || !_hasMore) return;
_page++;
await _loadData();
}
Future<void> _refresh() async {
await _loadData(reset: true);
}
Future<void> _showForm({Schedule? schedule}) async {
final result = await showModalBottomSheet<Map<String, dynamic>>(
context: context,
isScrollControlled: true,
builder: (context) => _ScheduleForm(schedule: schedule),
);
if (result == null) return;
try {
await _service.saveSchedule(result);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('保存成功')),
);
_refresh();
}
} on ApiException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message)),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('保存失败:$e')),
);
}
}
}
Future<void> _deleteSchedule(Schedule schedule) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('确认删除'),
content: Text('确定要删除任务「${schedule.beanName}.${schedule.methodName}」吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('删除'),
),
],
),
);
if (confirmed != true) return;
try {
await _service.deleteSchedule([int.tryParse(schedule.jobId) ?? 0]);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('删除成功')),
);
_refresh();
}
} on ApiException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message)),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('删除失败:$e')),
);
}
}
}
Future<void> _pauseSchedule(Schedule schedule) async {
try {
await _service.pauseSchedule(schedule.jobId);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('任务已暂停')),
);
_refresh();
}
} on ApiException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message)),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('暂停失败:$e')),
);
}
}
}
Future<void> _resumeSchedule(Schedule schedule) async {
try {
await _service.resumeSchedule(schedule.jobId);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('任务已恢复')),
);
_refresh();
}
} on ApiException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message)),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('恢复失败:$e')),
);
}
}
}
Future<void> _runSchedule(Schedule schedule) async {
try {
await _service.runSchedule(schedule.jobId);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('任务已触发执行')),
);
}
} on ApiException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message)),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('执行失败:$e')),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(automaticallyImplyLeading: false, title: const Text('定时任务')),
body: _buildBody(),
floatingActionButton: FloatingActionButton(
onPressed: () => _showForm(),
child: const Icon(Icons.add),
),
);
}
Widget _buildBody() {
if (_error != null && _items.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: _refresh,
child: const Text('重试'),
),
],
),
);
}
if (_items.isEmpty && !_loading) {
return const Center(child: Text('暂无数据'));
}
return RefreshIndicator(
onRefresh: _refresh,
child: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _items.length + 1,
itemBuilder: (context, index) {
if (index == _items.length) {
if (_loading) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
if (!_hasMore) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: Text('没有更多数据')),
);
}
return const SizedBox.shrink();
}
final schedule = _items[index];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ListTile(
title: Text('${schedule.beanName}.${schedule.methodName}'),
subtitle: Text(
'Cron:${schedule.cronExpression} | ${schedule.status == 0 ? '正常' : '暂停'}'
'${schedule.remark.isNotEmpty ? "\n${schedule.remark}" : ""}',
),
trailing: PopupMenuButton<String>(
onSelected: (value) {
switch (value) {
case 'edit':
_showForm(schedule: schedule);
break;
case 'delete':
_deleteSchedule(schedule);
break;
case 'pause':
_pauseSchedule(schedule);
break;
case 'resume':
_resumeSchedule(schedule);
break;
case 'run':
_runSchedule(schedule);
break;
case 'log':
_showScheduleLog(schedule);
break;
}
},
itemBuilder: (context) => [
const PopupMenuItem(value: 'edit', child: Text('编辑')),
const PopupMenuItem(value: 'delete', child: Text('删除')),
const PopupMenuItem(value: 'pause', child: Text('暂停')),
const PopupMenuItem(value: 'resume', child: Text('恢复')),
const PopupMenuItem(value: 'run', child: Text('立即运行')),
const PopupMenuItem(value: 'log', child: Text('日志')),
],
),
),
);
},
),
);
}
void _showScheduleLog(Schedule schedule) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => _ScheduleLogPage(jobId: schedule.jobId),
),
);
}
}
/// 定时任务日志页。
class _ScheduleLogPage extends StatefulWidget {
const _ScheduleLogPage({required this.jobId});
final String jobId;
@override
State<_ScheduleLogPage> createState() => _ScheduleLogPageState();
}
class _ScheduleLogPageState extends State<_ScheduleLogPage> {
final SystemService _service = SystemService();
final List<SysLog> _items = [];
bool _loading = false;
int _page = 1;
bool _hasMore = true;
String? _error;
final ScrollController _scrollController = ScrollController();
final int _limit = 20;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_loadData();
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
_loadMore();
}
}
Future<void> _loadData({bool reset = false}) async {
if (_loading) return;
if (reset) {
_page = 1;
_hasMore = true;
_items.clear();
}
setState(() {
_loading = true;
_error = null;
});
try {
final list =
await _service.getScheduleLogList(page: _page, limit: _limit);
_items.addAll(list);
_hasMore = list.length >= _limit;
} on ApiException catch (e) {
_error = e.message;
} catch (e) {
_error = e.toString();
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _loadMore() async {
if (_loading || !_hasMore) return;
_page++;
await _loadData();
}
Future<void> _refresh() async {
await _loadData(reset: true);
}
String _formatDate(DateTime? dt) {
if (dt == null) return '';
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-'
'${dt.day.toString().padLeft(2, '0')} '
'${dt.hour.toString().padLeft(2, '0')}:'
'${dt.minute.toString().padLeft(2, '0')}:'
'${dt.second.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(automaticallyImplyLeading: false, title: const Text('任务日志')),
body: _buildBody(),
);
}
Widget _buildBody() {
if (_error != null && _items.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: _refresh,
child: const Text('重试'),
),
],
),
);
}
if (_items.isEmpty && !_loading) {
return const Center(child: Text('暂无日志'));
}
return RefreshIndicator(
onRefresh: _refresh,
child: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _items.length + 1,
itemBuilder: (context, index) {
if (index == _items.length) {
if (_loading) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
if (!_hasMore) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: Text('没有更多数据')),
);
}
return const SizedBox.shrink();
}
final log = _items[index];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
log.operation.isEmpty ? '(无操作描述)' : log.operation,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 4),
Text('用户:${log.username}'),
const SizedBox(height: 2),
Text(
'方法:${log.method}',
style: Theme.of(context).textTheme.bodySmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text('耗时:${log.time}ms'),
const SizedBox(height: 2),
Text(
'时间:${_formatDate(log.createDate)}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
);
},
),
);
}
}
/// 定时任务表单。
class _ScheduleForm extends StatefulWidget {
const _ScheduleForm({this.schedule});
final Schedule? schedule;
@override
State<_ScheduleForm> createState() => _ScheduleFormState();
}
class _ScheduleFormState extends State<_ScheduleForm> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _beanName;
late final TextEditingController _methodName;
late final TextEditingController _params;
late final TextEditingController _cronExpression;
late final TextEditingController _remark;
@override
void initState() {
super.initState();
final s = widget.schedule;
_beanName = TextEditingController(text: s?.beanName ?? '');
_methodName = TextEditingController(text: s?.methodName ?? '');
_params = TextEditingController(text: s?.params ?? '');
_cronExpression = TextEditingController(text: s?.cronExpression ?? '');
_remark = TextEditingController(text: s?.remark ?? '');
}
@override
void dispose() {
_beanName.dispose();
_methodName.dispose();
_params.dispose();
_cronExpression.dispose();
_remark.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isEdit = widget.schedule != null;
return SingleChildScrollView(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(isEdit ? '编辑任务' : '新增任务',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 16),
TextFormField(
controller: _beanName,
decoration: const InputDecoration(labelText: 'Bean名称'),
validator: (v) =>
(v == null || v.trim().isEmpty) ? '请输入Bean名称' : null,
),
TextFormField(
controller: _methodName,
decoration: const InputDecoration(labelText: '方法名称'),
validator: (v) =>
(v == null || v.trim().isEmpty) ? '请输入方法名称' : null,
),
TextFormField(
controller: _params,
decoration: const InputDecoration(labelText: '参数'),
),
TextFormField(
controller: _cronExpression,
decoration: const InputDecoration(labelText: 'Cron表达式'),
validator: (v) =>
(v == null || v.trim().isEmpty) ? '请输入Cron表达式' : null,
),
TextFormField(
controller: _remark,
decoration: const InputDecoration(labelText: '备注'),
),
const SizedBox(height: 16),
FilledButton(
onPressed: () {
if (_formKey.currentState?.validate() != true) return;
final data = <String, dynamic>{
if (isEdit) 'jobId': widget.schedule!.jobId,
'beanName': _beanName.text.trim(),
'methodName': _methodName.text.trim(),
'params': _params.text.trim(),
'cronExpression': _cronExpression.text.trim(),
'remark': _remark.text.trim(),
};
Navigator.pop(context, data);
},
child: const Text('保存'),
),
],
),
),
),
);
}
}

Some files were not shown because too many files have changed in this diff Show More