ops-console v1.0.2 (versionCode 8) 源码入库
This commit is contained in:
30
.metadata
Normal file
30
.metadata
Normal 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
845
CHANGELOG.md
Normal 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
216
README.md
Normal 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
18
analysis_options.yaml
Normal 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
13
android/.gitignore
vendored
Normal 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
55
android/app/build.gradle
Normal 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 = "../.."
|
||||||
7
android/app/src/debug/AndroidManifest.xml
Normal file
7
android/app/src/debug/AndroidManifest.xml
Normal 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>
|
||||||
51
android/app/src/main/AndroidManifest.xml
Normal file
51
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.tongshi.cloudhome
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity: FlutterActivity()
|
||||||
12
android/app/src/main/res/drawable-v21/launch_background.xml
Normal file
12
android/app/src/main/res/drawable-v21/launch_background.xml
Normal 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>
|
||||||
12
android/app/src/main/res/drawable/launch_background.xml
Normal file
12
android/app/src/main/res/drawable/launch_background.xml
Normal 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>
|
||||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.3 KiB |
22
android/app/src/main/res/values-night/styles.xml
Normal file
22
android/app/src/main/res/values-night/styles.xml
Normal 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>
|
||||||
5
android/app/src/main/res/values/strings.xml
Normal file
5
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- 应用副标题(APP 说明),在系统「应用信息」中展示 -->
|
||||||
|
<string name="app_description">Cloud Homepage Operations</string>
|
||||||
|
</resources>
|
||||||
22
android/app/src/main/res/values/styles.xml
Normal file
22
android/app/src/main/res/values/styles.xml
Normal 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>
|
||||||
7
android/app/src/profile/AndroidManifest.xml
Normal file
7
android/app/src/profile/AndroidManifest.xml
Normal 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
20
android/build.gradle
Normal 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
|
||||||
|
}
|
||||||
5
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
160
android/gradlew
vendored
Normal 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
90
android/gradlew.bat
vendored
Normal 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
29
android/settings.gradle
Normal 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"
|
||||||
14793
assets/data/MCityPiker/province.json
Normal file
14793
assets/data/MCityPiker/province.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
assets/images/logo.png
Normal file
BIN
assets/images/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.3 KiB |
87
docs/class-diagram.mermaid
Normal file
87
docs/class-diagram.mermaid
Normal 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
270
docs/intro_v38_design.md
Normal 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: 关闭表单 + 刷新列表
|
||||||
|
```
|
||||||
70
docs/sequence-diagram.mermaid
Normal file
70
docs/sequence-diagram.mermaid
Normal 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
292
docs/system_design.md
Normal 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 顺序落地即可。
|
||||||
1486
docs/手机端UI优化方案-v2.md
Normal file
1486
docs/手机端UI优化方案-v2.md
Normal file
File diff suppressed because it is too large
Load Diff
1475
docs/手机端UI优化方案.md
Normal file
1475
docs/手机端UI优化方案.md
Normal file
File diff suppressed because it is too large
Load Diff
1
ios/Flutter/Debug.xcconfig
Normal file
1
ios/Flutter/Debug.xcconfig
Normal file
@@ -0,0 +1 @@
|
|||||||
|
#include "Generated.xcconfig"
|
||||||
14
ios/Flutter/Generated.xcconfig
Normal file
14
ios/Flutter/Generated.xcconfig
Normal 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
|
||||||
1
ios/Flutter/Release.xcconfig
Normal file
1
ios/Flutter/Release.xcconfig
Normal file
@@ -0,0 +1 @@
|
|||||||
|
#include "Generated.xcconfig"
|
||||||
13
ios/Flutter/flutter_export_environment.sh
Normal file
13
ios/Flutter/flutter_export_environment.sh
Normal 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
41
ios/Podfile
Normal 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
|
||||||
13
ios/Runner/AppDelegate.swift
Normal file
13
ios/Runner/AppDelegate.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
13
ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
13
ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
20
ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
vendored
Normal file
20
ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
39
ios/Runner/Base.lproj/LaunchScreen.storyboard
Normal file
39
ios/Runner/Base.lproj/LaunchScreen.storyboard
Normal 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>
|
||||||
28
ios/Runner/Base.lproj/Main.storyboard
Normal file
28
ios/Runner/Base.lproj/Main.storyboard
Normal 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>
|
||||||
19
ios/Runner/GeneratedPluginRegistrant.h
Normal file
19
ios/Runner/GeneratedPluginRegistrant.h
Normal 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 */
|
||||||
63
ios/Runner/GeneratedPluginRegistrant.m
Normal file
63
ios/Runner/GeneratedPluginRegistrant.m
Normal 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
67
ios/Runner/Info.plist
Normal 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
355
lib/app/router.dart
Normal 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
423
lib/app/theme.dart
Normal 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;
|
||||||
|
}
|
||||||
55
lib/config/app_config.dart
Normal file
55
lib/config/app_config.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
lib/config/environment.dart
Normal file
17
lib/config/environment.dart
Normal 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,
|
||||||
|
}
|
||||||
17
lib/config/prod_config.dart
Normal file
17
lib/config/prod_config.dart
Normal 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;
|
||||||
|
}
|
||||||
48
lib/config/prod_guard.dart
Normal file
48
lib/config/prod_guard.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
lib/config/staging_config.dart
Normal file
16
lib/config/staging_config.dart
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/// 预发环境专属配置。
|
||||||
|
///
|
||||||
|
/// 与 [TestConfig] 完全隔离,**禁止交叉引用生产环境**的任何常量或文件。
|
||||||
|
/// 预发环境的服务地址、密钥、数据库连接等敏感配置统一在此独立管理。
|
||||||
|
class StagingConfig {
|
||||||
|
StagingConfig._();
|
||||||
|
|
||||||
|
/// 预发环境后端 origin(按实际预发域名填写,**严禁填写生产地址**)。
|
||||||
|
static const String apiBaseUrl = 'https://ips-staging.ainetv.com';
|
||||||
|
|
||||||
|
/// 预发环境是否启用登录验证码。
|
||||||
|
static const bool enableCaptcha = true;
|
||||||
|
|
||||||
|
// 预发环境密钥 / 数据库连接等敏感配置在此独立维护,
|
||||||
|
// 不得出现任何生产环境标识或引用。
|
||||||
|
}
|
||||||
16
lib/config/test_config.dart
Normal file
16
lib/config/test_config.dart
Normal 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
145
lib/data/region_data.dart
Normal 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
315
lib/main.dart
Normal 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
95
lib/models/alert.dart
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
95
lib/models/app_version.dart
Normal file
95
lib/models/app_version.dart
Normal 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
622
lib/models/device.dart
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
281
lib/models/intro_category.dart
Normal file
281
lib/models/intro_category.dart
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
132
lib/models/marquee_config.dart
Normal file
132
lib/models/marquee_config.dart
Normal 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;
|
||||||
|
}
|
||||||
211
lib/models/marquee_message.dart
Normal file
211
lib/models/marquee_message.dart
Normal 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
128
lib/models/media_asset.dart
Normal 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
273
lib/models/org_node.dart
Normal 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
75
lib/models/schedule.dart
Normal 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
59
lib/models/store_config.dart
Normal file
59
lib/models/store_config.dart
Normal 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
219
lib/models/store_info.dart
Normal 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('');
|
||||||
|
}
|
||||||
|
}
|
||||||
74
lib/models/store_theme.dart
Normal file
74
lib/models/store_theme.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
41
lib/models/sys_config.dart
Normal file
41
lib/models/sys_config.dart
Normal 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
74
lib/models/sys_log.dart
Normal 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
72
lib/models/sys_menu.dart
Normal 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
84
lib/models/sys_role.dart
Normal 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
218
lib/models/sys_user.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
94
lib/models/theme_group.dart
Normal file
94
lib/models/theme_group.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
55
lib/models/theme_template.dart
Normal file
55
lib/models/theme_template.dart
Normal 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
80
lib/models/tv_app.dart
Normal 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
76
lib/models/user.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
158
lib/providers/auth_provider.dart
Normal file
158
lib/providers/auth_provider.dart
Normal 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,
|
||||||
|
);
|
||||||
106
lib/providers/dashboard_provider.dart
Normal file
106
lib/providers/dashboard_provider.dart
Normal 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),
|
||||||
|
),
|
||||||
|
);
|
||||||
33
lib/providers/devices_provider.dart
Normal file
33
lib/providers/devices_provider.dart
Normal 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();
|
||||||
|
});
|
||||||
97
lib/providers/display_provider.dart
Normal file
97
lib/providers/display_provider.dart
Normal 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,
|
||||||
|
);
|
||||||
85
lib/providers/locale_provider.dart
Normal file
85
lib/providers/locale_provider.dart
Normal 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,
|
||||||
|
);
|
||||||
51
lib/providers/metrics_visibility_provider.dart
Normal file
51
lib/providers/metrics_visibility_provider.dart
Normal 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,
|
||||||
|
);
|
||||||
23
lib/providers/theme_provider.dart
Normal file
23
lib/providers/theme_provider.dart
Normal 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,
|
||||||
|
);
|
||||||
122
lib/screens/admin/admin_hub_page.dart
Normal file
122
lib/screens/admin/admin_hub_page.dart
Normal 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;
|
||||||
|
}
|
||||||
358
lib/screens/admin/app/app_version_page.dart
Normal file
358
lib/screens/admin/app/app_version_page.dart
Normal 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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
346
lib/screens/admin/app/tv_app_page.dart
Normal file
346
lib/screens/admin/app/tv_app_page.dart
Normal 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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
894
lib/screens/admin/channel/channel_management_page.dart
Normal file
894
lib/screens/admin/channel/channel_management_page.dart
Normal 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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
435
lib/screens/admin/channel/channel_version_manage_dialog.dart
Normal file
435
lib/screens/admin/channel/channel_version_manage_dialog.dart
Normal 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
1358
lib/screens/admin/intro/intro_category_page.dart
Normal file
1358
lib/screens/admin/intro/intro_category_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
1144
lib/screens/admin/intro/intro_store_content_page.dart
Normal file
1144
lib/screens/admin/intro/intro_store_content_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
258
lib/screens/admin/intro/intro_store_list_page.dart
Normal file
258
lib/screens/admin/intro/intro_store_list_page.dart
Normal 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))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
1489
lib/screens/admin/marquee/marquee_management_page.dart
Normal file
1489
lib/screens/admin/marquee/marquee_management_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
391
lib/screens/admin/media/media_list_page.dart
Normal file
391
lib/screens/admin/media/media_list_page.dart
Normal 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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
368
lib/screens/admin/media/media_rank_page.dart
Normal file
368
lib/screens/admin/media/media_rank_page.dart
Normal 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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
404
lib/screens/admin/sys/config_page.dart
Normal file
404
lib/screens/admin/sys/config_page.dart
Normal 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('保存'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
261
lib/screens/admin/sys/log_page.dart
Normal file
261
lib/screens/admin/sys/log_page.dart
Normal 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
511
lib/screens/admin/sys/menu_page.dart
Normal file
511
lib/screens/admin/sys/menu_page.dart
Normal 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('保存'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
340
lib/screens/admin/sys/role_page.dart
Normal file
340
lib/screens/admin/sys/role_page.dart
Normal 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('保存'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
624
lib/screens/admin/sys/schedule_page.dart
Normal file
624
lib/screens/admin/sys/schedule_page.dart
Normal 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
Reference in New Issue
Block a user