From 685fff27c8e117c87830dbfc4204e22f1525c25c Mon Sep 17 00:00:00 2001 From: txh Date: Thu, 3 Sep 2026 18:01:05 +0800 Subject: [PATCH] =?UTF-8?q?ops-console=20v1.0.2=20(versionCode=208)=20?= =?UTF-8?q?=E6=BA=90=E7=A0=81=E5=85=A5=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .metadata | 30 + CHANGELOG.md | 845 + README.md | 216 + analysis_options.yaml | 18 + android/.gitignore | 13 + android/app/build.gradle | 55 + android/app/src/debug/AndroidManifest.xml | 7 + android/app/src/main/AndroidManifest.xml | 51 + .../com/tongshi/cloudhome/MainActivity.kt | 5 + .../res/drawable-v21/launch_background.xml | 12 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 4509 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2658 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 6393 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 10462 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 8505 bytes .../app/src/main/res/values-night/styles.xml | 22 + android/app/src/main/res/values/strings.xml | 5 + android/app/src/main/res/values/styles.xml | 22 + android/app/src/profile/AndroidManifest.xml | 7 + android/build.gradle | 20 + .../gradle/wrapper/gradle-wrapper.properties | 5 + android/gradlew | 160 + android/gradlew.bat | 90 + android/settings.gradle | 29 + assets/data/MCityPiker/province.json | 14793 ++++++++++++++++ assets/images/logo.png | Bin 0 -> 8505 bytes docs/class-diagram.mermaid | 87 + docs/intro_v38_design.md | 270 + docs/sequence-diagram.mermaid | 70 + docs/system_design.md | 292 + docs/手机端UI优化方案-v2.md | 1486 ++ docs/手机端UI优化方案.md | 1475 ++ ios/Flutter/Debug.xcconfig | 1 + ios/Flutter/Generated.xcconfig | 14 + ios/Flutter/Release.xcconfig | 1 + ios/Flutter/flutter_export_environment.sh | 13 + ios/Podfile | 41 + ios/Runner/AppDelegate.swift | 13 + .../AppIcon.appiconset/Contents.json | 13 + .../LaunchImage.imageset/Contents.json | 20 + ios/Runner/Base.lproj/LaunchScreen.storyboard | 39 + ios/Runner/Base.lproj/Main.storyboard | 28 + ios/Runner/GeneratedPluginRegistrant.h | 19 + ios/Runner/GeneratedPluginRegistrant.m | 63 + ios/Runner/Info.plist | 67 + lib/app/router.dart | 355 + lib/app/theme.dart | 423 + lib/config/app_config.dart | 55 + lib/config/environment.dart | 17 + lib/config/prod_config.dart | 17 + lib/config/prod_guard.dart | 48 + lib/config/staging_config.dart | 16 + lib/config/test_config.dart | 16 + lib/data/region_data.dart | 145 + lib/main.dart | 315 + lib/models/alert.dart | 95 + lib/models/app_version.dart | 95 + lib/models/device.dart | 622 + lib/models/intro_category.dart | 281 + lib/models/marquee_config.dart | 132 + lib/models/marquee_message.dart | 211 + lib/models/media_asset.dart | 128 + lib/models/org_node.dart | 273 + lib/models/schedule.dart | 75 + lib/models/store_config.dart | 59 + lib/models/store_info.dart | 219 + lib/models/store_theme.dart | 74 + lib/models/sys_config.dart | 41 + lib/models/sys_log.dart | 74 + lib/models/sys_menu.dart | 72 + lib/models/sys_role.dart | 84 + lib/models/sys_user.dart | 218 + lib/models/theme_group.dart | 94 + lib/models/theme_template.dart | 55 + lib/models/tv_app.dart | 80 + lib/models/user.dart | 76 + lib/providers/auth_provider.dart | 158 + lib/providers/dashboard_provider.dart | 106 + lib/providers/devices_provider.dart | 33 + lib/providers/display_provider.dart | 97 + lib/providers/locale_provider.dart | 85 + .../metrics_visibility_provider.dart | 51 + lib/providers/theme_provider.dart | 23 + lib/screens/admin/admin_hub_page.dart | 122 + lib/screens/admin/app/app_version_page.dart | 358 + lib/screens/admin/app/tv_app_page.dart | 346 + .../channel/channel_management_page.dart | 894 + .../channel_version_manage_dialog.dart | 435 + .../admin/intro/intro_category_page.dart | 1358 ++ .../admin/intro/intro_store_content_page.dart | 1144 ++ .../admin/intro/intro_store_list_page.dart | 258 + .../marquee/marquee_management_page.dart | 1489 ++ lib/screens/admin/media/media_list_page.dart | 391 + lib/screens/admin/media/media_rank_page.dart | 368 + lib/screens/admin/sys/config_page.dart | 404 + lib/screens/admin/sys/log_page.dart | 261 + lib/screens/admin/sys/menu_page.dart | 511 + lib/screens/admin/sys/role_page.dart | 340 + lib/screens/admin/sys/schedule_page.dart | 624 + lib/screens/admin/sys/user_page.dart | 1191 ++ lib/screens/alerts/alerts_page.dart | 140 + .../device/device_activation_page.dart | 567 + lib/screens/device/device_group_page.dart | 488 + lib/screens/device/device_scan_page.dart | 1023 ++ lib/screens/device/store_config_page.dart | 554 + lib/screens/home/device_detail_page.dart | 618 + lib/screens/home/device_list_page.dart | 492 + lib/screens/home/home_page.dart | 261 + lib/screens/login/login_page.dart | 509 + lib/screens/profile/settings_page.dart | 430 + lib/screens/store/org_tree_page.dart | 554 + lib/screens/store/store_detail_page.dart | 16 + lib/screens/store/store_list_page.dart | 1771 ++ lib/services/api_client.dart | 667 + lib/services/app_service.dart | 212 + lib/services/auth_service.dart | 264 + lib/services/cos/cos_client.dart | 355 + lib/services/device_service.dart | 612 + lib/services/intro_service.dart | 259 + lib/services/marquee_service.dart | 278 + lib/services/marquee_web_source.dart | 343 + lib/services/media_service.dart | 146 + lib/services/org_service.dart | 747 + lib/services/sys_service.dart | 444 + lib/services/theme_service.dart | 72 + lib/services/update_service.dart | 127 + lib/services/upload_service.dart | 383 + lib/utils/app_update.dart | 161 + lib/utils/constants.dart | 30 + lib/utils/credential_storage.dart | 134 + lib/utils/formatters.dart | 59 + lib/utils/i10n.dart | 666 + lib/utils/permissions.dart | 69 + lib/utils/responsive.dart | 55 + lib/utils/session_org_scope.dart | 37 + lib/utils/store_local_cache.dart | 139 + lib/utils/uuid.dart | 17 + lib/widgets/app_bottom_nav.dart | 107 + lib/widgets/app_bottom_sheet.dart | 75 + lib/widgets/branding_editor.dart | 173 + lib/widgets/cloud_card.dart | 48 + lib/widgets/device_command_sheet.dart | 441 + lib/widgets/device_list_tile.dart | 90 + lib/widgets/metric_card.dart | 75 + lib/widgets/org_picker_sheet.dart | 337 + lib/widgets/org_tree_filter.dart | 334 + lib/widgets/region_picker.dart | 208 + lib/widgets/status_chip.dart | 57 + lib/widgets/store_theme_grid.dart | 146 + lib/widgets/store_theme_group_list.dart | 177 + lib/widgets/store_theme_preview.dart | 125 + ohos/AppScope/app.json5 | 10 + .../resources/base/element/string.json | 8 + .../resources/base/media/app_icon.png | Bin 0 -> 70 bytes ohos/build-profile.json5 | 36 + ohos/entry/build-profile.json5 | 25 + ohos/entry/oh-package.json5 | 11 + .../main/ets/entryability/EntryAbility.ets | 21 + ohos/entry/src/main/ets/pages/Index.ets | 21 + ohos/entry/src/main/module.json5 | 44 + .../main/resources/base/element/color.json | 8 + .../main/resources/base/element/string.json | 16 + .../main/resources/base/media/app_icon.png | Bin 0 -> 70 bytes .../resources/base/profile/main_pages.json | 5 + ohos/hvigor/hvigor-config.json5 | 5 + ohos/oh-package.json5 | 12 + pubspec.lock | 727 + pubspec.yaml | 50 + test/widget_test.dart | 17 + 170 files changed, 53049 insertions(+) create mode 100644 .metadata create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 analysis_options.yaml create mode 100644 android/.gitignore create mode 100644 android/app/build.gradle create mode 100644 android/app/src/debug/AndroidManifest.xml create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/kotlin/com/tongshi/cloudhome/MainActivity.kt create mode 100644 android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 android/app/src/main/res/drawable/launch_background.xml create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/values-night/styles.xml create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/styles.xml create mode 100644 android/app/src/profile/AndroidManifest.xml create mode 100644 android/build.gradle create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100644 android/gradlew create mode 100644 android/gradlew.bat create mode 100644 android/settings.gradle create mode 100644 assets/data/MCityPiker/province.json create mode 100644 assets/images/logo.png create mode 100644 docs/class-diagram.mermaid create mode 100644 docs/intro_v38_design.md create mode 100644 docs/sequence-diagram.mermaid create mode 100644 docs/system_design.md create mode 100644 docs/手机端UI优化方案-v2.md create mode 100644 docs/手机端UI优化方案.md create mode 100644 ios/Flutter/Debug.xcconfig create mode 100644 ios/Flutter/Generated.xcconfig create mode 100644 ios/Flutter/Release.xcconfig create mode 100644 ios/Flutter/flutter_export_environment.sh create mode 100644 ios/Podfile create mode 100644 ios/Runner/AppDelegate.swift create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json create mode 100644 ios/Runner/Base.lproj/LaunchScreen.storyboard create mode 100644 ios/Runner/Base.lproj/Main.storyboard create mode 100644 ios/Runner/GeneratedPluginRegistrant.h create mode 100644 ios/Runner/GeneratedPluginRegistrant.m create mode 100644 ios/Runner/Info.plist create mode 100644 lib/app/router.dart create mode 100644 lib/app/theme.dart create mode 100644 lib/config/app_config.dart create mode 100644 lib/config/environment.dart create mode 100644 lib/config/prod_config.dart create mode 100644 lib/config/prod_guard.dart create mode 100644 lib/config/staging_config.dart create mode 100644 lib/config/test_config.dart create mode 100644 lib/data/region_data.dart create mode 100644 lib/main.dart create mode 100644 lib/models/alert.dart create mode 100644 lib/models/app_version.dart create mode 100644 lib/models/device.dart create mode 100644 lib/models/intro_category.dart create mode 100644 lib/models/marquee_config.dart create mode 100644 lib/models/marquee_message.dart create mode 100644 lib/models/media_asset.dart create mode 100644 lib/models/org_node.dart create mode 100644 lib/models/schedule.dart create mode 100644 lib/models/store_config.dart create mode 100644 lib/models/store_info.dart create mode 100644 lib/models/store_theme.dart create mode 100644 lib/models/sys_config.dart create mode 100644 lib/models/sys_log.dart create mode 100644 lib/models/sys_menu.dart create mode 100644 lib/models/sys_role.dart create mode 100644 lib/models/sys_user.dart create mode 100644 lib/models/theme_group.dart create mode 100644 lib/models/theme_template.dart create mode 100644 lib/models/tv_app.dart create mode 100644 lib/models/user.dart create mode 100644 lib/providers/auth_provider.dart create mode 100644 lib/providers/dashboard_provider.dart create mode 100644 lib/providers/devices_provider.dart create mode 100644 lib/providers/display_provider.dart create mode 100644 lib/providers/locale_provider.dart create mode 100644 lib/providers/metrics_visibility_provider.dart create mode 100644 lib/providers/theme_provider.dart create mode 100644 lib/screens/admin/admin_hub_page.dart create mode 100644 lib/screens/admin/app/app_version_page.dart create mode 100644 lib/screens/admin/app/tv_app_page.dart create mode 100644 lib/screens/admin/channel/channel_management_page.dart create mode 100644 lib/screens/admin/channel/channel_version_manage_dialog.dart create mode 100644 lib/screens/admin/intro/intro_category_page.dart create mode 100644 lib/screens/admin/intro/intro_store_content_page.dart create mode 100644 lib/screens/admin/intro/intro_store_list_page.dart create mode 100644 lib/screens/admin/marquee/marquee_management_page.dart create mode 100644 lib/screens/admin/media/media_list_page.dart create mode 100644 lib/screens/admin/media/media_rank_page.dart create mode 100644 lib/screens/admin/sys/config_page.dart create mode 100644 lib/screens/admin/sys/log_page.dart create mode 100644 lib/screens/admin/sys/menu_page.dart create mode 100644 lib/screens/admin/sys/role_page.dart create mode 100644 lib/screens/admin/sys/schedule_page.dart create mode 100644 lib/screens/admin/sys/user_page.dart create mode 100644 lib/screens/alerts/alerts_page.dart create mode 100644 lib/screens/device/device_activation_page.dart create mode 100644 lib/screens/device/device_group_page.dart create mode 100644 lib/screens/device/device_scan_page.dart create mode 100644 lib/screens/device/store_config_page.dart create mode 100644 lib/screens/home/device_detail_page.dart create mode 100644 lib/screens/home/device_list_page.dart create mode 100644 lib/screens/home/home_page.dart create mode 100644 lib/screens/login/login_page.dart create mode 100644 lib/screens/profile/settings_page.dart create mode 100644 lib/screens/store/org_tree_page.dart create mode 100644 lib/screens/store/store_detail_page.dart create mode 100644 lib/screens/store/store_list_page.dart create mode 100644 lib/services/api_client.dart create mode 100644 lib/services/app_service.dart create mode 100644 lib/services/auth_service.dart create mode 100644 lib/services/cos/cos_client.dart create mode 100644 lib/services/device_service.dart create mode 100644 lib/services/intro_service.dart create mode 100644 lib/services/marquee_service.dart create mode 100644 lib/services/marquee_web_source.dart create mode 100644 lib/services/media_service.dart create mode 100644 lib/services/org_service.dart create mode 100644 lib/services/sys_service.dart create mode 100644 lib/services/theme_service.dart create mode 100644 lib/services/update_service.dart create mode 100644 lib/services/upload_service.dart create mode 100644 lib/utils/app_update.dart create mode 100644 lib/utils/constants.dart create mode 100644 lib/utils/credential_storage.dart create mode 100644 lib/utils/formatters.dart create mode 100644 lib/utils/i10n.dart create mode 100644 lib/utils/permissions.dart create mode 100644 lib/utils/responsive.dart create mode 100644 lib/utils/session_org_scope.dart create mode 100644 lib/utils/store_local_cache.dart create mode 100644 lib/utils/uuid.dart create mode 100644 lib/widgets/app_bottom_nav.dart create mode 100644 lib/widgets/app_bottom_sheet.dart create mode 100644 lib/widgets/branding_editor.dart create mode 100644 lib/widgets/cloud_card.dart create mode 100644 lib/widgets/device_command_sheet.dart create mode 100644 lib/widgets/device_list_tile.dart create mode 100644 lib/widgets/metric_card.dart create mode 100644 lib/widgets/org_picker_sheet.dart create mode 100644 lib/widgets/org_tree_filter.dart create mode 100644 lib/widgets/region_picker.dart create mode 100644 lib/widgets/status_chip.dart create mode 100644 lib/widgets/store_theme_grid.dart create mode 100644 lib/widgets/store_theme_group_list.dart create mode 100644 lib/widgets/store_theme_preview.dart create mode 100644 ohos/AppScope/app.json5 create mode 100644 ohos/AppScope/resources/base/element/string.json create mode 100644 ohos/AppScope/resources/base/media/app_icon.png create mode 100644 ohos/build-profile.json5 create mode 100644 ohos/entry/build-profile.json5 create mode 100644 ohos/entry/oh-package.json5 create mode 100644 ohos/entry/src/main/ets/entryability/EntryAbility.ets create mode 100644 ohos/entry/src/main/ets/pages/Index.ets create mode 100644 ohos/entry/src/main/module.json5 create mode 100644 ohos/entry/src/main/resources/base/element/color.json create mode 100644 ohos/entry/src/main/resources/base/element/string.json create mode 100644 ohos/entry/src/main/resources/base/media/app_icon.png create mode 100644 ohos/entry/src/main/resources/base/profile/main_pages.json create mode 100644 ohos/hvigor/hvigor-config.json5 create mode 100644 ohos/oh-package.json5 create mode 100644 pubspec.lock create mode 100644 pubspec.yaml create mode 100644 test/widget_test.dart diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..9c3a509 --- /dev/null +++ b/.metadata @@ -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' diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7ddf53b --- /dev/null +++ b/CHANGELOG.md @@ -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 | + diff --git a/README.md b/README.md new file mode 100644 index 0000000..f74c51e --- /dev/null +++ b/README.md @@ -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 `,不使用 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` 后自动生成。 diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0f797a0 --- /dev/null +++ b/analysis_options.yaml @@ -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 diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..55afd91 --- /dev/null +++ b/android/.gitignore @@ -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 diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..cf7af12 --- /dev/null +++ b/android/app/build.gradle @@ -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 = "../.." diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..85950f0 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/tongshi/cloudhome/MainActivity.kt b/android/app/src/main/kotlin/com/tongshi/cloudhome/MainActivity.kt new file mode 100644 index 0000000..59d76ef --- /dev/null +++ b/android/app/src/main/kotlin/com/tongshi/cloudhome/MainActivity.kt @@ -0,0 +1,5 @@ +package com.tongshi.cloudhome + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..fdd9063f0ac31f526b55e09069edd60016129f3f GIT binary patch literal 4509 zcmV;O5n}F%P)B5X*vwUNSmhV1ik>=0bBr70tTOa3Ilt9r-6qI!x$7S48uqnIp8b;ehRGP z%%*+7mkh&rO@U;Zrf|qSV6$~hSzKeBA>3nAT7(G@DM|p>8HTY*0Fo0g09RWV+??5z z1YAG|aIpaNSzx2ZIRe&MB7?>K4oe{mYn`PmIV?kNnx^(y6#Ou{fMA42NsNbw%>rCl(+x z3ye^7xRbHLL&QSe#K(q6C!^{f0mtPn!&6#|uc8@$)jZsRO1ly3cxr$WGv5U?Sv?sa zIyk0DsCyT|j_pK-j*yCvVK^K(-F_TSkGjW9r${BDq?0jq&F!lsP`8-M*{kqZ&&zM` z69JH^CleX&WT15u;r;`dr16zCQBu1IU)5YZfhwHd5)2%qQwfsMAkmRtB7^OW4IU;D z2`UiF8dp%;vJQ8k3Pe8{j*mG&rUob2v5mfcuaJy}@K?^Ja{e018kgg6d8ce59_}a9 zxl^@~7#qY>T2KA*>nW{YGUFhfVt~wXR!9$iu>CFiTVGYDs^+bsdhwMw-M-8{`YF*( zInAhpiJ`(Vjgx++G@1+(dcvxibpV%mQRueoLXj*_?B98^?A(hv9Hi zxAYn+=Url5Kb}dMR(lS0?A!k`LxMY3?Nf?Dy-bQ??pzscZ*EnEpMi@z6F2PTxE8ZA0xSg zM3h8ih-k2zK+OVN-qM-136{8tPgGL3m{8{qHKv-yS138*f>ibxV$7%rnzwj`=!#r4$m0WviJBV9Wg?b(aVS3$$d8}q^WsL7gW zplTk`p`!{Mbvm6Qkq8nS>0v~iN|#rOd|A^3H`M93DUO*8VXrf4TM4$E!M&PR1GO5!YlZ(O{SIz^fNrYPlczG_9pK ztz%@kgH$rASlH#S&?iqX1xXfv&Jk2tHdSKmq1{I!ZJ2HIZd*q#?A7@BDT zWLS(95BDkbMbYVYPUB)o#yVLaI}EbbftgkTvSa~jmtL)uxH!9mZExmOckE~YpB!ITKV$x3`Qb1|0s+zxsnwIq{EE;HkU1c?Gkd8Z= zH)qtvJC4@UQp+1h#oUVH9ff6RdftDQV8?s-s~0e5^%oRi6RlV@1o3Fmj*anAvV52P zu$!N1w~US=McyVh58#jfed45tI^e0V!ma-V#_WmAWCI+Jz}6=q71!gEV{{Z2u7?_!w+^2BME@P}50$|8@6* zr^IS61;JzbxwdDZZUK~3X^^BGwBavc*#;Qyh9|!Qw|pC_nql8&c;~TcIkDOhnJQi! zI=F>Mu#;qLbexFE09!88pW~&pa7;Fd#ZV_a@nu+YCDbp1?a#uxFG4B_ksuu220LGX z8~+BjJPPNmhla%(aPe%NIYF;P|2KSHfB%m^gG=v#MW52Y-+nwNC!4YqbL#jFfF)+) zVY^Feh>!NE+;U)GEH~y{GiNdjEwU+2lZ{v#8;5>n9V!IaML%nG8NHKeL5y4twg*_EEnL%x9Pseleh@d zhnRI)MB9)EATK=tZ$4~^SXk>m;W**e^7`qEn6+4`SXlW*66I%gkg%4#z!E^rzt8RuX?jGk88G3bOrN!K4sv9}kxzbwwX?-1LEr-wlbMeH~q|R070xVgn4-dAS7>%(rsAsbAi->oCoSMOVBj$P z_t!yoP2BJe%X;J%_6%@3;G$bJNbfxj7yoY7bI$G1)~b_B@(hIPk&Rlzw`=dS_Ta*`da^??0unL%`HdY*CJ(4UKT)dSQ7xs z*7*BB*vx3}ex;Zt1)EvBjH8%LK<@!PSTXCu4c;=XgiES*{8L&7<#n3F{FP8r3ElhP zsjum<;iAvwlv3<5q_t18@|wcg4V&v^0S<00yy6mw-u^>9XDDUHBN;+>poUZ|gtxR2SD-=_JH>?i zRAt7P!P0Xfnu_E4wnFENP_aY!@(wCL*t+hMF5N@hV|w)I~}Vqq%uvm*yJ zmr0nSteX{b1e(EJ3h4w4@6CF~sVIcnp>k;!{MZnTv}pjPZikzQEyT?lu;p{I3M?|%m>m%-9Mfqg%MC3i#3D&5}UXQ1_GaNbv; zbe<)GYW4E}m0_B60stBM;ID_Z59D7O+yPs^X05znnbfOp>lIf39Byd14Ca4MpWFF8 zNQ{EvvVu@QthpatrO^5?v^}Qt)6sqiwL+i?M%r~uCZC2Yplg!`NR+T}sQ}aPX=}KX z@Bf*3fJ{qnMH~)o)LLIILh&k1l_3@~nuD0A=eC`JN#w-69hf4jVb30gi`Y z_O*KAtv`qJ|5|gU^wYFXbDoG>xD!HcFlz&NW#|3L?}I#X5sUUi$MfKCfH^m5k&pqJ zJEmT-+){J~Ok0?6c&87V*J}{H)lhw&)-|H^rC*zqMP&Ql)j$eRHuuax9r(nR zw9^CbG6>9qMYrj5p#vJU+)SXdGC3Z1=66odk{^f^)3!g({~#h*kNUz`2fi>-boz4}>j2DC_iBK}|OV?rn4Nt?yvtBh$?+hTKq-toV!b}}x1 zvAsMwIxEv>PRS|LPUZnl1AvTaD8by+NTJ`>xy;vT=!{IC6SDuD3OY@;r=Gra7N9BU zEI`u$$lAYlmWlzi%Q69zWWUZhva@bS1fWOG`acuMO0x5xXJ4>X`~^!A9$e(G(E3wJ zHCb<;k~H6k`v#HIgVNUA1sn+;?=%eKmh(Vfl`XKg*2sY(lkl)&dp77Z|-Y5YwvR(c{IK0W2Rl1lXJ4q zK6|hKTK~27+Ixah$_b#9xDM_W&0j1OrZJjObhhL~mPo7XpIV`2r zZNNW(tVRP4E^f(+%>iM~j|IS6!1eYEO{YshlzsIJfFYpTZm=%MTr#rxWu~ys-!v}3 z&4mz^wG|bGrD2ZJ$_8Ye6Bs1`&a>E#v`i>vl2at7M)V!aiQ5;#9gN^`=Io~I`&j|8 z&MyG5s%_E9feyyH-zO36)84QgF3EPFGFVOzfyfHNEmz~OYMuA(k`O4R#(|1+jsaVN zRD7IM2X-<&dYoX*YC?_c@r4?2dV;!7DiI|<+Ra4&|45AYQr@tRx^=hZ-!B!!SpZPR zlKIXSiY%7p)Yd-v!PB_>A?nxfz*pI{=tz@;oeUg&S<|h#{a!r5$dW-U4S){Jg_jAj zeAhd_$6wh@(^Yq4Ih(xvOO5MJ(x~vf2Q(Yrm{xr>gBbJ+{gk zaA)#{s}$u$;lBOPYs*)yyq?PDt+)e~vwBJ&hs$GYwuEZLlD>*i!#ai!ze=qCAdyu! z&UU~eYsTULG+(n?`S@5jE!)43zc?_91~$r{Wg=6uM7Rl5w=sU=LtOp}94;>|Z#mww z8XOWAxsGPS5&+md96P>;$-xdCV4}RrX0RNN1xkXm&%(+M^gyJISpOl0J6_do*-T0= zc>b#8gqLq4P_=yCT#E*=(Bjyek5FEJ1$FDcnh&}QB`TXCotVg5JROfRJ=#U|>!MfogkB%rn2QK{ee!07f>vR6L%n-|J&*I;|qk$1tk;a0 z*Z|A6eha6^uXUPj&TK|s%DyLK12|niJb?&gb7lAfVC9`5?4zh7+x(pFHn+1g^Ll{1qT&^xB&t z(rTXHeHw;715W^!UkzP*Vf($%@urz;%}wz3A7R~>OrIh#F{r3)Y1e^0J#x$jAnpy- z!Hx$@btp`a!)uSi7w(78_CUDFfZY2$T>o7-`kwKafcDFZEnBAm-0}dt{y1E=9a^^- zqlq{4{y;${1>WGoDcPo>r>q*6uR_zwrRg)?3*Gxc(vx^VEGbbwGi9#_2EV3Tuo6~& z&QPs>4frd;j7(D?KgHTp&tT)^RYi@+1%rY+~GAS524pXBBfP9lqE)p|R2s&_i z%X9&0JTTq|hjtm{GI)FfdOn5~?cnkj0yLe1>Q)0FP+|C+j6>~Zke-3+HmF$%1Ban$ zodNUWFX7vN0H;T9W_aTX==u-_I$^XIyaDq~bkG2hu_T7(X0{?C85|~7O%Mwg<;ux( z55Tqe87iDk1k}}cz~rd8#4|FM02O5?2H?8yK+TFmZ6x>hZyKsKUug`{^?@;#?A!!^ zJd5{*1*h{tCPgYfp;u^!rjX{IBu(IjpMtO4C@-!Q7Yk1Zjui5?h)i(#4bXGIc){&A znMCfw=gzkw9);C6n1Ih&PcoCxtFqC|bKQN=em692FjxCs@cHi*QK*PY+UkmUw2$;m zOqU)uOtG2^UiqB~s5>4q&AIAQvdvph!aa|fez^rr2GrIvWwD7D+Y3TJU#RGGqNth_ z@Dg`U7wmhs6DCXLtSG7a{b%9Ce&f|mx5I5e zH}CfU-Ke;E#~jLAvlcQb>0^BdXxw6UeJ-uMO2fVv!R>{=J!C8*RPdFVG$?!3TYm<7 z{$$eMvW<3NYeGW9dZ=49`z%Q4BSz5+rA;C6R7D-!@)M|OgMEL6oxd{Fi%Ag6S=*;3 z`>nhNHr#3oNQYBjvT^F&_be67TM0F8(B8322~|OVr^4m7bj}vn=jc-`qdnj)gJ8Iz z?5;;)dJM{I?KYc|Vr}uvj)zX45~s%G;K;mp*yluIJfV%mhc4KR^`kxe82IF$1Zyv+ zY15r%&uv*}*b~n!%JRWjBzh4#&5>#xdx`}_W=tnyBqs-TIUVaeNGd+2 zx0^MW-)tLXZaF=x7^85ymOo9%8tdc((jf&qWD?*E7lE}0x12`#TwLf09pI2>JFTkR z8Er1No4$P?{e{V)PJI~W@&pLhtfI1Y8(#79QZ_ValoKyQ?`yE_w~OZOc{20?RBZsK zp~zgBi4E#i))NTh4pbSs*dhUs4DeHHp%hwyqh`7apS3*9fl*g-h-7|e{o z@Q08bgIG6oJ_QbYyC{J2hX$wLPn_XY!gg6=1bTNF>q(iQFR$@T?v&Z-1AhZFei2er zb}F{3&s%;X!?}4K4mS17%D+Zr_jk)VC%0TvdLIDn<{|J z2la;aF?6-soh?fuS6*=3yHOb zujz!DTVcq}^ipXI!wxLBa9pZ(oJf0ryu zJYc7jOHwQW@QLlq(``a8Mp3rDy#nCj!YXq~fXLdH=>D5a{+~U#)c@V)|6Te;?-Q~4 Q5&!@I literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..7f6325badb5cecba6f70df5c52eb99c62bb8b6ea GIT binary patch literal 6393 zcmVv#>8Q4Wu!lfH8QtjENU4B-z^6ku;j^_5AL4-^`oQj5He0NOI8U^NwcTy!YL!E8)*>M+D^{H%z)+KEa8W0{bim2vj8N(yxyNhpb9$o!8E~t1S3w6<*Yhq%uOLb zcL`w9XCPwbh)28yNzU5un*(s}5x|T9hC>dzZXrL$eLV>HdJystA{-cl840Q19iDu+ zJwM1xjfPk+X{=s&2 zZ+P6N%{7I|iK3bns9bs#JVjNrLWDU?05i(nJ;z@{|EXP~|A}~a9*Sx&KuP^tbQhxP{P&}D;YFBfn4<(R)l?#(adaGh7QT*V7%mSG ziJ+){@-1RSf`0U#co_q&Z@@5|N@bNc zZ9wgXH_Vtings%AuVXS}3lnD!JPu#S5jdP~IK9PKuNBOTV(C?=TE2Bgh%hr%FD}GFoGp05goFOQfu7(07QT+R#H;ZXqW+SPYvdDYSqh>Q z@;R>vN*1m~?V6j_X@|?JRMT+B5$#Q-E)1LaX$anth^5S73bv$`8MFQ&4`K% z#`;>|>ug47d`L+`&4t&;)^kn}z{V}3eW#TBIM)9zBEfN8hS2KBuolj*<6?Xq4&~cN zf}<#?T8h%9OZBxgp($m~mCLtctnU=UAwPyYno+iB6FdbKIg^6y5y^3%oq}EWmY)j6Db!8j*V*~BzJp42QBR$H$CK4H`mej97LFE#-@`@*YOgJ#A zINs^blM^$-{T2wJOBSxfP}^RWUt-`^vE)h>HP2lFXyh|paQeVwFhc=_c(=C%70b4u zq<(eE{JjMdL>X{;SgXeDge6MFnD7=>A+M}KCH#UTJs2PEjQ7HnCvB@goP}y~WRvsH z9C!@S29`;#h7F&JB3~I@n{ig&j}bd!i!f(5Ce-$+pkk3iGF9k6>mE_1N*Z;ex$TuzTCgGqftvx$;eksZq zZARm!J5af7tB!x12>XZ7zW3)CY(J=xAITB{G$qG}I;0Tdbfd76Li7g+hUMoc9NHC) zwt1SYtmUI}>6K_$e>=P-3lNEfVK^M(Sd|MWqp(g|h+-jX^jyz~>Q-pu-f!{J6(^K%&OYnhPZJ_~G_h(tmvhvO-%gwt(B zuaE|wl{vN8pl|aT6pR0D5z+X z4iC%)Fe89}Smlu{4bnG-S5VL&Mo>Rcf|CwPVD}%!A1+MuEKm{U6n9V}683BHIH!wN~eDWc-ttGUU z=;BSwe&F%10q?sDSbtlZb1{LC)fH!|#mD+eO%{NGXC46B4r+H(yNOmtRuNmP6yl;` z4RGrN)5D8SOO!TViox~+2#pP>svDcd*i@Df32RJ^4ghce?korp0z8Gl)<27XZro52 zmDs}GCx8>L10Vd7AVk*#G%X__r z!o}f${JrrWA&R#Mxa8KDBprB8p0VL0u{G^~8W2L=I4UL( zz*PDzS&ni4ve6o?ikizXG`d62}Sjwg6Aw3$RQ1$~%Cg zJ6Vwj*4`jQV7Zc+NX8dC9t6r309V}w?0Fn0tp(2C!d^om6m3U&gKZgl9qxQ!(bQyYKdOqHw8r32n80l6i3nud>+5l@HOCO#MM1!(e z`-eK$b_jU%Zb8t^_X0J`fc9qL(2XAlmR%us=(UG|vU;HV9pL)E1XgSTnqL6k*$r&| zG*Ho)aIJ>!e$nd(p98l3Y3$LNW?(rNUoUXr8P$8LfHs0ahEUf~%?$x1)_%qeKoc&S9W}!U z1$-m`2j#>gz^Ib$k^w!js`zX>$|TGRtw$V45;?=)&wrHhl9D)Qwb zDf+?NgaA?THGw^kim{`Cr8?kmsv8c#yZhxkjdRsv;K+-yd#!o&ydy}Ws#LEdfJa)1}^_JP+BKCHdRDPgi)M{Wb3;gSS_e-C2;2tGh9hTRK|}# z3pl;0E)oKqZU&gEB4X*yknmjNl8xe7QNJr2pcg&dDW8>u>3R3J0)=N|19W?)avZ2Y zxOK{Fw*d}d`TJ7e-+4@oA>FnsJ|o65!$~M0cjcD|Cm(-Jh-~TL$!=#^u&^9hyG_&< zRg;}l`u z<|5J?80mw-t=WcC*@FZxNOqt^S4*UjYdVhuPuwfs!lq9M@>R!Mk^n1NQqJ=0?Qp}_ zM4cUd33%Zlpmw>AnkVUmqm*`@0E(-DRUeeOec~U5D5*eo-|3iu^Chtq!920KE#3fZ z=S#vg{NgJz4@Eo;Pu>rNMlekpL{H^dlD1DS9%~audM?wXIF7db& zM(oN!dRC*6HQ|=~GLHkS-l_gJgo9%$HJw*lrwUogi}DtN$47u2-vv$|iMfr@b`x7e z5TvDqQ6%9w@&1?7v>xrqd@)CZ00um4H%H1(^1>%Ssi$%F}L zHRSgJCwGb0({wSg`a?D%OokZ9H66bPdRsN8PNIT(T7cpzabMHgtf6=0MD@EKm)uQ< zf&rmwz_M|-)nxU(TjJ*%2?DqNjTrBA389(QKh&YNTd_vK;#EP_GWBzkWSr*ym%a}i zc|r0d*WWFASMNZNa*kcMS2h9Hei``HcYv3FC;?RY!l@@}7^1eW`zv7Ks;L+B?mi*1 z)x0~ETCm0@^H?r0C7baDlW>=RCZ6EDhU+HcXlLy9jB6>cw=SG|uF?4!7&x^HW=PsL ziyJP0OEI&VL;^VB*Z&)M^OxdwZTlKfvm~A~64S*hUp@*{F9sG|2(%plcK#c%{XScl zSSnRyidx(Ml%yD@0#2Osc=abh^-{_0q;d^;o{~Z@htH-#-u(;Ul^+BBrzOx*1V{iC zheUwQpGrOFbd0fPzR}*3818&aalgY=fU@)WDpmp4gbd~3=VgNQNIvjIQ9pwnQZQk5 zaz@T??T)Zsm-^s3Jz{q!=wjKm7 zyhaF+3PB9gp86&*a7N;hf^vXcj+hZ`x(GP`%GC3jY!Yc9Uo;78z@0~Ss4i;OhO3rs zft&NNQWMb|hg(dD&WCDjfpc#4Sn-dZvd}9 z0yL}!>Q8GkfJN((Hhlkf8D}zJ?xv_xQXh>MiI-KnK#X|zN#M0dbkv(r zRU^akJA<=iuw_?dYYb`I0ER23})c=3C{D?bAEJOOO`>g0$r3>lNe#bh=^t1?yE3#Cql zSV-Z#KlTst&$ITXF{*#*KV&_67f+m;BQP;IVG1VDxK0Q#LA9E3oX?JNZqMR1?t)6x ztllOYJ#5Wu6SJY0+yaEd!adv@RahqKNacaj@b4|0Y}Ool(M^+!ZA5!Y&s#xH8oo9W zN|o1Wq}e8-)9caL#Sq=EY6bRAW@`%CV@kycR^)B3+*xZ>{(dXE-g+JZAKzB&P(fGS zx(~z6*X1Ng&BQ?LBH1sS5~8(S`%Xy9owOmE(z4}qQzOXb7Rh0zf#C+p80dfV;39b!%^xS1Tk@Srgk*taOAu zaTfhEoiN-Y(iFaWBrNvD68m()DqUKshKyhuk!^~13Yn20#sG!i-ZZOl(Kz62U^?9m@@|RH_^|4R4vh98Fx&xvKve=lp+@O&$aPt06@n#$zAg_9wna0d>@|mb9?u8il zb*UV_dXjp@f=4$dFHl=>3u~5(x>2`Th@X7S9JD5;qgR=J7z1|S4fs2N;w8YP4``!E z8)RKJ)dHp(%XV*=F^muJmM2JBs%6JluDH{myCtc5h-6n@iEPp6gm)N7+p8hu6 zoDjfjX>0@b-j_V3%!TuO(PCiT-9Tgyf!$vP znzjK=H^picl*)bg10!uf)p}s{mt$FC1<79%n+arB0=TgHt0x9iD%ti5Fw!dAWJ$zG zD=^+85-~v)>o_9H(P5x_H&DJx2oN3t#ySKkm6rgeE9AC;W}s)UNDh%X_%?9lU$yRc z0WD7gfgxbkp94*cfU=c9`wKwhwwM$R90i6?0+E1>7i}ub7TRqn%g$r0+)FvIRJQ^q z0#%Fe6kyoW84i!;*o25ywHuBDjn@MW*Tv4i{5hLS(W;D`zhEm+ryB~|{s-uOQ=Y5F zVA~0MfppT1=bibn=TM^d{Iw9O>;gI0cR-9bZAHyx>1RM^`(+&%JO*_9P9r81uZo6Q z$0hRZi7(T3Bzvx{@J~cXzuU)U6K^{(dRop=Dy)~cQq5JY11`P?=x+wvb^zz!33!>~ zYLaJ_t^|r2fz$sh1R&!359x-lHDaIU{u{G2l8EG!KLcEB6)Dt{*XoFRIwM16q#lyb zW@|~yQ$Wkpv7T0{8cGm}MjOI|-}zhBB|u({eumch_16F=e-4;`B5N3F7b&UP9KXIC zKz3~aIh<;U+~05mu80?HOub_G^Ef%yF)ubuZ0LDz9EW@d*Vq73MSP;12Y=U8>nrSFk z12vmcOv$P*=vYFzp)R2HIZ;0)OGQ#^Hi`$P2;%^%E|reE9KUuU2k(sIgxM2%8t6N$ zxs9D=gE3b!4vZuZNtffax}gCj60N2IHIEQb*a+lnbz{NMN%4Xhl%0Qv5P(pBEwJ~S z;=+2$f$9_l%`)U55~f}*0J?SqU9ZRVC)JShcI-wpEQqqARdtT32pwQeXb=v^REn|z zBY=yY8J-bEsGd&XL`{xF-lnc_g zQg_nbOf_lMUY-vWFA`)791-2j_<@V3uJyG5{vNsB&cVF5|F8sSJSH8B9#B~B&C~sy z<7^;kjgOcM8{~IO&G=e@;S(Yi9D4?K?!E{13D0fFjS-#{YzZsVR)C57uSJrSJFV-Z z=h7G}?*1g#Pk6r7m^O79Ia`o@*JN6IXdTmgM%?w&%{-=Y{R|@Bg1NC%{yhxy1ej%* zC%`PjJOSna0TTN1=3%A@(4E7#A(kIcH!Oagi59+yxdYu_O`{C0i7WkZjqcl|@Nqo0hGjN}?^6Sz?t* zS)xP+(BA@VKtq0N|Gad;-8t3w1R<6$JGHcm}{Xlu|FHGnI@0B6uvy zT-W^>0AB>K)BJ3A)Bszci|}b-$@RR*bx6Yi{tCc53A@+Q|vAL8H?fS*=M zowef30!aViy6)!z{HXyB0p*J-pOD5(-UqN>Db=Mpkq12Y19%odkq;c7vQX5Bv~L1< zf?sF=0VoIXLt~=*V#}xL#2GR2xKiqAYnFY?_<4Nr_%szM10qVP&$+H!;kd3_V*EVh zM3WpvKKYbJ0fQ5&JbMY6T>$o_+5SHHG!++$8(rw51kf!2D$JL@)#XzzWL-Y{9TTD; z_wm8wQ?6vOvYl*bgAX2`W=IY>P}NNL_Q@y5BhP&DDR1(@vR#2!wd2u`rbfGd|{emKYn zke6H+{HF*4bD!OK6cZ!82oJSmVyFufqrHen#}SWC=-(_UCkQ843@2EEqOw{PRW_oi zq7g;q4R8XXBnVxT@Rt>6ON)E}S>laV3j9azGCtUb(e85??YW4^XfND&46e(Uh$#Mp zN@p+v4gyfA0NE0P#g!iAZR#}XKEyC9Fr*>RIUJy;Dd{z@r1cf0w0=W9M^Oxw zR3lVcgHU;$<|0L9H3*he!3h*YIS$--9P!u$V&P#-jP+q+xEm8AU5JhiA~MzwHx_{k z7Qu0Xa_m@C^rt`&mCZX*yWvh0S2m@LZK=>Q1dv=NZ*2G37d6)*sX$1WNj#=GMWCnx z<@Fm;TDKl0)oV~(*_ij4A|pK*?Qg|+{}qh(TtH;BPXm$)H@O)BOppdjDp9xLcGRr9 zP5-`>ZpHEfkP#CjT`lN7{Ud~jyR_w`Z>Cm~qZ&9>tM;IB<@Fw};Mt#)X3`6p-x9iU z>QqjQ_G7g7JO-~G!NgFz29JPNA+AMa6ctT7(6IeMZN+JS(h{)LmJxvD@&>NHkM7ey z)FOwz8!ETdO9G){RIJ#Ax(#%kJuNSZQtKGSHadaQx(#U9vQNJ!cbj9S z*5&c|L5>-VjG^z+TNt=>Sc93|NN*M+m`l-g{bQ(T+*a_nWtjj-q30aIb4YU?>(Hav zs#&)e^_%y>36(6ICFCa0GuC?yPW8|6s8-Nd?D-j#H*6|s3$3vE<6McY zEAOJ`{L3B-E2iHYw?Bl&9S? zbBXN>kesDm|DXQyZYJOv?mnlVaL4E|@^pvLwDTZp*58)gcfl1_WzOj$SX_Y>yFZGm z)i)p>3+u7de-s@XK*x#iAv(d%D}@Q7!UvG?=1K^Gq^5Z39TF@*Jk*JKnCAq<;w(Aw1BgEyjceSYTAR07?WMmB;hT zs6a$3pnNL%icO4Yb}xZVqsA$PiIF~Zo_r3G(SCrU%awg=H{OlvH8+ZdQ!r+-k{Fs3 zK-10x+B@wU5Cww87`k>G1Fc7l^RuvkRA9kWj~;LI5qfpUhuRSt=|g0EzyM}E83|X8 z7D${4`NVh1>G?8Or?c1TkEaxc{@T7C1A~fxM1g-CU3$gGJV7%9T z7k`5ChRq0;R2Gy{T{r;pIL_jcF${N}!BEF3M25Q&2@h$|Xn&03i9>nz)sb*cta(Be z!NmL=W7QN_G@)VJg9WXSg4g6S=vs&R&G(}H_|w`)M16?!(0}!ZY@h(#j_z)1RqaH@DDjLH?eP`P5KDA9!s9#7>kufJyfUX1o#M%<0U zaU8^%HE`_+>Nnj}Fk&pY*x(r-xQ6bQmoV0SUe8^ih&~x;Q0Wv3Cx}3C6-sJWX)(kw zL!hWE5yR3)a^r|ij7cU-#Gc?r?*hy)gOs)IzsLnYNHsac20l{-;dyB1Ec zGy$0D5AVW_h7pU7!wHokV059SMoKAEt=xmru5%{8D5%qGNBde)(YU!FxX>a1NG`g& z7Msv!Ywg;bwNtIQvN^-hFI7?k3}!F}%W4rF z?bQ!iEIfkozRMaw^lB}(5L*NQDRiFx5e6>3sl}6?DCWh)A}FidfcmZXp|oaATKa@% znhHSfFC;mE5Xu|2pzq@A2xz@9h|#`_n!5(*qA(u!1TAnW(ZXqz1`IuCUqb&yg2z-U zn3U1ljdxubSiH7L8nqkul9R8Bmr&M(ys1jGCelE2KhV-Hb(aG@M@5LNG-OnOtLQg;?QA&?GV} zO(p>yrfKOY-V|D$bvZGHk*>2C?l`Tz*kuiyQPH#=6;0b_UF0}_05ZT}B?3LnHt&}T zvySy_ePBsB_ZEwkdAJxzEv{aT@ZdG!KFn}3inFeQ_12}ull5sDV9dLw-bpY;#xZ#H zJq)*>L}~4M?d{fCkE9tq%<~5zGmgP)N431RJ{g81YB$~^CB8oQ$$^r3(5f3B$7s(v z4GPw!1w&oKl^v1K6KQh*<6VqbhG4b9z0R)^<4Y1Mf7L4^>LgmWq zP`8=)q&jbrHjg4qAr=i|@Y*qLC6Eo%cTrZi5tS=;%-?B0#1t&4&<@sYL_QQMtww3h z>cn@fe;e;_)pcnj-7ScQhqR)tvl@&(I&k$}jP+eX{g#hN%urKqK$;%_8P3Ay1Ujch zmk84pAJ92Dx}2uaqvC~1?o_pDdV>^|Mpx99)@Uvh9_qqS$8ij{9@TL}+W>-A8@X1; zv8T0og@4+~JzB890L3FFd*_DSfx zo)2N8rGh1s671z(p|V;u?K+^VL1@-9<;6-3HkBA`JBIdS|DrP-%#I5PAT!8`ksjUk zf_W?yS56?HEeW3%ky0L7SH~V#-t-C7Y`9Hwt&E?E%JxXt8FZd}R#(zaRS`1h0Fow4 z&?y>Bsj&VD%GBbj6&WtXr^O*@?!auvrX2^+u$M=sIqeJU(U*3;2Nz}Td>P?LTmW>Advw`MFzn0pSC@SJW!&4$}`w~6w`t*BmS zqU{A+5wYA!X-UD@WZlNQwe-;IGV8qZF?6>a@^qzj7p}R2?mVFzD)~O2f+r)Qn2+9Z z{AmoepORWTQ*ka~fqL^DWO8FUmBg zl+RObKO5%92@L9_s=OzX+s|abxgy7&-;6(>*G#W@I&xhd5~*bsuQu(O30Z`R;Z6)* z`#`oaw`+P1G(H6MUBV!{wm)ePj_htNE-ba>5y0vo6b4spm06N@u93$Q5CDSnw z#8BH&)U3PNl#EXWAUlayjsida>ikV8&HS-`;I>Z!cl@jTdIQq=#i#GY3&8jPM7nSs z_-!K=(vIV0_x3f^3H-GzP*rO-?{9Y_hX-T>O3mkL0Zo@T?ejhmXA`lD$ z^ay)TsJW9VNn*1FUYA$aG1Nc+ZNFDubEU$X2A~vWm8Pp7~2P-ERX7k?eUCxb5e1+n19~?!$Ahy#aXa z4}|;7h+H||Xdm$8uL11wW9Lg()0*FPC9UDOL6E0G}-g8VP+`r?j{Moz( z&|U65ic-%dpsy7uWg5nOtT_-~@0I zb0#-=b6)!2K;JdF`QcIE&2O3emCi+!a43qnt#<)q1HgOV0RjvdD&Y7JxY@;W1$A-N4)b1oU5%efj>Q zKbJm2JWfJhS^dKSBI5wdYc}5@07}u5l)RPJ0>AK;)Fq$ZdfBY1WR?9`N-&iw!~ubm~?*23ZTV!D$-ZLNe7e_8Lxd4 zc=P|rjaa(}c=Y!KJp8+QrRYNMe;1&$>z-edW6->R_Dew9N#OeZ!WDS)_I_NzLrZ4G zR-olo;Pofvc-KE-YTD9uscA&4vH{9;H-h!2r5%&kMN8|#TfnzlJ(HnCY}xR^3x93^ zFvkyprOy)?*mS_Bu?B%?_Y~Ot&x-SJ@!u@8;Wqhkjkap!*3rz|IF`QeXXR z;EgANrp>^%du8H_%f-@bJpmm2Jh1L2VB0-Ms89O%q|soJ{#mzl^14{ZRx4bum5w2~n+p-d5n~GxGPH{~D-UV=P58 z7bgz^m)>5GW(rL0NPKn_Vv(@!s-FZ9pF}EwbXC9jbz>Ul8lcIt_}Yv{>WEo;`_BV= z4o>zab28gc0Wbf>WbdsOa}GfHOwefO;)JgU#jJR1+yvbE@yz4W$4cW)o+jj3^%-mOoBS9Y{zstuBEZ&r z(~=AP=qte4*JOJhi{qpTf9YM|%&S0vp_@Tq*TcZi{!yYol6D=WU$fW4%+Kk+Yq2u_ z3N(pD67~-;Eh!oF%AY>xx8Zy~HOIaickbbDUyP^8--lz{{B;^=J4m0tNPpqKuWkX$TH z=No1A%*Xq<;JV7Oa?t~nVN3V8JM#@lST_Yc1U zy#EYv!$IJ{?2(wL=q&X~Snh+K!A%ou>A!u~d**I|@T)~}P(m=%LkPEPT#_QqsN zhUGF06n{Vt=!#KMvnn;q;LOVctaczv zMU4C9UD|BXPZO6>aI#|FIQlRi_*|+#X1ZhO&ofNi(m_RIsaXFCMZ@Mh#nhh3dZRCX z4Y+n{GWSiSoSIb*=b8@uwuDo7e7pXK9Y002C4T1biR%V%_ov0bmA>@DT#4UKHgt6*{tAT3_r}eBif|mRl~aem*tK!rT4m%yRh?>9J}1 zQGc;1viXv(1J57|=3B=Igr@k-b9-zG>lBiGUdVNIXAc6G4n!^x%w`SjbD%?bxgEp+ zqxUNC+v3{-#vl#tbhq5urc*ve7y6 zoMq}z)^b`XiktL|Z1bH^nyK4Gj33CufxMI+Xq_#YF^u+I(Wx7(57Ql197mT#+ivVHvaAe`(tKR?`{|G2EahBO8*3&Yh zPBasqYZ2a3F&4p3jKqe$JK)7aeY>{kYbD z*vyljXXO;3qS^F!qPuSDYc)5Op@kp(nS?nA91^qV7?4aj-liIhGA42vu8T2d)% zKP@R3IumaePrg2WOhn?2`#ibBdF+4qDd6aHKy~xvc-4%^&D^Kin^R3LOiRlvsyw^v z@98wR;I7;?}vU( zVqux;>o~=9UXtc8V_@L&+j@aNy_z%|fC+7M`fs1d;iyM%QGs0fgI15px7pv6Rkv?caQ0z2c7B zX45wlQK;}BwsFMr*vNsTyR-o=f9jJEW9lDd5w>{xx09o)b5&UKPdYJHSd@T3> zDiE0vVDL5AEl=2R7ckN<5oZF2UTH?uIbr(3C*-qkR%E!9b>(wghEGnuAl}&P9!gD0 z&-vox09RsW)kVIJ!~~Hw$AHW43iq)3Uar)AQFKL}3{OypgmY6QQn#XbWnMJ7EjiD0 z4nWRJ;&Yi%^fB+NC3c^E5#xhxp1$5u)UMf!(weoC7VQgW)qCE5T5^ufa*G-W-~A#m z(j##p4Ty**Ny;oSnb3bc5pXY8Nd5C4!I^VWMLIzJm- ziXi{He-A|4b2sUg87H$C>B(jWBmu(|5Q2s!JGHBl`VhxQ144h!bPjXv^h8_E$YeO( zMJ$7OwnhqlaSZRw=-TC+z2{%S(6u8;#W^~mTl3UyzB{9PBsjgp&U~ixFOgr9>?xCy zCSfmzVu=bjFNo1;ozhSz3M&6*eHAB~pQ&p+_dkGvRsknH!VE~-Y{YyRiM_blu<_aK zTT%f${F|maW7p(DMax;KME~^@lHi}o59FPHdkj|4X4_VayAdfldi+mBWag{~+6R>@ zL%)IEbFU=&IJxl%0>u@&D~DdUHG`#`9YEwhIrx9;~thP&V~cc#-*^?#OO%YV)8z8 z{LGs{5ynXSQ~8q9s1^Qb!>2!j%odzEK zteE?KzG~aJ~Xby=qGClPs9j z3iH`R7-~JD6=o|Asrxi;z0WuevvafT(f9{Hm;VJM=Gz03#GADevRuqvzbGJ>$ugB* z9ZY*`XB&?2>U4RMlY^Ga`OQ~BpEY^c3X_{K)OiZMEiWTH)F}yKY^o5CYCliI)(2%V zoN08?X*|*Cc|mD_NdGN8FFF^p$hJjv`TB6INZ`|R<4;SjP)aUzoMdGj}K! z3fU&(o`;vyJKlE%{Z|f~M(|N_6za6+2%Mr)G;DnU)m%~`hkG&aE$(urOnJ|koL_mz`KZzr}Cz>R=jER7@ZhFxW5&HZO8Qz6tu2XfD6?zNXj*9Cv*vOrGSVzd{Lkse*U z&M0~`JgAp4pa)s6iD4G+CC4Fvipv}Hg2z=WcPFcA@_3k+IDpcX+(InLooU>rI1nfW%2pL#V+ePa1q7K7s;`ra=yEMuT2Ho$NUSb7pDegrtd|TgtJ{RKy7efk zY|6lKWB!A*dG#K%N*+K9rI(6O%L%hgktkHqX>v zSLNPyKb@7QG!w{aaF|rCG%*BRej11l0gZbNfYQ5-)U5A0I z&&l_8|J9EGrA(W(31@clV{)TQOtN@%6zFLDN`=Jq52T>CLFMkgR^>El4t?Lg(m)NL2ZcvxTn*@oP$ z!11pzW-xWSh7Ki!l27#R-%SFj>rLRyUjoH-a>MIx1R8Em-lVZMpz|jJ3@ZW#PA3~0 zShIzkCO!c)-wAC0H%Z;)?B4=mHdr^S7jqkL%NyM+s#wO)?Kgi?05sYTocorkmK~E9 z>FhUwqG|zU-5y}wPYL+jUId0O0_AIfhFd%s^;nzSccK^Iih{+p0-yq+#o<7qDxfKc z@Y`6ca4XC5y-zSFpB9)F5lmx9OWsl;AXqNndv%CJmZY)f;$uLVX}H5_;Su)hDN8m- zsMsaP)0HSp#T^2M&kMi_E)6t36XafR0&zAQH`SyZd;f8B+VSLAIreo2fs+SM40W4+WsnV?(dTTNbGJEeWO$C`gcH?r-EXj1vvb94~O)muuvq$O=6Z+ z#x1vsGf&79^d;vkX#{%S5sp_}pQ?y=zavIM&@7c;xz9o_2r~sOr@)Z_w_>e4!Bv|? zY*6pX#p2T4?^%j$Tn6L{8HwU9l6<9)!Bd!7 zFWVMS%x`f-*w6)Fv~3zohl`D{Oxcw4&IQ>e5E9l9`xu&z7}Jq%Y+$o5(+UQ?R8$9S zW}|YhfiWOTGQqa`08rK}W2oGa1Q3tYcq`EMW1#a*VD6Hi zXkh_lMak7409T#{9BUz&g-6nf6Q|kz9HlCy)J>(C)0qcs-dy0^Q$SIba1e?u>Sm5v zjZqFRZ^bph+Q;Pccn@&xMbU>=-X~noOEztm)@9{pVBieUe+H=CDd!wG1w=+f`KA@u zu(zPZ*Wz&?f`SSuC&ilJjAKpWK%GE@J#rR%vRGF0vJpKV>n&Jn#F8lv)SJdV-8IIO z0Hdit$q!^3Y?h0ovE}c8o_7U6*oqCn&3~A@IaYB! z^>yId3r2}vjB$FBwKhI33NA%nqNF=6zwsJ))Agp@C&MzH3GU}9g-UGW0?qdVm!1~? z*v5~G-)Hc=l<35VfchIGU_58tH|B;4Bo=0M3J-uZ705*KCa-=NC|jA!#>h&I7Vc}gtH#|vZSNv zW18+9EB2WVZqEZ9Zx|8J^<+1T1zBL+r*JqBCq_H+(l}7Y0O=wT^$@lo>JJXb;!b zt2WjE^|t_nEn-*@bPUyK4|U=Aeik1ifWWo4wKQbS2+IJRU7E-fZiqVZD0KdnQ(vmJ!HvGx*DR znD?06mx?sQ8vNXRwEHksf)7xF0^*{oIO`hHa@HLM+p zN~s|Lf54tuzH`y1DJW)`%u@iq=ellyA}mfWq?95{`hrIn@)c^Iyc9DTtnUN(9i>#v zn(-u;XaewO0KRNc<}1}c!7vibdAQK)CzMiMhUvIggxO93eElAP|LUoZncjFlwhg>5#OUqDYnY^IDnI8XS1Gwe)*#9!%MEY$JWvc8XqA$ zq8?OAouj0(SV1PUo#+xAY2Ap0+fR^f0VvG&5I)Tp^#XVXz&DgqFYyZ_wo-!cS^YlV zja}Dm2Eek<2Las1wTS`L7ff>opD;@zM`2y|RRG6~kLMMo)N%cz=*Mv{=dtwv0R{aj U2$Dw&82|tP07*qoM6N<$f=D_L82|tP literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..5849376f0f1c54a765922a4d716ad721b10d78d8 GIT binary patch literal 8505 zcma)C^;gu*+y3m*jdZ7kbf<)blnYCTG)hZLNG*aOAxKDfOP2yt3rI-q($Xm*4KB^{ z<$3>t_lKD|bDueLuDNICj%y|ws;x$ZPlpcx0Fj2eirzyD|F7U;Kh%1Qt~da|3TUW2 zGw{zo$ieY>IU6u=PYpct2oH?Tq;lfdO65x!)L&-zrK|1#^)Uo zq?xyRd5`1io1G@a7JSzq4X}T2FMFlcQ2LD{CQ$G*%gHz0=sS?VNysjerC8PKo80$&c{+@hv@%CWAIXT|QZP3l3gn7$j1Qv*n($dK zgQh;3XwQgM6T{1ZMa+C!7=P+4FNMY{Ot==n1RS@J+KSkK^PSA3VX~mbzj=QsV_x*( zSj)l$G5qy#y9Z7PV5Lsf7?+P=eRv%&KCrcVtlpEs8I?YcrDG{hid9HjfsZMHsVho+ z-UbplO6j9t5fuQLJOzTN;VYbXrxY=plN;3Uw;d>f`;kyV*uoU2aiZ!!P>VV4Nxv)D zLl$<_=gg|d%?B3-=p#tgz&NEuUXV#`j5<6;T{8il0(E&E&}BYpPb^fnv(!ldP%sF$ zO8jFEUQK?)`vC3frCrH2w-?M87>*#l^TI-kjqXYY@N`%u5=?a4VZveF(g8>5v{wObRXD=D&f=SAS0ms1*(jff)?IN0sE$Tr@P6` z>l$LAUNET2Akn4q&}wi4A9g!&Kd|M1^C##irDN%CW3r#)!Pv@l!TEzuDFC+@9S86r zGV~l)6oZ6Ih=)BnqABI<7y85ar<)u>5XiJYru6#^e-rY1->Jirw+NGE)G?DppSF!d7I}$V=?yFy7T6lkr_@_FBYr8y8%~j zkosi)HVgB?a>96SI1Te(*j85D2_)z=`0~ez4M~4g__sV)U#@J9Z`|B@2|Y1b9$&=E z4SO&PoaQ%x8Zp^tiBjMCC3m|$wwVYSSBMeTZ45s()NrL@E1-JK;J&xRtAm2Nyvn$m zfF8`RiWy^Ix!ZbA;tA&N`@ed+5Ya`*UUBB*h=#BS9Bh^dmTQuZu!3{OV@N}xUnI*u zcU3yc#e31>n3UmiIYqK#NWdT0mB7`KbnMR2L=qgyroL$pDkHYqe8Twm4dkgztoz6y z{bLAZ@FM3Qp|x!>_42r7Ygsx2bBvo{0pwT5fFt4=#dK4?*DvWo-l{>3X>~ls_XXl* zE{H}NX4lxix9vS%6b5`Mx_@jp(`I@P`95E1j1%>j=Fb^q^%OhcZiFAu94tqTFZZTr zTK~Gupxs>zug_KH&gvrmLofy#?FO^9hU%#Kz|!gpS?f z2;t9^V_fpd1uEfAv_H0Q&?}V*FMRm@2&c^0XB-iF* z$Y4?qll9BJjw@&!{iV!zA=48C#kqOW^)k22565u)+n$p@c0yL}wwGjK2yL1asTzp@$*A>2qb$^Wv2Sab2EPt#+9kZ58EH#*;_mOsr?bwmsn`mUu9D#)*-Zo z_WKN)-4n!Ze|T5EH*-aOF<)r)8Xw$@%j zm~R%Cx~DEbubnj=PgVagGYhPWAng%sksLB%Wsh9&D~#e?{#>{3s)(nU;&HyWh~n~B z`|Zx3)KEGJUGhqdYp!Qg!re_SxANxS=Tw4!I&&?xtbD+ZFSUo-57m8V@`<}fn$}PY zffyWhtCEU!K`OF((E0qD2-=yyqi8SMmrP^i27P?ry|Ga83yEuN%3hIXkx3b@P)9Zh zxgDw`G8W}L^5B>ve_NFxKD)<_d}}BZ%DILj95i)t4l>aZE#94|9X`d`-b$b~I899> zX=ArSUlKag@6K~6XMD1~IJoMYm{0!1sbm~L&Z8qkvJ`mFaI$E!@`&*5zKYYGrb$i} zIqd5%Pyg$LU(8NbN99vJwwNN>7t1$R+LlwPv!4v9^TiN8^Qgv8_IcCaK5mAfM12BG#;(!)n| zk>KHe)z#qd)K!EaT5dYSYpt8MpO4)nz1k*1{OFirozuAwNnQ&O#ht4Yk-=z zsZD)LwNyr+P8-hu_5)e>X^DCn1EJ7Pcv4_t+`Tv#*YoktgGbWCBbdBJt=rYgx}y^j z2C4+@xvy~><(@n?GkIBlv05oo&zX{VcvpT3dG=d0L`rpa@LvPl!2%j~Lc2b_8)qxx z=hVwH>G)jLwr-oc2rj2%wyb31!4k@DRMWI z>HAe)SS=&I`F5QSH;caw3ugZiwL})@~T-M>ILxpXnM$83)k-@HiC>*3I zN4W@YzoVK9;Y$&xsty`f%4}S{O$2cD3y_oaN0t1F06U{ndV7qT2J8{pJ9|REgD(Y- zM&i7sVoRKVk{vDXc$Iv9*A@4jF9X{~@^ z?Bt6JGi{KTRQ^C2s}~{{vs3V=v~Xvne5~Y3w5x?Dy%y3DYO8YOu<-WM#Nx4R-;NcU?uqyiY zhs}`7Ks?v0POm=`1Y5-%#fCtntS>E7S?4y-8}%y`_XHPd&svJc`G+sD#mS#CL2a|? zsdiIAUdbafQ1M~G*afky?@-*oAM6eLHR+5vrvH@;nn%=W;Lr{?NamgkQy5C#@cPj7 ze$Lo9x-~KaqRU+E=AY9xR+%_TF#H&lCg=8|XT>Vep0SwVeUl~o%t_+S!u98Ds2wHv zrA(F<&2GTrrCG;J9GfSU+}eF3U{8UE(aydSRIR}%%OpuSJ*E!Irycp&qv^7h%9GTl zwpu*+5%_x}kqi-(oOl=#?!fYcILC<+I7;^no4ytKc4e4esmg(>MAmJ++gR@~$7^TG zP;x1@bch3>I(80y9yCDT781WNt}jq)?+&6K*SkI^lx;uZnC-gT2>!rfL~h#GT}p9+4Yt5lBqYT$yTd6@$1lZ>SiU zlB>4cMO)hV*5dsO#w}@Dqc9L`8-cdfl}tDN@z8Kk3v1IQ3hB%4C)-o{B3>gLl#3N) z`eF{3(mj8Wj@+p)$(x8@c|>hT_A+xV(&Qo7b+I-lM1bN@s;RRE58*El zjRw?w$@VxdGR!V(n}&F)lUnL{K5jhVNmy%vivpn(IgM!3oyO|bO?ZOBC8hQF_2sh5 ztT$YAMQbFGBdhEX>)cgyEKo8J!`zozuNuFVI`@zyE{jQXREqWJH(boY#TAVFPVRrh zTFi(KkI^FM0_?)!E(#>_mUV7!7``vn*`qT#GGs3sQ*M)FC=3LCI$of0w|TvxL)5vn zWk7KQxh_ut$^quU&27HDM0OwO`DGvXm$ykMdlELik%GT>P_0D}w#Rf@8MEC2S0nzy zvS&Zz-i4DlLR30o4JUb*KcU>O#hjiMc-7)fvUsMWkm#Otp1s=Sy$Z1&nCu>KsR1p1 zI_X-W@4{WKHN)kT#z8iB|?Hs4+&j*$(O!pR-Nn71ZqhkeNHJ3yDo_kXhZjpE_zj02908$tuH74I3*#8)$l{HA)DvGgPem9(ZGjA;IgpKL=n?nCRh)cs%}>9ocQJ?P!gj>sSds~U2t;seb=9ctQAqh9)0 zwuLD26|l@Xc7k{{_UWDQ)&{%ck#4@_5xE4rD%$a9bpl4tO?Wu35B8CCVXfIAw>P7d zV^LqZnOO_tQ?=p2ZEE9TU(-$vMAHU&uLalPb}ryi8uE9gtdgh9954Hx_AUfrVO&-rWos3wxl9L7{ILR8MVa&u^2QGlZD&Wi1*= zumZbYU&zqJ&tg2ie!F^?R<=-qxbZnYNmYZPH?Db32HY|3uy^0;C;zCvH@*iLmMSXs z!ZryUSUt8N=(BeCRurm>Js57cf~`5)Zi1J zmk>nKvQE%{)O;#peNPWx8@<+CZ+_;m`B>Ll&&RjPN|KnAj$j0hQ_}=0v&FVHvgJZ9 zz|8@vMxu)zy(#{jfMgBUmUJajtV#i#_#;fJDlF|zI1AOTAdH#`$*liP`kzGIvDn!~ z+p*E=ZeZA_X0Y2Cm9o?8s}JMSJ*m0LC-HT2eAZzR0p1|%5k%Om%@cupaGeho@9^+F zV@@u;HG%*>xsr3&uGU4hbaOZ2{zk<1jtFkb9E+Z((3SofcYM_=-rz!#pE=0QCiE45 zO)_Y6j0uiwM8lhxw(OGM`$mAM)Tv_WqT=eTR}LPGeZv^}L^3bjbzp!+9%CP!uiy>h zt{#^RKT*QllVyryA!PI^Iq{{<{s7f~ErwL`zVrKR6!I1a*(%sE`?>GMhk!5)r1H~7 zAp6d9>GY8xp=kc(6EUtZUr#r1GUZO62Zrjc>o0i)xB=XY@_Bw|`PIjI>GefcN5xRt zzOgy^NqD6T*?a>6wBJL-NSnfc`Q_x?QvMPUN>TwO$jks)=! zOo)2IH^k5G`%B3%4EM+P-hVU0=npma1Fsi|R2gEqXvPv5@q`Od@1KTa7x4A?vhj=5 zgN1}k5w{jL;`3Pu?6H<7qV|mQt)s3>yQ_jns{J0C=Z}AqJ`bZaWTlpfdXcJ81qiLi zo*b(!XN0ude5kp=Q6dn@iQ%-i1bZ|62C|)!Vp4jEQ~1){%p>M<6+!vL*VlsoaDPE* z0+qdAvQ@K}P)^GDPNQ}vs_IrGj2c$~@Oh{>p}33KVvkYq}F zUSk^8KsbhWO|^+=b6UbXa|i+}k}x+Gr*o6xA8av*MaT8oJgS*=M@s$%(TIJ&dSrpD zD#WiBvlSU;e@<}WwAjeFfX%Q_6IJYbiFq8x4M`P5n>3$eRaj=<&+S;I^;|1w-`x;* zxJlhio*3#;w+g=f`6?;sPw2nw8K`qAIE|}&1_A244u_Awnapvin(wER|HMmn6EGL! zKy$f7japSPYQ%+h{FI98NkQVNy?Ry6>Kn=6Mv|3%*RowJxv#YOabD!?u&A4s1(+m} zz9b)CNCWp%Mm9(5!g4lgjepkdLoc5i7SMMVWxcF+#j?$O<@i3h{rI9lbBHzop;<{@>4Rk%-v<9gE_z}#ps(6 z%t}e9g{#Te`)b>yWYu#WU1IbDbyiAml_^KSOP$df;tR5VWp%mVa~!#p^{>K#VbSjH zI$_B4rNFZ%xE8AkIu525u>nsKUetj<^om4BlL6N+_Z8A>79MhWX|=;-{~}5GSlT_= zxWgp`P2_N$jiuokmZ*f4pNcrL$Hq~qr~IpoC|p! zSuqt$^0!1=#6v&T$0RT6lR(RF-7L1Rp^LEc@rP62c$DT)a z-sxWUOuF5%BE*LU!tGLnZDYJ}?~E{(H&+EF9e9Y{#-Qy85F%`B`>OX;5Rs<9Sxu?5 zqUC`+3X-^-IqE8&J#>;r$drz&^uT%yeBw*emg zTO*?snnsHD6}0yaj~R!<^yQvZAgiT)tp89qbOi^6Pj8WYb6f%uVu}PuFr^j@W^^ko z))QE?<%(oqVQ}g!+v^1vQUk=U#3rS1&&VyJoT$Gvhy{J@y$N@_tZeqVw2+6H24p#4CQE zik7I60Nxi`OXB!E;B=UP(Q#K#`gh;S`a)R0;Sahmu@wmOy5AuhlVY^%$c_u?-hMw2 zer0ODeV<`nA8De{O@_CIqXrej@fu2oytOAKLVATv15vPRMBf)zm)Hkg2;7l77Af6f z5ick&#d^q{6m*Yy%nVp_Jj%5#{OObWb=>fbz8IyCUB+$OsC%!hNeT-;?Y-b|<#sWP z9pcy?iWjVB6N3b12jkg=lqe-egeiy%$0gPT)3`1ST4N+aHw>RTj=%_3HA% z*bc&TONOL>N6q`HGlmYi)cnO9Re6g$!!TynYvbj=~=7^6r8V)FJ{^xbWrS|ZsyTmbSI**Bs))V^c%LE-^2HN z9CPMu4esEo)6SzKr3p~rk!T$%+&-|77lXdV^~7GHZ_9$#GPu9WnMO)ICVQaRBPJT} zRSB60Dv4>R!DC6zDWflA*E6Yp-%V$ee|o^Al<`WgtmTnjtzRp>?nIqth0J$M#O2y% zDc~V0(WD?UF|CVBA^rq$%J86?sCu*=^2NGOoWwU`<0s}NL?UtBf85?LnM!dDYx8ZE zGQ%+Lzb%AC*Ub$rfvc{{QaHUF9E!I*<&<6Wi82W-sJ$kKs`w;It>1*4AH6PeHa0Dy z0REnTrTbT~xrNWNw#DZfo(dy+`Mk<)qr>%6p@Oer1C#ZTyd`cJ*pHoVHbkZQI*w@2 zG#7O#-PkCuD}()mR+NeMNRx!x#;UQi?GY)*!UH51@dR}Pr>}p zr^^PKZM0R^0IH_UN&&e(nHOk*m_@GR&`hEub&t2wp8OIlwNptmjU!aBu#O+{vc^Nk z;L$u>`P@^$eTDH#`G+wu-22Xoh*_c&8@);qKWE0bNeOyub2W5y|G2zpK=#r&6mL-Q zV%7qmUv#6kkHYx`-}NJkx>2n>M9z*8-ext45Ol#dT9R&5^cI;%1zWRJor+x03@6hl zh!(_$Q61Yj!p{R? ziFT~B5)|!SH=tlR4!1L=QD~yRJXDOtV;WG#fpuQ|b6FP_z05%!#N96Q=zQDU&$|M{ zndh18elk1sl85(d*73zfw}m>F$|z+D#E~a1k#Z#4=%$X51=oM>lfZ4D9v#OQXBd|UATK3B=Ga% zr)K@(UgE&lo_5axJ|!WXidhVt(8^l6oLbOX?TmL_6vsw}KQ{wsWka|4WqB`AetzMT7Tfplds#OO8)=fk-i3pNKAL|p|EB09TJ>V91~B*fI{Tc~;O%>ush|U? z;;88lN-o6<``}(pp4q=^q@Yt?Hi!L&62+6CrS-m9Z9?$agb-?bbTFYtcEO9osEQqU zHG9`TeE0(MZ=@L!{*Oc_gKNSd(MoNCnhbdpHM4Lq(q9?~^y{E9cT*&v4N9Qog}mgjrt=(%8P zzvAc?pRX(4Zf`_h0wdg(j-7KTpR6oQ(Oe2bAJJ?#3yZ)0cr-y)E2KC@ohW^!q>#P^r+W7A!fTM~a9 zYF0?~ALe?bRHarzE7h)Fq7A(Phin)+hx0FD&h)JMr8gGzCyuhp@)e!Xav9=ZS&+O} zUzHqz)W6SeXYRp9Rzy8=Gl$8IThvfqMFG}Hj=AVYob(;ZWgpq$aQn&JQMieTjaejY zK`+fV!`s=u%88a|E~ zg*7-7)>BD1c!)A+9!Q(o+%%8jBYilajW&lz8x^_e^hVAV*{AOC!A6Zx4CRPk)awke zqX>#U`1l<(-e~Sj+f}j>t6w>^FP0l(+%pw^q z-p49JDN!5rJMX)WG55sqV=QZ}ca;r16i#+HSk^dNEG0*D6fsRFg%I3svvtAJ|LZAM zZ;!*6SMO8kiU);TE`r5b0KQLNETh!L&|g7pD?7+5qX5sqJ#D8NdsZ#O)a_73zqkpT7_D4|{P)kq|YukyU_KPQE~0W3ksV3-lB%8P=# bdy>erftVRZi>8M!5rBrOwn~kX_51$;-LxvB literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..5270ccf --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..dfc5292 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + + Cloud Homepage Operations + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..06302df --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..2dd05a4 --- /dev/null +++ b/android/build.gradle @@ -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 +} diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..045a907 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/android/gradlew b/android/gradlew new file mode 100644 index 0000000..9d82f78 --- /dev/null +++ b/android/gradlew @@ -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 "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..8a0b282 --- /dev/null +++ b/android/gradlew.bat @@ -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 diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..8c0024e --- /dev/null +++ b/android/settings.gradle @@ -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" diff --git a/assets/data/MCityPiker/province.json b/assets/data/MCityPiker/province.json new file mode 100644 index 0000000..6569465 --- /dev/null +++ b/assets/data/MCityPiker/province.json @@ -0,0 +1,14793 @@ +[ + { + "code":"110000", + "name":"北京市", + "sub": + [ + { + "code":"110000", + "name":"北京市", + "sub": + [ + { + "code":"110101", + "name":"东城区" + }, + { + "code":"110102", + "name":"西城区" + }, + { + "code":"110105", + "name":"朝阳区" + }, + { + "code":"110106", + "name":"丰台区" + }, + { + "code":"110107", + "name":"石景山区" + }, + { + "code":"110108", + "name":"海淀区" + }, + { + "code":"110109", + "name":"门头沟区" + }, + { + "code":"110111", + "name":"房山区" + }, + { + "code":"110112", + "name":"通州区" + }, + { + "code":"110113", + "name":"顺义区" + }, + { + "code":"110114", + "name":"昌平区" + }, + { + "code":"110115", + "name":"大兴区" + }, + { + "code":"110116", + "name":"怀柔区" + }, + { + "code":"110117", + "name":"平谷区" + }, + { + "code":"110118", + "name":"密云区" + }, + { + "code":"110119", + "name":"延庆区" + } + + ] + + } + + ] + + }, + { + "code":"120000", + "name":"天津市", + "sub": + [ + { + "code":"120000", + "name":"天津市", + "sub": + [ + { + "code":"120101", + "name":"和平区" + }, + { + "code":"120102", + "name":"河东区" + }, + { + "code":"120103", + "name":"河西区" + }, + { + "code":"120104", + "name":"南开区" + }, + { + "code":"120105", + "name":"河北区" + }, + { + "code":"120106", + "name":"红桥区" + }, + { + "code":"120110", + "name":"东丽区" + }, + { + "code":"120111", + "name":"西青区" + }, + { + "code":"120112", + "name":"津南区" + }, + { + "code":"120113", + "name":"北辰区" + }, + { + "code":"120114", + "name":"武清区" + }, + { + "code":"120115", + "name":"宝坻区" + }, + { + "code":"120116", + "name":"滨海新区" + }, + { + "code":"120117", + "name":"宁河区" + }, + { + "code":"120118", + "name":"静海区" + }, + { + "code":"120119", + "name":"蓟州区" + } + + ] + + } + + ] + + }, + { + "code":"130000", + "name":"河北省", + "sub": + [ + { + "code":"130100", + "name":"石家庄市", + "sub": + [ + { + "code":"130102", + "name":"长安区" + }, + { + "code":"130104", + "name":"桥西区" + }, + { + "code":"130105", + "name":"新华区" + }, + { + "code":"130107", + "name":"井陉矿区" + }, + { + "code":"130108", + "name":"裕华区" + }, + { + "code":"130109", + "name":"藁城区" + }, + { + "code":"130110", + "name":"鹿泉区" + }, + { + "code":"130111", + "name":"栾城区" + }, + { + "code":"130121", + "name":"井陉县" + }, + { + "code":"130123", + "name":"正定县" + }, + { + "code":"130125", + "name":"行唐县" + }, + { + "code":"130126", + "name":"灵寿县" + }, + { + "code":"130127", + "name":"高邑县" + }, + { + "code":"130128", + "name":"深泽县" + }, + { + "code":"130129", + "name":"赞皇县" + }, + { + "code":"130130", + "name":"无极县" + }, + { + "code":"130131", + "name":"平山县" + }, + { + "code":"130132", + "name":"元氏县" + }, + { + "code":"130133", + "name":"赵县" + }, + { + "code":"130181", + "name":"辛集市" + }, + { + "code":"130183", + "name":"晋州市" + }, + { + "code":"130184", + "name":"新乐市" + } + + ] + + }, + { + "code":"130200", + "name":"唐山市", + "sub": + [ + { + "code":"130202", + "name":"路南区" + }, + { + "code":"130203", + "name":"路北区" + }, + { + "code":"130204", + "name":"古冶区" + }, + { + "code":"130205", + "name":"开平区" + }, + { + "code":"130207", + "name":"丰南区" + }, + { + "code":"130208", + "name":"丰润区" + }, + { + "code":"130209", + "name":"曹妃甸区" + }, + { + "code":"130223", + "name":"滦县" + }, + { + "code":"130224", + "name":"滦南县" + }, + { + "code":"130225", + "name":"乐亭县" + }, + { + "code":"130227", + "name":"迁西县" + }, + { + "code":"130229", + "name":"玉田县" + }, + { + "code":"130281", + "name":"遵化市" + }, + { + "code":"130283", + "name":"迁安市" + } + + ] + + }, + { + "code":"130300", + "name":"秦皇岛市", + "sub": + [ + { + "code":"130302", + "name":"海港区" + }, + { + "code":"130303", + "name":"山海关区" + }, + { + "code":"130304", + "name":"北戴河区" + }, + { + "code":"130306", + "name":"抚宁区" + }, + { + "code":"130321", + "name":"青龙满族自治县" + }, + { + "code":"130322", + "name":"昌黎县" + }, + { + "code":"130324", + "name":"卢龙县" + } + + ] + + }, + { + "code":"130400", + "name":"邯郸市", + "sub": + [ + { + "code":"130402", + "name":"邯山区" + }, + { + "code":"130403", + "name":"丛台区" + }, + { + "code":"130404", + "name":"复兴区" + }, + { + "code":"130406", + "name":"峰峰矿区" + }, + { + "code":"130407", + "name":"肥乡区" + }, + { + "code":"130408", + "name":"永年区" + }, + { + "code":"130423", + "name":"临漳县" + }, + { + "code":"130424", + "name":"成安县" + }, + { + "code":"130425", + "name":"大名县" + }, + { + "code":"130426", + "name":"涉县" + }, + { + "code":"130427", + "name":"磁县" + }, + { + "code":"130430", + "name":"邱县" + }, + { + "code":"130431", + "name":"鸡泽县" + }, + { + "code":"130432", + "name":"广平县" + }, + { + "code":"130433", + "name":"馆陶县" + }, + { + "code":"130434", + "name":"魏县" + }, + { + "code":"130435", + "name":"曲周县" + }, + { + "code":"130481", + "name":"武安市" + } + + ] + + }, + { + "code":"130500", + "name":"邢台市", + "sub": + [ + { + "code":"130502", + "name":"桥东区" + }, + { + "code":"130503", + "name":"桥西区" + }, + { + "code":"130521", + "name":"邢台县" + }, + { + "code":"130522", + "name":"临城县" + }, + { + "code":"130523", + "name":"内丘县" + }, + { + "code":"130524", + "name":"柏乡县" + }, + { + "code":"130525", + "name":"隆尧县" + }, + { + "code":"130526", + "name":"任县" + }, + { + "code":"130527", + "name":"南和县" + }, + { + "code":"130528", + "name":"宁晋县" + }, + { + "code":"130529", + "name":"巨鹿县" + }, + { + "code":"130530", + "name":"新河县" + }, + { + "code":"130531", + "name":"广宗县" + }, + { + "code":"130532", + "name":"平乡县" + }, + { + "code":"130533", + "name":"威县" + }, + { + "code":"130534", + "name":"清河县" + }, + { + "code":"130535", + "name":"临西县" + }, + { + "code":"130581", + "name":"南宫市" + }, + { + "code":"130582", + "name":"沙河市" + } + + ] + + }, + { + "code":"130600", + "name":"保定市", + "sub": + [ + { + "code":"130602", + "name":"竞秀区" + }, + { + "code":"130606", + "name":"莲池区" + }, + { + "code":"130607", + "name":"满城区" + }, + { + "code":"130608", + "name":"清苑区" + }, + { + "code":"130609", + "name":"徐水区" + }, + { + "code":"130623", + "name":"涞水县" + }, + { + "code":"130624", + "name":"阜平县" + }, + { + "code":"130626", + "name":"定兴县" + }, + { + "code":"130627", + "name":"唐县" + }, + { + "code":"130628", + "name":"高阳县" + }, + { + "code":"130629", + "name":"容城县" + }, + { + "code":"130630", + "name":"涞源县" + }, + { + "code":"130631", + "name":"望都县" + }, + { + "code":"130632", + "name":"安新县" + }, + { + "code":"130633", + "name":"易县" + }, + { + "code":"130634", + "name":"曲阳县" + }, + { + "code":"130635", + "name":"蠡县" + }, + { + "code":"130636", + "name":"顺平县" + }, + { + "code":"130637", + "name":"博野县" + }, + { + "code":"130638", + "name":"雄县" + }, + { + "code":"130681", + "name":"涿州市" + }, + { + "code":"130682", + "name":"定州市" + }, + { + "code":"130683", + "name":"安国市" + }, + { + "code":"130684", + "name":"高碑店市" + } + + ] + + }, + { + "code":"130700", + "name":"张家口市", + "sub": + [ + { + "code":"130702", + "name":"桥东区" + }, + { + "code":"130703", + "name":"桥西区" + }, + { + "code":"130705", + "name":"宣化区" + }, + { + "code":"130706", + "name":"下花园区" + }, + { + "code":"130708", + "name":"万全区" + }, + { + "code":"130709", + "name":"崇礼区" + }, + { + "code":"130722", + "name":"张北县" + }, + { + "code":"130723", + "name":"康保县" + }, + { + "code":"130724", + "name":"沽源县" + }, + { + "code":"130725", + "name":"尚义县" + }, + { + "code":"130726", + "name":"蔚县" + }, + { + "code":"130727", + "name":"阳原县" + }, + { + "code":"130728", + "name":"怀安县" + }, + { + "code":"130730", + "name":"怀来县" + }, + { + "code":"130731", + "name":"涿鹿县" + }, + { + "code":"130732", + "name":"赤城县" + } + + ] + + }, + { + "code":"130800", + "name":"承德市", + "sub": + [ + { + "code":"130802", + "name":"双桥区" + }, + { + "code":"130803", + "name":"双滦区" + }, + { + "code":"130804", + "name":"鹰手营子矿区" + }, + { + "code":"130821", + "name":"承德县" + }, + { + "code":"130822", + "name":"兴隆县" + }, + { + "code":"130824", + "name":"滦平县" + }, + { + "code":"130825", + "name":"隆化县" + }, + { + "code":"130826", + "name":"丰宁满族自治县" + }, + { + "code":"130827", + "name":"宽城满族自治县" + }, + { + "code":"130828", + "name":"围场满族蒙古族自治县" + }, + { + "code":"130881", + "name":"平泉市" + } + + ] + + }, + { + "code":"130900", + "name":"沧州市", + "sub": + [ + { + "code":"130902", + "name":"新华区" + }, + { + "code":"130903", + "name":"运河区" + }, + { + "code":"130921", + "name":"沧县" + }, + { + "code":"130922", + "name":"青县" + }, + { + "code":"130923", + "name":"东光县" + }, + { + "code":"130924", + "name":"海兴县" + }, + { + "code":"130925", + "name":"盐山县" + }, + { + "code":"130926", + "name":"肃宁县" + }, + { + "code":"130927", + "name":"南皮县" + }, + { + "code":"130928", + "name":"吴桥县" + }, + { + "code":"130929", + "name":"献县" + }, + { + "code":"130930", + "name":"孟村回族自治县" + }, + { + "code":"130981", + "name":"泊头市" + }, + { + "code":"130982", + "name":"任丘市" + }, + { + "code":"130983", + "name":"黄骅市" + }, + { + "code":"130984", + "name":"河间市" + } + + ] + + }, + { + "code":"131000", + "name":"廊坊市", + "sub": + [ + { + "code":"131002", + "name":"安次区" + }, + { + "code":"131003", + "name":"广阳区" + }, + { + "code":"131022", + "name":"固安县" + }, + { + "code":"131023", + "name":"永清县" + }, + { + "code":"131024", + "name":"香河县" + }, + { + "code":"131025", + "name":"大城县" + }, + { + "code":"131026", + "name":"文安县" + }, + { + "code":"131028", + "name":"大厂回族自治县" + }, + { + "code":"131081", + "name":"霸州市" + }, + { + "code":"131082", + "name":"三河市" + } + + ] + + }, + { + "code":"131100", + "name":"衡水市", + "sub": + [ + { + "code":"131102", + "name":"桃城区" + }, + { + "code":"131103", + "name":"冀州区" + }, + { + "code":"131121", + "name":"枣强县" + }, + { + "code":"131122", + "name":"武邑县" + }, + { + "code":"131123", + "name":"武强县" + }, + { + "code":"131124", + "name":"饶阳县" + }, + { + "code":"131125", + "name":"安平县" + }, + { + "code":"131126", + "name":"故城县" + }, + { + "code":"131127", + "name":"景县" + }, + { + "code":"131128", + "name":"阜城县" + }, + { + "code":"131182", + "name":"深州市" + } + + ] + + } + + ] + + }, + { + "code":"140000", + "name":"山西省", + "sub": + [ + { + "code":"140100", + "name":"太原市", + "sub": + [ + { + "code":"140105", + "name":"小店区" + }, + { + "code":"140106", + "name":"迎泽区" + }, + { + "code":"140107", + "name":"杏花岭区" + }, + { + "code":"140108", + "name":"尖草坪区" + }, + { + "code":"140109", + "name":"万柏林区" + }, + { + "code":"140110", + "name":"晋源区" + }, + { + "code":"140121", + "name":"清徐县" + }, + { + "code":"140122", + "name":"阳曲县" + }, + { + "code":"140123", + "name":"娄烦县" + }, + { + "code":"140181", + "name":"古交市" + } + + ] + + }, + { + "code":"140200", + "name":"大同市", + "sub": + [ + { + "code":"140202", + "name":"城区" + }, + { + "code":"140203", + "name":"矿区" + }, + { + "code":"140211", + "name":"南郊区" + }, + { + "code":"140212", + "name":"新荣区" + }, + { + "code":"140221", + "name":"阳高县" + }, + { + "code":"140222", + "name":"天镇县" + }, + { + "code":"140223", + "name":"广灵县" + }, + { + "code":"140224", + "name":"灵丘县" + }, + { + "code":"140225", + "name":"浑源县" + }, + { + "code":"140226", + "name":"左云县" + }, + { + "code":"140227", + "name":"大同县" + } + + ] + + }, + { + "code":"140300", + "name":"阳泉市", + "sub": + [ + { + "code":"140302", + "name":"城区" + }, + { + "code":"140303", + "name":"矿区" + }, + { + "code":"140311", + "name":"郊区" + }, + { + "code":"140321", + "name":"平定县" + }, + { + "code":"140322", + "name":"盂县" + } + + ] + + }, + { + "code":"140400", + "name":"长治市", + "sub": + [ + { + "code":"140402", + "name":"城区" + }, + { + "code":"140411", + "name":"郊区" + }, + { + "code":"140421", + "name":"长治县" + }, + { + "code":"140423", + "name":"襄垣县" + }, + { + "code":"140424", + "name":"屯留县" + }, + { + "code":"140425", + "name":"平顺县" + }, + { + "code":"140426", + "name":"黎城县" + }, + { + "code":"140427", + "name":"壶关县" + }, + { + "code":"140428", + "name":"长子县" + }, + { + "code":"140429", + "name":"武乡县" + }, + { + "code":"140430", + "name":"沁县" + }, + { + "code":"140431", + "name":"沁源县" + }, + { + "code":"140481", + "name":"潞城市" + } + + ] + + }, + { + "code":"140500", + "name":"晋城市", + "sub": + [ + { + "code":"140502", + "name":"城区" + }, + { + "code":"140521", + "name":"沁水县" + }, + { + "code":"140522", + "name":"阳城县" + }, + { + "code":"140524", + "name":"陵川县" + }, + { + "code":"140525", + "name":"泽州县" + }, + { + "code":"140581", + "name":"高平市" + } + + ] + + }, + { + "code":"140600", + "name":"朔州市", + "sub": + [ + { + "code":"140602", + "name":"朔城区" + }, + { + "code":"140603", + "name":"平鲁区" + }, + { + "code":"140621", + "name":"山阴县" + }, + { + "code":"140622", + "name":"应县" + }, + { + "code":"140623", + "name":"右玉县" + }, + { + "code":"140624", + "name":"怀仁县" + } + + ] + + }, + { + "code":"140700", + "name":"晋中市", + "sub": + [ + { + "code":"140702", + "name":"榆次区" + }, + { + "code":"140721", + "name":"榆社县" + }, + { + "code":"140722", + "name":"左权县" + }, + { + "code":"140723", + "name":"和顺县" + }, + { + "code":"140724", + "name":"昔阳县" + }, + { + "code":"140725", + "name":"寿阳县" + }, + { + "code":"140726", + "name":"太谷县" + }, + { + "code":"140727", + "name":"祁县" + }, + { + "code":"140728", + "name":"平遥县" + }, + { + "code":"140729", + "name":"灵石县" + }, + { + "code":"140781", + "name":"介休市" + } + + ] + + }, + { + "code":"140800", + "name":"运城市", + "sub": + [ + { + "code":"140802", + "name":"盐湖区" + }, + { + "code":"140821", + "name":"临猗县" + }, + { + "code":"140822", + "name":"万荣县" + }, + { + "code":"140823", + "name":"闻喜县" + }, + { + "code":"140824", + "name":"稷山县" + }, + { + "code":"140825", + "name":"新绛县" + }, + { + "code":"140826", + "name":"绛县" + }, + { + "code":"140827", + "name":"垣曲县" + }, + { + "code":"140828", + "name":"夏县" + }, + { + "code":"140829", + "name":"平陆县" + }, + { + "code":"140830", + "name":"芮城县" + }, + { + "code":"140881", + "name":"永济市" + }, + { + "code":"140882", + "name":"河津市" + } + + ] + + }, + { + "code":"140900", + "name":"忻州市", + "sub": + [ + { + "code":"140902", + "name":"忻府区" + }, + { + "code":"140921", + "name":"定襄县" + }, + { + "code":"140922", + "name":"五台县" + }, + { + "code":"140923", + "name":"代县" + }, + { + "code":"140924", + "name":"繁峙县" + }, + { + "code":"140925", + "name":"宁武县" + }, + { + "code":"140926", + "name":"静乐县" + }, + { + "code":"140927", + "name":"神池县" + }, + { + "code":"140928", + "name":"五寨县" + }, + { + "code":"140929", + "name":"岢岚县" + }, + { + "code":"140930", + "name":"河曲县" + }, + { + "code":"140931", + "name":"保德县" + }, + { + "code":"140932", + "name":"偏关县" + }, + { + "code":"140981", + "name":"原平市" + } + + ] + + }, + { + "code":"141000", + "name":"临汾市", + "sub": + [ + { + "code":"141002", + "name":"尧都区" + }, + { + "code":"141021", + "name":"曲沃县" + }, + { + "code":"141022", + "name":"翼城县" + }, + { + "code":"141023", + "name":"襄汾县" + }, + { + "code":"141024", + "name":"洪洞县" + }, + { + "code":"141025", + "name":"古县" + }, + { + "code":"141026", + "name":"安泽县" + }, + { + "code":"141027", + "name":"浮山县" + }, + { + "code":"141028", + "name":"吉县" + }, + { + "code":"141029", + "name":"乡宁县" + }, + { + "code":"141030", + "name":"大宁县" + }, + { + "code":"141031", + "name":"隰县" + }, + { + "code":"141032", + "name":"永和县" + }, + { + "code":"141033", + "name":"蒲县" + }, + { + "code":"141034", + "name":"汾西县" + }, + { + "code":"141081", + "name":"侯马市" + }, + { + "code":"141082", + "name":"霍州市" + } + + ] + + }, + { + "code":"141100", + "name":"吕梁市", + "sub": + [ + { + "code":"141102", + "name":"离石区" + }, + { + "code":"141121", + "name":"文水县" + }, + { + "code":"141122", + "name":"交城县" + }, + { + "code":"141123", + "name":"兴县" + }, + { + "code":"141124", + "name":"临县" + }, + { + "code":"141125", + "name":"柳林县" + }, + { + "code":"141126", + "name":"石楼县" + }, + { + "code":"141127", + "name":"岚县" + }, + { + "code":"141128", + "name":"方山县" + }, + { + "code":"141129", + "name":"中阳县" + }, + { + "code":"141130", + "name":"交口县" + }, + { + "code":"141181", + "name":"孝义市" + }, + { + "code":"141182", + "name":"汾阳市" + } + + ] + + } + + ] + + }, + { + "code":"150000", + "name":"内蒙古自治区", + "sub": + [ + { + "code":"150100", + "name":"呼和浩特市", + "sub": + [ + { + "code":"150102", + "name":"新城区" + }, + { + "code":"150103", + "name":"回民区" + }, + { + "code":"150104", + "name":"玉泉区" + }, + { + "code":"150105", + "name":"赛罕区" + }, + { + "code":"150121", + "name":"土默特左旗" + }, + { + "code":"150122", + "name":"托克托县" + }, + { + "code":"150123", + "name":"和林格尔县" + }, + { + "code":"150124", + "name":"清水河县" + }, + { + "code":"150125", + "name":"武川县" + } + + ] + + }, + { + "code":"150200", + "name":"包头市", + "sub": + [ + { + "code":"150202", + "name":"东河区" + }, + { + "code":"150203", + "name":"昆都仑区" + }, + { + "code":"150204", + "name":"青山区" + }, + { + "code":"150205", + "name":"石拐区" + }, + { + "code":"150206", + "name":"白云鄂博矿区" + }, + { + "code":"150207", + "name":"九原区" + }, + { + "code":"150221", + "name":"土默特右旗" + }, + { + "code":"150222", + "name":"固阳县" + }, + { + "code":"150223", + "name":"达尔罕茂明安联合旗" + } + + ] + + }, + { + "code":"150300", + "name":"乌海市", + "sub": + [ + { + "code":"150302", + "name":"海勃湾区" + }, + { + "code":"150303", + "name":"海南区" + }, + { + "code":"150304", + "name":"乌达区" + } + + ] + + }, + { + "code":"150400", + "name":"赤峰市", + "sub": + [ + { + "code":"150402", + "name":"红山区" + }, + { + "code":"150403", + "name":"元宝山区" + }, + { + "code":"150404", + "name":"松山区" + }, + { + "code":"150421", + "name":"阿鲁科尔沁旗" + }, + { + "code":"150422", + "name":"巴林左旗" + }, + { + "code":"150423", + "name":"巴林右旗" + }, + { + "code":"150424", + "name":"林西县" + }, + { + "code":"150425", + "name":"克什克腾旗" + }, + { + "code":"150426", + "name":"翁牛特旗" + }, + { + "code":"150428", + "name":"喀喇沁旗" + }, + { + "code":"150429", + "name":"宁城县" + }, + { + "code":"150430", + "name":"敖汉旗" + } + + ] + + }, + { + "code":"150500", + "name":"通辽市", + "sub": + [ + { + "code":"150502", + "name":"科尔沁区" + }, + { + "code":"150521", + "name":"科尔沁左翼中旗" + }, + { + "code":"150522", + "name":"科尔沁左翼后旗" + }, + { + "code":"150523", + "name":"开鲁县" + }, + { + "code":"150524", + "name":"库伦旗" + }, + { + "code":"150525", + "name":"奈曼旗" + }, + { + "code":"150526", + "name":"扎鲁特旗" + }, + { + "code":"150581", + "name":"霍林郭勒市" + } + + ] + + }, + { + "code":"150600", + "name":"鄂尔多斯市", + "sub": + [ + { + "code":"150602", + "name":"东胜区" + }, + { + "code":"150603", + "name":"康巴什区" + }, + { + "code":"150621", + "name":"达拉特旗" + }, + { + "code":"150622", + "name":"准格尔旗" + }, + { + "code":"150623", + "name":"鄂托克前旗" + }, + { + "code":"150624", + "name":"鄂托克旗" + }, + { + "code":"150625", + "name":"杭锦旗" + }, + { + "code":"150626", + "name":"乌审旗" + }, + { + "code":"150627", + "name":"伊金霍洛旗" + } + + ] + + }, + { + "code":"150700", + "name":"呼伦贝尔市", + "sub": + [ + { + "code":"150702", + "name":"海拉尔区" + }, + { + "code":"150703", + "name":"扎赉诺尔区" + }, + { + "code":"150721", + "name":"阿荣旗" + }, + { + "code":"150722", + "name":"莫力达瓦达斡尔族自治旗" + }, + { + "code":"150723", + "name":"鄂伦春自治旗" + }, + { + "code":"150724", + "name":"鄂温克族自治旗" + }, + { + "code":"150725", + "name":"陈巴尔虎旗" + }, + { + "code":"150726", + "name":"新巴尔虎左旗" + }, + { + "code":"150727", + "name":"新巴尔虎右旗" + }, + { + "code":"150781", + "name":"满洲里市" + }, + { + "code":"150782", + "name":"牙克石市" + }, + { + "code":"150783", + "name":"扎兰屯市" + }, + { + "code":"150784", + "name":"额尔古纳市" + }, + { + "code":"150785", + "name":"根河市" + } + + ] + + }, + { + "code":"150800", + "name":"巴彦淖尔市", + "sub": + [ + { + "code":"150802", + "name":"临河区" + }, + { + "code":"150821", + "name":"五原县" + }, + { + "code":"150822", + "name":"磴口县" + }, + { + "code":"150823", + "name":"乌拉特前旗" + }, + { + "code":"150824", + "name":"乌拉特中旗" + }, + { + "code":"150825", + "name":"乌拉特后旗" + }, + { + "code":"150826", + "name":"杭锦后旗" + } + + ] + + }, + { + "code":"150900", + "name":"乌兰察布市", + "sub": + [ + { + "code":"150902", + "name":"集宁区" + }, + { + "code":"150921", + "name":"卓资县" + }, + { + "code":"150922", + "name":"化德县" + }, + { + "code":"150923", + "name":"商都县" + }, + { + "code":"150924", + "name":"兴和县" + }, + { + "code":"150925", + "name":"凉城县" + }, + { + "code":"150926", + "name":"察哈尔右翼前旗" + }, + { + "code":"150927", + "name":"察哈尔右翼中旗" + }, + { + "code":"150928", + "name":"察哈尔右翼后旗" + }, + { + "code":"150929", + "name":"四子王旗" + }, + { + "code":"150981", + "name":"丰镇市" + } + + ] + + }, + { + "code":"152200", + "name":"兴安盟", + "sub": + [ + { + "code":"152201", + "name":"乌兰浩特市" + }, + { + "code":"152202", + "name":"阿尔山市" + }, + { + "code":"152221", + "name":"科尔沁右翼前旗" + }, + { + "code":"152222", + "name":"科尔沁右翼中旗" + }, + { + "code":"152223", + "name":"扎赉特旗" + }, + { + "code":"152224", + "name":"突泉县" + } + + ] + + }, + { + "code":"152500", + "name":"锡林郭勒盟", + "sub": + [ + { + "code":"152501", + "name":"二连浩特市" + }, + { + "code":"152502", + "name":"锡林浩特市" + }, + { + "code":"152522", + "name":"阿巴嘎旗" + }, + { + "code":"152523", + "name":"苏尼特左旗" + }, + { + "code":"152524", + "name":"苏尼特右旗" + }, + { + "code":"152525", + "name":"东乌珠穆沁旗" + }, + { + "code":"152526", + "name":"西乌珠穆沁旗" + }, + { + "code":"152527", + "name":"太仆寺旗" + }, + { + "code":"152528", + "name":"镶黄旗" + }, + { + "code":"152529", + "name":"正镶白旗" + }, + { + "code":"152530", + "name":"正蓝旗" + }, + { + "code":"152531", + "name":"多伦县" + } + + ] + + }, + { + "code":"152900", + "name":"阿拉善盟", + "sub": + [ + { + "code":"152921", + "name":"阿拉善左旗" + }, + { + "code":"152922", + "name":"阿拉善右旗" + }, + { + "code":"152923", + "name":"额济纳旗" + } + + ] + + } + + ] + + }, + { + "code":"210000", + "name":"辽宁省", + "sub": + [ + { + "code":"210100", + "name":"沈阳市", + "sub": + [ + { + "code":"210102", + "name":"和平区" + }, + { + "code":"210103", + "name":"沈河区" + }, + { + "code":"210104", + "name":"大东区" + }, + { + "code":"210105", + "name":"皇姑区" + }, + { + "code":"210106", + "name":"铁西区" + }, + { + "code":"210111", + "name":"苏家屯区" + }, + { + "code":"210112", + "name":"浑南区" + }, + { + "code":"210113", + "name":"沈北新区" + }, + { + "code":"210114", + "name":"于洪区" + }, + { + "code":"210115", + "name":"辽中区" + }, + { + "code":"210123", + "name":"康平县" + }, + { + "code":"210124", + "name":"法库县" + }, + { + "code":"210181", + "name":"新民市" + } + + ] + + }, + { + "code":"210200", + "name":"大连市", + "sub": + [ + { + "code":"210202", + "name":"中山区" + }, + { + "code":"210203", + "name":"西岗区" + }, + { + "code":"210204", + "name":"沙河口区" + }, + { + "code":"210211", + "name":"甘井子区" + }, + { + "code":"210212", + "name":"旅顺口区" + }, + { + "code":"210213", + "name":"金州区" + }, + { + "code":"210214", + "name":"普兰店区" + }, + { + "code":"210224", + "name":"长海县" + }, + { + "code":"210281", + "name":"瓦房店市" + }, + { + "code":"210283", + "name":"庄河市" + } + + ] + + }, + { + "code":"210300", + "name":"鞍山市", + "sub": + [ + { + "code":"210302", + "name":"铁东区" + }, + { + "code":"210303", + "name":"铁西区" + }, + { + "code":"210304", + "name":"立山区" + }, + { + "code":"210311", + "name":"千山区" + }, + { + "code":"210321", + "name":"台安县" + }, + { + "code":"210323", + "name":"岫岩满族自治县" + }, + { + "code":"210381", + "name":"海城市" + } + + ] + + }, + { + "code":"210400", + "name":"抚顺市", + "sub": + [ + { + "code":"210402", + "name":"新抚区" + }, + { + "code":"210403", + "name":"东洲区" + }, + { + "code":"210404", + "name":"望花区" + }, + { + "code":"210411", + "name":"顺城区" + }, + { + "code":"210421", + "name":"抚顺县" + }, + { + "code":"210422", + "name":"新宾满族自治县" + }, + { + "code":"210423", + "name":"清原满族自治县" + } + + ] + + }, + { + "code":"210500", + "name":"本溪市", + "sub": + [ + { + "code":"210502", + "name":"平山区" + }, + { + "code":"210503", + "name":"溪湖区" + }, + { + "code":"210504", + "name":"明山区" + }, + { + "code":"210505", + "name":"南芬区" + }, + { + "code":"210521", + "name":"本溪满族自治县" + }, + { + "code":"210522", + "name":"桓仁满族自治县" + } + + ] + + }, + { + "code":"210600", + "name":"丹东市", + "sub": + [ + { + "code":"210602", + "name":"元宝区" + }, + { + "code":"210603", + "name":"振兴区" + }, + { + "code":"210604", + "name":"振安区" + }, + { + "code":"210624", + "name":"宽甸满族自治县" + }, + { + "code":"210681", + "name":"东港市" + }, + { + "code":"210682", + "name":"凤城市" + } + + ] + + }, + { + "code":"210700", + "name":"锦州市", + "sub": + [ + { + "code":"210702", + "name":"古塔区" + }, + { + "code":"210703", + "name":"凌河区" + }, + { + "code":"210711", + "name":"太和区" + }, + { + "code":"210726", + "name":"黑山县" + }, + { + "code":"210727", + "name":"义县" + }, + { + "code":"210781", + "name":"凌海市" + }, + { + "code":"210782", + "name":"北镇市" + } + + ] + + }, + { + "code":"210800", + "name":"营口市", + "sub": + [ + { + "code":"210802", + "name":"站前区" + }, + { + "code":"210803", + "name":"西市区" + }, + { + "code":"210804", + "name":"鲅鱼圈区" + }, + { + "code":"210811", + "name":"老边区" + }, + { + "code":"210881", + "name":"盖州市" + }, + { + "code":"210882", + "name":"大石桥市" + } + + ] + + }, + { + "code":"210900", + "name":"阜新市", + "sub": + [ + { + "code":"210902", + "name":"海州区" + }, + { + "code":"210903", + "name":"新邱区" + }, + { + "code":"210904", + "name":"太平区" + }, + { + "code":"210905", + "name":"清河门区" + }, + { + "code":"210911", + "name":"细河区" + }, + { + "code":"210921", + "name":"阜新蒙古族自治县" + }, + { + "code":"210922", + "name":"彰武县" + } + + ] + + }, + { + "code":"211000", + "name":"辽阳市", + "sub": + [ + { + "code":"211002", + "name":"白塔区" + }, + { + "code":"211003", + "name":"文圣区" + }, + { + "code":"211004", + "name":"宏伟区" + }, + { + "code":"211005", + "name":"弓长岭区" + }, + { + "code":"211011", + "name":"太子河区" + }, + { + "code":"211021", + "name":"辽阳县" + }, + { + "code":"211081", + "name":"灯塔市" + } + + ] + + }, + { + "code":"211100", + "name":"盘锦市", + "sub": + [ + { + "code":"211102", + "name":"双台子区" + }, + { + "code":"211103", + "name":"兴隆台区" + }, + { + "code":"211104", + "name":"大洼区" + }, + { + "code":"211122", + "name":"盘山县" + } + + ] + + }, + { + "code":"211200", + "name":"铁岭市", + "sub": + [ + { + "code":"211202", + "name":"银州区" + }, + { + "code":"211204", + "name":"清河区" + }, + { + "code":"211221", + "name":"铁岭县" + }, + { + "code":"211223", + "name":"西丰县" + }, + { + "code":"211224", + "name":"昌图县" + }, + { + "code":"211281", + "name":"调兵山市" + }, + { + "code":"211282", + "name":"开原市" + } + + ] + + }, + { + "code":"211300", + "name":"朝阳市", + "sub": + [ + { + "code":"211302", + "name":"双塔区" + }, + { + "code":"211303", + "name":"龙城区" + }, + { + "code":"211321", + "name":"朝阳县" + }, + { + "code":"211322", + "name":"建平县" + }, + { + "code":"211324", + "name":"喀喇沁左翼蒙古族自治县" + }, + { + "code":"211381", + "name":"北票市" + }, + { + "code":"211382", + "name":"凌源市" + } + + ] + + }, + { + "code":"211400", + "name":"葫芦岛市", + "sub": + [ + { + "code":"211402", + "name":"连山区" + }, + { + "code":"211403", + "name":"龙港区" + }, + { + "code":"211404", + "name":"南票区" + }, + { + "code":"211421", + "name":"绥中县" + }, + { + "code":"211422", + "name":"建昌县" + }, + { + "code":"211481", + "name":"兴城市" + } + + ] + + } + + ] + + }, + { + "code":"220000", + "name":"吉林省", + "sub": + [ + { + "code":"220100", + "name":"长春市", + "sub": + [ + { + "code":"220102", + "name":"南关区" + }, + { + "code":"220103", + "name":"宽城区" + }, + { + "code":"220104", + "name":"朝阳区" + }, + { + "code":"220105", + "name":"二道区" + }, + { + "code":"220106", + "name":"绿园区" + }, + { + "code":"220112", + "name":"双阳区" + }, + { + "code":"220113", + "name":"九台区" + }, + { + "code":"220122", + "name":"农安县" + }, + { + "code":"220182", + "name":"榆树市" + }, + { + "code":"220183", + "name":"德惠市" + } + + ] + + }, + { + "code":"220200", + "name":"吉林市", + "sub": + [ + { + "code":"220202", + "name":"昌邑区" + }, + { + "code":"220203", + "name":"龙潭区" + }, + { + "code":"220204", + "name":"船营区" + }, + { + "code":"220211", + "name":"丰满区" + }, + { + "code":"220221", + "name":"永吉县" + }, + { + "code":"220281", + "name":"蛟河市" + }, + { + "code":"220282", + "name":"桦甸市" + }, + { + "code":"220283", + "name":"舒兰市" + }, + { + "code":"220284", + "name":"磐石市" + } + + ] + + }, + { + "code":"220300", + "name":"四平市", + "sub": + [ + { + "code":"220302", + "name":"铁西区" + }, + { + "code":"220303", + "name":"铁东区" + }, + { + "code":"220322", + "name":"梨树县" + }, + { + "code":"220323", + "name":"伊通满族自治县" + }, + { + "code":"220381", + "name":"公主岭市" + }, + { + "code":"220382", + "name":"双辽市" + } + + ] + + }, + { + "code":"220400", + "name":"辽源市", + "sub": + [ + { + "code":"220402", + "name":"龙山区" + }, + { + "code":"220403", + "name":"西安区" + }, + { + "code":"220421", + "name":"东丰县" + }, + { + "code":"220422", + "name":"东辽县" + } + + ] + + }, + { + "code":"220500", + "name":"通化市", + "sub": + [ + { + "code":"220502", + "name":"东昌区" + }, + { + "code":"220503", + "name":"二道江区" + }, + { + "code":"220521", + "name":"通化县" + }, + { + "code":"220523", + "name":"辉南县" + }, + { + "code":"220524", + "name":"柳河县" + }, + { + "code":"220581", + "name":"梅河口市" + }, + { + "code":"220582", + "name":"集安市" + } + + ] + + }, + { + "code":"220600", + "name":"白山市", + "sub": + [ + { + "code":"220602", + "name":"浑江区" + }, + { + "code":"220605", + "name":"江源区" + }, + { + "code":"220621", + "name":"抚松县" + }, + { + "code":"220622", + "name":"靖宇县" + }, + { + "code":"220623", + "name":"长白朝鲜族自治县" + }, + { + "code":"220681", + "name":"临江市" + } + + ] + + }, + { + "code":"220700", + "name":"松原市", + "sub": + [ + { + "code":"220702", + "name":"宁江区" + }, + { + "code":"220721", + "name":"前郭尔罗斯蒙古族自治县" + }, + { + "code":"220722", + "name":"长岭县" + }, + { + "code":"220723", + "name":"乾安县" + }, + { + "code":"220781", + "name":"扶余市" + } + + ] + + }, + { + "code":"220800", + "name":"白城市", + "sub": + [ + { + "code":"220802", + "name":"洮北区" + }, + { + "code":"220821", + "name":"镇赉县" + }, + { + "code":"220822", + "name":"通榆县" + }, + { + "code":"220881", + "name":"洮南市" + }, + { + "code":"220882", + "name":"大安市" + } + + ] + + }, + { + "code":"222400", + "name":"延边朝鲜族自治州", + "sub": + [ + { + "code":"222401", + "name":"延吉市" + }, + { + "code":"222402", + "name":"图们市" + }, + { + "code":"222403", + "name":"敦化市" + }, + { + "code":"222404", + "name":"珲春市" + }, + { + "code":"222405", + "name":"龙井市" + }, + { + "code":"222406", + "name":"和龙市" + }, + { + "code":"222424", + "name":"汪清县" + }, + { + "code":"222426", + "name":"安图县" + } + + ] + + } + + ] + + }, + { + "code":"230000", + "name":"黑龙江省", + "sub": + [ + { + "code":"230100", + "name":"哈尔滨市", + "sub": + [ + { + "code":"230102", + "name":"道里区" + }, + { + "code":"230103", + "name":"南岗区" + }, + { + "code":"230104", + "name":"道外区" + }, + { + "code":"230108", + "name":"平房区" + }, + { + "code":"230109", + "name":"松北区" + }, + { + "code":"230110", + "name":"香坊区" + }, + { + "code":"230111", + "name":"呼兰区" + }, + { + "code":"230112", + "name":"阿城区" + }, + { + "code":"230113", + "name":"双城区" + }, + { + "code":"230123", + "name":"依兰县" + }, + { + "code":"230124", + "name":"方正县" + }, + { + "code":"230125", + "name":"宾县" + }, + { + "code":"230126", + "name":"巴彦县" + }, + { + "code":"230127", + "name":"木兰县" + }, + { + "code":"230128", + "name":"通河县" + }, + { + "code":"230129", + "name":"延寿县" + }, + { + "code":"230183", + "name":"尚志市" + }, + { + "code":"230184", + "name":"五常市" + } + + ] + + }, + { + "code":"230200", + "name":"齐齐哈尔市", + "sub": + [ + { + "code":"230202", + "name":"龙沙区" + }, + { + "code":"230203", + "name":"建华区" + }, + { + "code":"230204", + "name":"铁锋区" + }, + { + "code":"230205", + "name":"昂昂溪区" + }, + { + "code":"230206", + "name":"富拉尔基区" + }, + { + "code":"230207", + "name":"碾子山区" + }, + { + "code":"230208", + "name":"梅里斯达斡尔族区" + }, + { + "code":"230221", + "name":"龙江县" + }, + { + "code":"230223", + "name":"依安县" + }, + { + "code":"230224", + "name":"泰来县" + }, + { + "code":"230225", + "name":"甘南县" + }, + { + "code":"230227", + "name":"富裕县" + }, + { + "code":"230229", + "name":"克山县" + }, + { + "code":"230230", + "name":"克东县" + }, + { + "code":"230231", + "name":"拜泉县" + }, + { + "code":"230281", + "name":"讷河市" + } + + ] + + }, + { + "code":"230300", + "name":"鸡西市", + "sub": + [ + { + "code":"230302", + "name":"鸡冠区" + }, + { + "code":"230303", + "name":"恒山区" + }, + { + "code":"230304", + "name":"滴道区" + }, + { + "code":"230305", + "name":"梨树区" + }, + { + "code":"230306", + "name":"城子河区" + }, + { + "code":"230307", + "name":"麻山区" + }, + { + "code":"230321", + "name":"鸡东县" + }, + { + "code":"230381", + "name":"虎林市" + }, + { + "code":"230382", + "name":"密山市" + } + + ] + + }, + { + "code":"230400", + "name":"鹤岗市", + "sub": + [ + { + "code":"230402", + "name":"向阳区" + }, + { + "code":"230403", + "name":"工农区" + }, + { + "code":"230404", + "name":"南山区" + }, + { + "code":"230405", + "name":"兴安区" + }, + { + "code":"230406", + "name":"东山区" + }, + { + "code":"230407", + "name":"兴山区" + }, + { + "code":"230421", + "name":"萝北县" + }, + { + "code":"230422", + "name":"绥滨县" + } + + ] + + }, + { + "code":"230500", + "name":"双鸭山市", + "sub": + [ + { + "code":"230502", + "name":"尖山区" + }, + { + "code":"230503", + "name":"岭东区" + }, + { + "code":"230505", + "name":"四方台区" + }, + { + "code":"230506", + "name":"宝山区" + }, + { + "code":"230521", + "name":"集贤县" + }, + { + "code":"230522", + "name":"友谊县" + }, + { + "code":"230523", + "name":"宝清县" + }, + { + "code":"230524", + "name":"饶河县" + } + + ] + + }, + { + "code":"230600", + "name":"大庆市", + "sub": + [ + { + "code":"230602", + "name":"萨尔图区" + }, + { + "code":"230603", + "name":"龙凤区" + }, + { + "code":"230604", + "name":"让胡路区" + }, + { + "code":"230605", + "name":"红岗区" + }, + { + "code":"230606", + "name":"大同区" + }, + { + "code":"230621", + "name":"肇州县" + }, + { + "code":"230622", + "name":"肇源县" + }, + { + "code":"230623", + "name":"林甸县" + }, + { + "code":"230624", + "name":"杜尔伯特蒙古族自治县" + } + + ] + + }, + { + "code":"230700", + "name":"伊春市", + "sub": + [ + { + "code":"230702", + "name":"伊春区" + }, + { + "code":"230703", + "name":"南岔区" + }, + { + "code":"230704", + "name":"友好区" + }, + { + "code":"230705", + "name":"西林区" + }, + { + "code":"230706", + "name":"翠峦区" + }, + { + "code":"230707", + "name":"新青区" + }, + { + "code":"230708", + "name":"美溪区" + }, + { + "code":"230709", + "name":"金山屯区" + }, + { + "code":"230710", + "name":"五营区" + }, + { + "code":"230711", + "name":"乌马河区" + }, + { + "code":"230712", + "name":"汤旺河区" + }, + { + "code":"230713", + "name":"带岭区" + }, + { + "code":"230714", + "name":"乌伊岭区" + }, + { + "code":"230715", + "name":"红星区" + }, + { + "code":"230716", + "name":"上甘岭区" + }, + { + "code":"230722", + "name":"嘉荫县" + }, + { + "code":"230781", + "name":"铁力市" + } + + ] + + }, + { + "code":"230800", + "name":"佳木斯市", + "sub": + [ + { + "code":"230803", + "name":"向阳区" + }, + { + "code":"230804", + "name":"前进区" + }, + { + "code":"230805", + "name":"东风区" + }, + { + "code":"230811", + "name":"郊区" + }, + { + "code":"230822", + "name":"桦南县" + }, + { + "code":"230826", + "name":"桦川县" + }, + { + "code":"230828", + "name":"汤原县" + }, + { + "code":"230881", + "name":"同江市" + }, + { + "code":"230882", + "name":"富锦市" + }, + { + "code":"230883", + "name":"抚远市" + } + + ] + + }, + { + "code":"230900", + "name":"七台河市", + "sub": + [ + { + "code":"230902", + "name":"新兴区" + }, + { + "code":"230903", + "name":"桃山区" + }, + { + "code":"230904", + "name":"茄子河区" + }, + { + "code":"230921", + "name":"勃利县" + } + + ] + + }, + { + "code":"231000", + "name":"牡丹江市", + "sub": + [ + { + "code":"231002", + "name":"东安区" + }, + { + "code":"231003", + "name":"阳明区" + }, + { + "code":"231004", + "name":"爱民区" + }, + { + "code":"231005", + "name":"西安区" + }, + { + "code":"231025", + "name":"林口县" + }, + { + "code":"231081", + "name":"绥芬河市" + }, + { + "code":"231083", + "name":"海林市" + }, + { + "code":"231084", + "name":"宁安市" + }, + { + "code":"231085", + "name":"穆棱市" + }, + { + "code":"231086", + "name":"东宁市" + } + + ] + + }, + { + "code":"231100", + "name":"黑河市", + "sub": + [ + { + "code":"231102", + "name":"爱辉区" + }, + { + "code":"231121", + "name":"嫩江县" + }, + { + "code":"231123", + "name":"逊克县" + }, + { + "code":"231124", + "name":"孙吴县" + }, + { + "code":"231181", + "name":"北安市" + }, + { + "code":"231182", + "name":"五大连池市" + } + + ] + + }, + { + "code":"231200", + "name":"绥化市", + "sub": + [ + { + "code":"231202", + "name":"北林区" + }, + { + "code":"231221", + "name":"望奎县" + }, + { + "code":"231222", + "name":"兰西县" + }, + { + "code":"231223", + "name":"青冈县" + }, + { + "code":"231224", + "name":"庆安县" + }, + { + "code":"231225", + "name":"明水县" + }, + { + "code":"231226", + "name":"绥棱县" + }, + { + "code":"231281", + "name":"安达市" + }, + { + "code":"231282", + "name":"肇东市" + }, + { + "code":"231283", + "name":"海伦市" + } + + ] + + }, + { + "code":"232700", + "name":"大兴安岭地区", + "sub": + [ + { + "code":"232721", + "name":"呼玛县" + }, + { + "code":"232722", + "name":"塔河县" + }, + { + "code":"232723", + "name":"漠河县" + } + + ] + + } + + ] + + }, + { + "code":"310000", + "name":"上海市", + "sub": + [ + { + "code":"310000", + "name":"上海市", + "sub": + [ + { + "code":"310101", + "name":"黄浦区" + }, + { + "code":"310104", + "name":"徐汇区" + }, + { + "code":"310105", + "name":"长宁区" + }, + { + "code":"310106", + "name":"静安区" + }, + { + "code":"310107", + "name":"普陀区" + }, + { + "code":"310109", + "name":"虹口区" + }, + { + "code":"310110", + "name":"杨浦区" + }, + { + "code":"310112", + "name":"闵行区" + }, + { + "code":"310113", + "name":"宝山区" + }, + { + "code":"310114", + "name":"嘉定区" + }, + { + "code":"310115", + "name":"浦东新区" + }, + { + "code":"310116", + "name":"金山区" + }, + { + "code":"310117", + "name":"松江区" + }, + { + "code":"310118", + "name":"青浦区" + }, + { + "code":"310120", + "name":"奉贤区" + }, + { + "code":"310151", + "name":"崇明区" + } + + ] + + } + + ] + + }, + { + "code":"320000", + "name":"江苏省", + "sub": + [ + { + "code":"320100", + "name":"南京市", + "sub": + [ + { + "code":"320102", + "name":"玄武区" + }, + { + "code":"320104", + "name":"秦淮区" + }, + { + "code":"320105", + "name":"建邺区" + }, + { + "code":"320106", + "name":"鼓楼区" + }, + { + "code":"320111", + "name":"浦口区" + }, + { + "code":"320113", + "name":"栖霞区" + }, + { + "code":"320114", + "name":"雨花台区" + }, + { + "code":"320115", + "name":"江宁区" + }, + { + "code":"320116", + "name":"六合区" + }, + { + "code":"320117", + "name":"溧水区" + }, + { + "code":"320118", + "name":"高淳区" + } + + ] + + }, + { + "code":"320200", + "name":"无锡市", + "sub": + [ + { + "code":"320205", + "name":"锡山区" + }, + { + "code":"320206", + "name":"惠山区" + }, + { + "code":"320211", + "name":"滨湖区" + }, + { + "code":"320213", + "name":"梁溪区" + }, + { + "code":"320214", + "name":"新吴区" + }, + { + "code":"320281", + "name":"江阴市" + }, + { + "code":"320282", + "name":"宜兴市" + } + + ] + + }, + { + "code":"320300", + "name":"徐州市", + "sub": + [ + { + "code":"320302", + "name":"鼓楼区" + }, + { + "code":"320303", + "name":"云龙区" + }, + { + "code":"320305", + "name":"贾汪区" + }, + { + "code":"320311", + "name":"泉山区" + }, + { + "code":"320312", + "name":"铜山区" + }, + { + "code":"320321", + "name":"丰县" + }, + { + "code":"320322", + "name":"沛县" + }, + { + "code":"320324", + "name":"睢宁县" + }, + { + "code":"320381", + "name":"新沂市" + }, + { + "code":"320382", + "name":"邳州市" + } + + ] + + }, + { + "code":"320400", + "name":"常州市", + "sub": + [ + { + "code":"320402", + "name":"天宁区" + }, + { + "code":"320404", + "name":"钟楼区" + }, + { + "code":"320411", + "name":"新北区" + }, + { + "code":"320412", + "name":"武进区" + }, + { + "code":"320413", + "name":"金坛区" + }, + { + "code":"320481", + "name":"溧阳市" + } + + ] + + }, + { + "code":"320500", + "name":"苏州市", + "sub": + [ + { + "code":"320505", + "name":"虎丘区" + }, + { + "code":"320506", + "name":"吴中区" + }, + { + "code":"320507", + "name":"相城区" + }, + { + "code":"320508", + "name":"姑苏区" + }, + { + "code":"320509", + "name":"吴江区" + }, + { + "code":"320581", + "name":"常熟市" + }, + { + "code":"320582", + "name":"张家港市" + }, + { + "code":"320583", + "name":"昆山市" + }, + { + "code":"320585", + "name":"太仓市" + } + + ] + + }, + { + "code":"320600", + "name":"南通市", + "sub": + [ + { + "code":"320602", + "name":"崇川区" + }, + { + "code":"320611", + "name":"港闸区" + }, + { + "code":"320612", + "name":"通州区" + }, + { + "code":"320621", + "name":"海安县" + }, + { + "code":"320623", + "name":"如东县" + }, + { + "code":"320681", + "name":"启东市" + }, + { + "code":"320682", + "name":"如皋市" + }, + { + "code":"320684", + "name":"海门市" + } + + ] + + }, + { + "code":"320700", + "name":"连云港市", + "sub": + [ + { + "code":"320703", + "name":"连云区" + }, + { + "code":"320706", + "name":"海州区" + }, + { + "code":"320707", + "name":"赣榆区" + }, + { + "code":"320722", + "name":"东海县" + }, + { + "code":"320723", + "name":"灌云县" + }, + { + "code":"320724", + "name":"灌南县" + } + + ] + + }, + { + "code":"320800", + "name":"淮安市", + "sub": + [ + { + "code":"320803", + "name":"淮安区" + }, + { + "code":"320804", + "name":"淮阴区" + }, + { + "code":"320812", + "name":"清江浦区" + }, + { + "code":"320813", + "name":"洪泽区" + }, + { + "code":"320826", + "name":"涟水县" + }, + { + "code":"320830", + "name":"盱眙县" + }, + { + "code":"320831", + "name":"金湖县" + } + + ] + + }, + { + "code":"320900", + "name":"盐城市", + "sub": + [ + { + "code":"320902", + "name":"亭湖区" + }, + { + "code":"320903", + "name":"盐都区" + }, + { + "code":"320904", + "name":"大丰区" + }, + { + "code":"320921", + "name":"响水县" + }, + { + "code":"320922", + "name":"滨海县" + }, + { + "code":"320923", + "name":"阜宁县" + }, + { + "code":"320924", + "name":"射阳县" + }, + { + "code":"320925", + "name":"建湖县" + }, + { + "code":"320981", + "name":"东台市" + } + + ] + + }, + { + "code":"321000", + "name":"扬州市", + "sub": + [ + { + "code":"321002", + "name":"广陵区" + }, + { + "code":"321003", + "name":"邗江区" + }, + { + "code":"321012", + "name":"江都区" + }, + { + "code":"321023", + "name":"宝应县" + }, + { + "code":"321081", + "name":"仪征市" + }, + { + "code":"321084", + "name":"高邮市" + } + + ] + + }, + { + "code":"321100", + "name":"镇江市", + "sub": + [ + { + "code":"321102", + "name":"京口区" + }, + { + "code":"321111", + "name":"润州区" + }, + { + "code":"321112", + "name":"丹徒区" + }, + { + "code":"321181", + "name":"丹阳市" + }, + { + "code":"321182", + "name":"扬中市" + }, + { + "code":"321183", + "name":"句容市" + } + + ] + + }, + { + "code":"321200", + "name":"泰州市", + "sub": + [ + { + "code":"321202", + "name":"海陵区" + }, + { + "code":"321203", + "name":"高港区" + }, + { + "code":"321204", + "name":"姜堰区" + }, + { + "code":"321281", + "name":"兴化市" + }, + { + "code":"321282", + "name":"靖江市" + }, + { + "code":"321283", + "name":"泰兴市" + } + + ] + + }, + { + "code":"321300", + "name":"宿迁市", + "sub": + [ + { + "code":"321302", + "name":"宿城区" + }, + { + "code":"321311", + "name":"宿豫区" + }, + { + "code":"321322", + "name":"沭阳县" + }, + { + "code":"321323", + "name":"泗阳县" + }, + { + "code":"321324", + "name":"泗洪县" + } + + ] + + } + + ] + + }, + { + "code":"330000", + "name":"浙江省", + "sub": + [ + { + "code":"330100", + "name":"杭州市", + "sub": + [ + { + "code":"330102", + "name":"上城区" + }, + { + "code":"330103", + "name":"下城区" + }, + { + "code":"330104", + "name":"江干区" + }, + { + "code":"330105", + "name":"拱墅区" + }, + { + "code":"330106", + "name":"西湖区" + }, + { + "code":"330108", + "name":"滨江区" + }, + { + "code":"330109", + "name":"萧山区" + }, + { + "code":"330110", + "name":"余杭区" + }, + { + "code":"330111", + "name":"富阳区" + }, + { + "code":"330112", + "name":"临安区" + }, + { + "code":"330122", + "name":"桐庐县" + }, + { + "code":"330127", + "name":"淳安县" + }, + { + "code":"330182", + "name":"建德市" + } + + ] + + }, + { + "code":"330200", + "name":"宁波市", + "sub": + [ + { + "code":"330203", + "name":"海曙区" + }, + { + "code":"330205", + "name":"江北区" + }, + { + "code":"330206", + "name":"北仑区" + }, + { + "code":"330211", + "name":"镇海区" + }, + { + "code":"330212", + "name":"鄞州区" + }, + { + "code":"330213", + "name":"奉化区" + }, + { + "code":"330225", + "name":"象山县" + }, + { + "code":"330226", + "name":"宁海县" + }, + { + "code":"330281", + "name":"余姚市" + }, + { + "code":"330282", + "name":"慈溪市" + } + + ] + + }, + { + "code":"330300", + "name":"温州市", + "sub": + [ + { + "code":"330302", + "name":"鹿城区" + }, + { + "code":"330303", + "name":"龙湾区" + }, + { + "code":"330304", + "name":"瓯海区" + }, + { + "code":"330305", + "name":"洞头区" + }, + { + "code":"330324", + "name":"永嘉县" + }, + { + "code":"330326", + "name":"平阳县" + }, + { + "code":"330327", + "name":"苍南县" + }, + { + "code":"330328", + "name":"文成县" + }, + { + "code":"330329", + "name":"泰顺县" + }, + { + "code":"330381", + "name":"瑞安市" + }, + { + "code":"330382", + "name":"乐清市" + } + + ] + + }, + { + "code":"330400", + "name":"嘉兴市", + "sub": + [ + { + "code":"330402", + "name":"南湖区" + }, + { + "code":"330411", + "name":"秀洲区" + }, + { + "code":"330421", + "name":"嘉善县" + }, + { + "code":"330424", + "name":"海盐县" + }, + { + "code":"330481", + "name":"海宁市" + }, + { + "code":"330482", + "name":"平湖市" + }, + { + "code":"330483", + "name":"桐乡市" + } + + ] + + }, + { + "code":"330500", + "name":"湖州市", + "sub": + [ + { + "code":"330502", + "name":"吴兴区" + }, + { + "code":"330503", + "name":"南浔区" + }, + { + "code":"330521", + "name":"德清县" + }, + { + "code":"330522", + "name":"长兴县" + }, + { + "code":"330523", + "name":"安吉县" + } + + ] + + }, + { + "code":"330600", + "name":"绍兴市", + "sub": + [ + { + "code":"330602", + "name":"越城区" + }, + { + "code":"330603", + "name":"柯桥区" + }, + { + "code":"330604", + "name":"上虞区" + }, + { + "code":"330624", + "name":"新昌县" + }, + { + "code":"330681", + "name":"诸暨市" + }, + { + "code":"330683", + "name":"嵊州市" + } + + ] + + }, + { + "code":"330700", + "name":"金华市", + "sub": + [ + { + "code":"330702", + "name":"婺城区" + }, + { + "code":"330703", + "name":"金东区" + }, + { + "code":"330723", + "name":"武义县" + }, + { + "code":"330726", + "name":"浦江县" + }, + { + "code":"330727", + "name":"磐安县" + }, + { + "code":"330781", + "name":"兰溪市" + }, + { + "code":"330782", + "name":"义乌市" + }, + { + "code":"330783", + "name":"东阳市" + }, + { + "code":"330784", + "name":"永康市" + } + + ] + + }, + { + "code":"330800", + "name":"衢州市", + "sub": + [ + { + "code":"330802", + "name":"柯城区" + }, + { + "code":"330803", + "name":"衢江区" + }, + { + "code":"330822", + "name":"常山县" + }, + { + "code":"330824", + "name":"开化县" + }, + { + "code":"330825", + "name":"龙游县" + }, + { + "code":"330881", + "name":"江山市" + } + + ] + + }, + { + "code":"330900", + "name":"舟山市", + "sub": + [ + { + "code":"330902", + "name":"定海区" + }, + { + "code":"330903", + "name":"普陀区" + }, + { + "code":"330921", + "name":"岱山县" + }, + { + "code":"330922", + "name":"嵊泗县" + } + + ] + + }, + { + "code":"331000", + "name":"台州市", + "sub": + [ + { + "code":"331002", + "name":"椒江区" + }, + { + "code":"331003", + "name":"黄岩区" + }, + { + "code":"331004", + "name":"路桥区" + }, + { + "code":"331022", + "name":"三门县" + }, + { + "code":"331023", + "name":"天台县" + }, + { + "code":"331024", + "name":"仙居县" + }, + { + "code":"331081", + "name":"温岭市" + }, + { + "code":"331082", + "name":"临海市" + }, + { + "code":"331083", + "name":"玉环市" + } + + ] + + }, + { + "code":"331100", + "name":"丽水市", + "sub": + [ + { + "code":"331102", + "name":"莲都区" + }, + { + "code":"331121", + "name":"青田县" + }, + { + "code":"331122", + "name":"缙云县" + }, + { + "code":"331123", + "name":"遂昌县" + }, + { + "code":"331124", + "name":"松阳县" + }, + { + "code":"331125", + "name":"云和县" + }, + { + "code":"331126", + "name":"庆元县" + }, + { + "code":"331127", + "name":"景宁畲族自治县" + }, + { + "code":"331181", + "name":"龙泉市" + } + + ] + + } + + ] + + }, + { + "code":"340000", + "name":"安徽省", + "sub": + [ + { + "code":"340100", + "name":"合肥市", + "sub": + [ + { + "code":"340102", + "name":"瑶海区" + }, + { + "code":"340103", + "name":"庐阳区" + }, + { + "code":"340104", + "name":"蜀山区" + }, + { + "code":"340111", + "name":"包河区" + }, + { + "code":"340121", + "name":"长丰县" + }, + { + "code":"340122", + "name":"肥东县" + }, + { + "code":"340123", + "name":"肥西县" + }, + { + "code":"340124", + "name":"庐江县" + }, + { + "code":"340181", + "name":"巢湖市" + } + + ] + + }, + { + "code":"340200", + "name":"芜湖市", + "sub": + [ + { + "code":"340202", + "name":"镜湖区" + }, + { + "code":"340203", + "name":"弋江区" + }, + { + "code":"340207", + "name":"鸠江区" + }, + { + "code":"340208", + "name":"三山区" + }, + { + "code":"340221", + "name":"芜湖县" + }, + { + "code":"340222", + "name":"繁昌县" + }, + { + "code":"340223", + "name":"南陵县" + }, + { + "code":"340225", + "name":"无为县" + } + + ] + + }, + { + "code":"340300", + "name":"蚌埠市", + "sub": + [ + { + "code":"340302", + "name":"龙子湖区" + }, + { + "code":"340303", + "name":"蚌山区" + }, + { + "code":"340304", + "name":"禹会区" + }, + { + "code":"340311", + "name":"淮上区" + }, + { + "code":"340321", + "name":"怀远县" + }, + { + "code":"340322", + "name":"五河县" + }, + { + "code":"340323", + "name":"固镇县" + } + + ] + + }, + { + "code":"340400", + "name":"淮南市", + "sub": + [ + { + "code":"340402", + "name":"大通区" + }, + { + "code":"340403", + "name":"田家庵区" + }, + { + "code":"340404", + "name":"谢家集区" + }, + { + "code":"340405", + "name":"八公山区" + }, + { + "code":"340406", + "name":"潘集区" + }, + { + "code":"340421", + "name":"凤台县" + }, + { + "code":"340422", + "name":"寿县" + } + + ] + + }, + { + "code":"340500", + "name":"马鞍山市", + "sub": + [ + { + "code":"340503", + "name":"花山区" + }, + { + "code":"340504", + "name":"雨山区" + }, + { + "code":"340506", + "name":"博望区" + }, + { + "code":"340521", + "name":"当涂县" + }, + { + "code":"340522", + "name":"含山县" + }, + { + "code":"340523", + "name":"和县" + } + + ] + + }, + { + "code":"340600", + "name":"淮北市", + "sub": + [ + { + "code":"340602", + "name":"杜集区" + }, + { + "code":"340603", + "name":"相山区" + }, + { + "code":"340604", + "name":"烈山区" + }, + { + "code":"340621", + "name":"濉溪县" + } + + ] + + }, + { + "code":"340700", + "name":"铜陵市", + "sub": + [ + { + "code":"340705", + "name":"铜官区" + }, + { + "code":"340706", + "name":"义安区" + }, + { + "code":"340711", + "name":"郊区" + }, + { + "code":"340722", + "name":"枞阳县" + } + + ] + + }, + { + "code":"340800", + "name":"安庆市", + "sub": + [ + { + "code":"340802", + "name":"迎江区" + }, + { + "code":"340803", + "name":"大观区" + }, + { + "code":"340811", + "name":"宜秀区" + }, + { + "code":"340822", + "name":"怀宁县" + }, + { + "code":"340824", + "name":"潜山县" + }, + { + "code":"340825", + "name":"太湖县" + }, + { + "code":"340826", + "name":"宿松县" + }, + { + "code":"340827", + "name":"望江县" + }, + { + "code":"340828", + "name":"岳西县" + }, + { + "code":"340881", + "name":"桐城市" + } + + ] + + }, + { + "code":"341000", + "name":"黄山市", + "sub": + [ + { + "code":"341002", + "name":"屯溪区" + }, + { + "code":"341003", + "name":"黄山区" + }, + { + "code":"341004", + "name":"徽州区" + }, + { + "code":"341021", + "name":"歙县" + }, + { + "code":"341022", + "name":"休宁县" + }, + { + "code":"341023", + "name":"黟县" + }, + { + "code":"341024", + "name":"祁门县" + } + + ] + + }, + { + "code":"341100", + "name":"滁州市", + "sub": + [ + { + "code":"341102", + "name":"琅琊区" + }, + { + "code":"341103", + "name":"南谯区" + }, + { + "code":"341122", + "name":"来安县" + }, + { + "code":"341124", + "name":"全椒县" + }, + { + "code":"341125", + "name":"定远县" + }, + { + "code":"341126", + "name":"凤阳县" + }, + { + "code":"341181", + "name":"天长市" + }, + { + "code":"341182", + "name":"明光市" + } + + ] + + }, + { + "code":"341200", + "name":"阜阳市", + "sub": + [ + { + "code":"341202", + "name":"颍州区" + }, + { + "code":"341203", + "name":"颍东区" + }, + { + "code":"341204", + "name":"颍泉区" + }, + { + "code":"341221", + "name":"临泉县" + }, + { + "code":"341222", + "name":"太和县" + }, + { + "code":"341225", + "name":"阜南县" + }, + { + "code":"341226", + "name":"颍上县" + }, + { + "code":"341282", + "name":"界首市" + } + + ] + + }, + { + "code":"341300", + "name":"宿州市", + "sub": + [ + { + "code":"341302", + "name":"埇桥区" + }, + { + "code":"341321", + "name":"砀山县" + }, + { + "code":"341322", + "name":"萧县" + }, + { + "code":"341323", + "name":"灵璧县" + }, + { + "code":"341324", + "name":"泗县" + } + + ] + + }, + { + "code":"341500", + "name":"六安市", + "sub": + [ + { + "code":"341502", + "name":"金安区" + }, + { + "code":"341503", + "name":"裕安区" + }, + { + "code":"341504", + "name":"叶集区" + }, + { + "code":"341522", + "name":"霍邱县" + }, + { + "code":"341523", + "name":"舒城县" + }, + { + "code":"341524", + "name":"金寨县" + }, + { + "code":"341525", + "name":"霍山县" + } + + ] + + }, + { + "code":"341600", + "name":"亳州市", + "sub": + [ + { + "code":"341602", + "name":"谯城区" + }, + { + "code":"341621", + "name":"涡阳县" + }, + { + "code":"341622", + "name":"蒙城县" + }, + { + "code":"341623", + "name":"利辛县" + } + + ] + + }, + { + "code":"341700", + "name":"池州市", + "sub": + [ + { + "code":"341702", + "name":"贵池区" + }, + { + "code":"341721", + "name":"东至县" + }, + { + "code":"341722", + "name":"石台县" + }, + { + "code":"341723", + "name":"青阳县" + } + + ] + + }, + { + "code":"341800", + "name":"宣城市", + "sub": + [ + { + "code":"341802", + "name":"宣州区" + }, + { + "code":"341821", + "name":"郎溪县" + }, + { + "code":"341822", + "name":"广德县" + }, + { + "code":"341823", + "name":"泾县" + }, + { + "code":"341824", + "name":"绩溪县" + }, + { + "code":"341825", + "name":"旌德县" + }, + { + "code":"341881", + "name":"宁国市" + } + + ] + + } + + ] + + }, + { + "code":"350000", + "name":"福建省", + "sub": + [ + { + "code":"350100", + "name":"福州市", + "sub": + [ + { + "code":"350102", + "name":"鼓楼区" + }, + { + "code":"350103", + "name":"台江区" + }, + { + "code":"350104", + "name":"仓山区" + }, + { + "code":"350105", + "name":"马尾区" + }, + { + "code":"350111", + "name":"晋安区" + }, + { + "code":"350112", + "name":"长乐区" + }, + { + "code":"350121", + "name":"闽侯县" + }, + { + "code":"350122", + "name":"连江县" + }, + { + "code":"350123", + "name":"罗源县" + }, + { + "code":"350124", + "name":"闽清县" + }, + { + "code":"350125", + "name":"永泰县" + }, + { + "code":"350128", + "name":"平潭县" + }, + { + "code":"350181", + "name":"福清市" + } + + ] + + }, + { + "code":"350200", + "name":"厦门市", + "sub": + [ + { + "code":"350203", + "name":"思明区" + }, + { + "code":"350205", + "name":"海沧区" + }, + { + "code":"350206", + "name":"湖里区" + }, + { + "code":"350211", + "name":"集美区" + }, + { + "code":"350212", + "name":"同安区" + }, + { + "code":"350213", + "name":"翔安区" + } + + ] + + }, + { + "code":"350300", + "name":"莆田市", + "sub": + [ + { + "code":"350302", + "name":"城厢区" + }, + { + "code":"350303", + "name":"涵江区" + }, + { + "code":"350304", + "name":"荔城区" + }, + { + "code":"350305", + "name":"秀屿区" + }, + { + "code":"350322", + "name":"仙游县" + } + + ] + + }, + { + "code":"350400", + "name":"三明市", + "sub": + [ + { + "code":"350402", + "name":"梅列区" + }, + { + "code":"350403", + "name":"三元区" + }, + { + "code":"350421", + "name":"明溪县" + }, + { + "code":"350423", + "name":"清流县" + }, + { + "code":"350424", + "name":"宁化县" + }, + { + "code":"350425", + "name":"大田县" + }, + { + "code":"350426", + "name":"尤溪县" + }, + { + "code":"350427", + "name":"沙县" + }, + { + "code":"350428", + "name":"将乐县" + }, + { + "code":"350429", + "name":"泰宁县" + }, + { + "code":"350430", + "name":"建宁县" + }, + { + "code":"350481", + "name":"永安市" + } + + ] + + }, + { + "code":"350500", + "name":"泉州市", + "sub": + [ + { + "code":"350502", + "name":"鲤城区" + }, + { + "code":"350503", + "name":"丰泽区" + }, + { + "code":"350504", + "name":"洛江区" + }, + { + "code":"350505", + "name":"泉港区" + }, + { + "code":"350521", + "name":"惠安县" + }, + { + "code":"350524", + "name":"安溪县" + }, + { + "code":"350525", + "name":"永春县" + }, + { + "code":"350526", + "name":"德化县" + }, + { + "code":"350527", + "name":"金门县" + }, + { + "code":"350581", + "name":"石狮市" + }, + { + "code":"350582", + "name":"晋江市" + }, + { + "code":"350583", + "name":"南安市" + } + + ] + + }, + { + "code":"350600", + "name":"漳州市", + "sub": + [ + { + "code":"350602", + "name":"芗城区" + }, + { + "code":"350603", + "name":"龙文区" + }, + { + "code":"350622", + "name":"云霄县" + }, + { + "code":"350623", + "name":"漳浦县" + }, + { + "code":"350624", + "name":"诏安县" + }, + { + "code":"350625", + "name":"长泰县" + }, + { + "code":"350626", + "name":"东山县" + }, + { + "code":"350627", + "name":"南靖县" + }, + { + "code":"350628", + "name":"平和县" + }, + { + "code":"350629", + "name":"华安县" + }, + { + "code":"350681", + "name":"龙海市" + } + + ] + + }, + { + "code":"350700", + "name":"南平市", + "sub": + [ + { + "code":"350702", + "name":"延平区" + }, + { + "code":"350703", + "name":"建阳区" + }, + { + "code":"350721", + "name":"顺昌县" + }, + { + "code":"350722", + "name":"浦城县" + }, + { + "code":"350723", + "name":"光泽县" + }, + { + "code":"350724", + "name":"松溪县" + }, + { + "code":"350725", + "name":"政和县" + }, + { + "code":"350781", + "name":"邵武市" + }, + { + "code":"350782", + "name":"武夷山市" + }, + { + "code":"350783", + "name":"建瓯市" + } + + ] + + }, + { + "code":"350800", + "name":"龙岩市", + "sub": + [ + { + "code":"350802", + "name":"新罗区" + }, + { + "code":"350803", + "name":"永定区" + }, + { + "code":"350821", + "name":"长汀县" + }, + { + "code":"350823", + "name":"上杭县" + }, + { + "code":"350824", + "name":"武平县" + }, + { + "code":"350825", + "name":"连城县" + }, + { + "code":"350881", + "name":"漳平市" + } + + ] + + }, + { + "code":"350900", + "name":"宁德市", + "sub": + [ + { + "code":"350902", + "name":"蕉城区" + }, + { + "code":"350921", + "name":"霞浦县" + }, + { + "code":"350922", + "name":"古田县" + }, + { + "code":"350923", + "name":"屏南县" + }, + { + "code":"350924", + "name":"寿宁县" + }, + { + "code":"350925", + "name":"周宁县" + }, + { + "code":"350926", + "name":"柘荣县" + }, + { + "code":"350981", + "name":"福安市" + }, + { + "code":"350982", + "name":"福鼎市" + } + + ] + + } + + ] + + }, + { + "code":"360000", + "name":"江西省", + "sub": + [ + { + "code":"360100", + "name":"南昌市", + "sub": + [ + { + "code":"360102", + "name":"东湖区" + }, + { + "code":"360103", + "name":"西湖区" + }, + { + "code":"360104", + "name":"青云谱区" + }, + { + "code":"360105", + "name":"湾里区" + }, + { + "code":"360111", + "name":"青山湖区" + }, + { + "code":"360112", + "name":"新建区" + }, + { + "code":"360121", + "name":"南昌县" + }, + { + "code":"360123", + "name":"安义县" + }, + { + "code":"360124", + "name":"进贤县" + } + + ] + + }, + { + "code":"360200", + "name":"景德镇市", + "sub": + [ + { + "code":"360202", + "name":"昌江区" + }, + { + "code":"360203", + "name":"珠山区" + }, + { + "code":"360222", + "name":"浮梁县" + }, + { + "code":"360281", + "name":"乐平市" + } + + ] + + }, + { + "code":"360300", + "name":"萍乡市", + "sub": + [ + { + "code":"360302", + "name":"安源区" + }, + { + "code":"360313", + "name":"湘东区" + }, + { + "code":"360321", + "name":"莲花县" + }, + { + "code":"360322", + "name":"上栗县" + }, + { + "code":"360323", + "name":"芦溪县" + } + + ] + + }, + { + "code":"360400", + "name":"九江市", + "sub": + [ + { + "code":"360402", + "name":"濂溪区" + }, + { + "code":"360403", + "name":"浔阳区" + }, + { + "code":"360404", + "name":"柴桑区" + }, + { + "code":"360423", + "name":"武宁县" + }, + { + "code":"360424", + "name":"修水县" + }, + { + "code":"360425", + "name":"永修县" + }, + { + "code":"360426", + "name":"德安县" + }, + { + "code":"360428", + "name":"都昌县" + }, + { + "code":"360429", + "name":"湖口县" + }, + { + "code":"360430", + "name":"彭泽县" + }, + { + "code":"360481", + "name":"瑞昌市" + }, + { + "code":"360482", + "name":"共青城市" + }, + { + "code":"360483", + "name":"庐山市" + } + + ] + + }, + { + "code":"360500", + "name":"新余市", + "sub": + [ + { + "code":"360502", + "name":"渝水区" + }, + { + "code":"360521", + "name":"分宜县" + } + + ] + + }, + { + "code":"360600", + "name":"鹰潭市", + "sub": + [ + { + "code":"360602", + "name":"月湖区" + }, + { + "code":"360622", + "name":"余江县" + }, + { + "code":"360681", + "name":"贵溪市" + } + + ] + + }, + { + "code":"360700", + "name":"赣州市", + "sub": + [ + { + "code":"360702", + "name":"章贡区" + }, + { + "code":"360703", + "name":"南康区" + }, + { + "code":"360704", + "name":"赣县区" + }, + { + "code":"360722", + "name":"信丰县" + }, + { + "code":"360723", + "name":"大余县" + }, + { + "code":"360724", + "name":"上犹县" + }, + { + "code":"360725", + "name":"崇义县" + }, + { + "code":"360726", + "name":"安远县" + }, + { + "code":"360727", + "name":"龙南县" + }, + { + "code":"360728", + "name":"定南县" + }, + { + "code":"360729", + "name":"全南县" + }, + { + "code":"360730", + "name":"宁都县" + }, + { + "code":"360731", + "name":"于都县" + }, + { + "code":"360732", + "name":"兴国县" + }, + { + "code":"360733", + "name":"会昌县" + }, + { + "code":"360734", + "name":"寻乌县" + }, + { + "code":"360735", + "name":"石城县" + }, + { + "code":"360781", + "name":"瑞金市" + } + + ] + + }, + { + "code":"360800", + "name":"吉安市", + "sub": + [ + { + "code":"360802", + "name":"吉州区" + }, + { + "code":"360803", + "name":"青原区" + }, + { + "code":"360821", + "name":"吉安县" + }, + { + "code":"360822", + "name":"吉水县" + }, + { + "code":"360823", + "name":"峡江县" + }, + { + "code":"360824", + "name":"新干县" + }, + { + "code":"360825", + "name":"永丰县" + }, + { + "code":"360826", + "name":"泰和县" + }, + { + "code":"360827", + "name":"遂川县" + }, + { + "code":"360828", + "name":"万安县" + }, + { + "code":"360829", + "name":"安福县" + }, + { + "code":"360830", + "name":"永新县" + }, + { + "code":"360881", + "name":"井冈山市" + } + + ] + + }, + { + "code":"360900", + "name":"宜春市", + "sub": + [ + { + "code":"360902", + "name":"袁州区" + }, + { + "code":"360921", + "name":"奉新县" + }, + { + "code":"360922", + "name":"万载县" + }, + { + "code":"360923", + "name":"上高县" + }, + { + "code":"360924", + "name":"宜丰县" + }, + { + "code":"360925", + "name":"靖安县" + }, + { + "code":"360926", + "name":"铜鼓县" + }, + { + "code":"360981", + "name":"丰城市" + }, + { + "code":"360982", + "name":"樟树市" + }, + { + "code":"360983", + "name":"高安市" + } + + ] + + }, + { + "code":"361000", + "name":"抚州市", + "sub": + [ + { + "code":"361002", + "name":"临川区" + }, + { + "code":"361003", + "name":"东乡区" + }, + { + "code":"361021", + "name":"南城县" + }, + { + "code":"361022", + "name":"黎川县" + }, + { + "code":"361023", + "name":"南丰县" + }, + { + "code":"361024", + "name":"崇仁县" + }, + { + "code":"361025", + "name":"乐安县" + }, + { + "code":"361026", + "name":"宜黄县" + }, + { + "code":"361027", + "name":"金溪县" + }, + { + "code":"361028", + "name":"资溪县" + }, + { + "code":"361030", + "name":"广昌县" + } + + ] + + }, + { + "code":"361100", + "name":"上饶市", + "sub": + [ + { + "code":"361102", + "name":"信州区" + }, + { + "code":"361103", + "name":"广丰区" + }, + { + "code":"361121", + "name":"上饶县" + }, + { + "code":"361123", + "name":"玉山县" + }, + { + "code":"361124", + "name":"铅山县" + }, + { + "code":"361125", + "name":"横峰县" + }, + { + "code":"361126", + "name":"弋阳县" + }, + { + "code":"361127", + "name":"余干县" + }, + { + "code":"361128", + "name":"鄱阳县" + }, + { + "code":"361129", + "name":"万年县" + }, + { + "code":"361130", + "name":"婺源县" + }, + { + "code":"361181", + "name":"德兴市" + } + + ] + + } + + ] + + }, + { + "code":"370000", + "name":"山东省", + "sub": + [ + { + "code":"370100", + "name":"济南市", + "sub": + [ + { + "code":"370102", + "name":"历下区" + }, + { + "code":"370103", + "name":"市中区" + }, + { + "code":"370104", + "name":"槐荫区" + }, + { + "code":"370105", + "name":"天桥区" + }, + { + "code":"370112", + "name":"历城区" + }, + { + "code":"370113", + "name":"长清区" + }, + { + "code":"370114", + "name":"章丘区" + }, + { + "code":"370124", + "name":"平阴县" + }, + { + "code":"370125", + "name":"济阳县" + }, + { + "code":"370126", + "name":"商河县" + } + + ] + + }, + { + "code":"370200", + "name":"青岛市", + "sub": + [ + { + "code":"370202", + "name":"市南区" + }, + { + "code":"370203", + "name":"市北区" + }, + { + "code":"370211", + "name":"黄岛区" + }, + { + "code":"370212", + "name":"崂山区" + }, + { + "code":"370213", + "name":"李沧区" + }, + { + "code":"370214", + "name":"城阳区" + }, + { + "code":"370215", + "name":"即墨区" + }, + { + "code":"370281", + "name":"胶州市" + }, + { + "code":"370283", + "name":"平度市" + }, + { + "code":"370285", + "name":"莱西市" + } + + ] + + }, + { + "code":"370300", + "name":"淄博市", + "sub": + [ + { + "code":"370302", + "name":"淄川区" + }, + { + "code":"370303", + "name":"张店区" + }, + { + "code":"370304", + "name":"博山区" + }, + { + "code":"370305", + "name":"临淄区" + }, + { + "code":"370306", + "name":"周村区" + }, + { + "code":"370321", + "name":"桓台县" + }, + { + "code":"370322", + "name":"高青县" + }, + { + "code":"370323", + "name":"沂源县" + } + + ] + + }, + { + "code":"370400", + "name":"枣庄市", + "sub": + [ + { + "code":"370402", + "name":"市中区" + }, + { + "code":"370403", + "name":"薛城区" + }, + { + "code":"370404", + "name":"峄城区" + }, + { + "code":"370405", + "name":"台儿庄区" + }, + { + "code":"370406", + "name":"山亭区" + }, + { + "code":"370481", + "name":"滕州市" + } + + ] + + }, + { + "code":"370500", + "name":"东营市", + "sub": + [ + { + "code":"370502", + "name":"东营区" + }, + { + "code":"370503", + "name":"河口区" + }, + { + "code":"370505", + "name":"垦利区" + }, + { + "code":"370522", + "name":"利津县" + }, + { + "code":"370523", + "name":"广饶县" + } + + ] + + }, + { + "code":"370600", + "name":"烟台市", + "sub": + [ + { + "code":"370602", + "name":"芝罘区" + }, + { + "code":"370611", + "name":"福山区" + }, + { + "code":"370612", + "name":"牟平区" + }, + { + "code":"370613", + "name":"莱山区" + }, + { + "code":"370634", + "name":"长岛县" + }, + { + "code":"370681", + "name":"龙口市" + }, + { + "code":"370682", + "name":"莱阳市" + }, + { + "code":"370683", + "name":"莱州市" + }, + { + "code":"370684", + "name":"蓬莱市" + }, + { + "code":"370685", + "name":"招远市" + }, + { + "code":"370686", + "name":"栖霞市" + }, + { + "code":"370687", + "name":"海阳市" + } + + ] + + }, + { + "code":"370700", + "name":"潍坊市", + "sub": + [ + { + "code":"370702", + "name":"潍城区" + }, + { + "code":"370703", + "name":"寒亭区" + }, + { + "code":"370704", + "name":"坊子区" + }, + { + "code":"370705", + "name":"奎文区" + }, + { + "code":"370724", + "name":"临朐县" + }, + { + "code":"370725", + "name":"昌乐县" + }, + { + "code":"370781", + "name":"青州市" + }, + { + "code":"370782", + "name":"诸城市" + }, + { + "code":"370783", + "name":"寿光市" + }, + { + "code":"370784", + "name":"安丘市" + }, + { + "code":"370785", + "name":"高密市" + }, + { + "code":"370786", + "name":"昌邑市" + } + + ] + + }, + { + "code":"370800", + "name":"济宁市", + "sub": + [ + { + "code":"370811", + "name":"任城区" + }, + { + "code":"370812", + "name":"兖州区" + }, + { + "code":"370826", + "name":"微山县" + }, + { + "code":"370827", + "name":"鱼台县" + }, + { + "code":"370828", + "name":"金乡县" + }, + { + "code":"370829", + "name":"嘉祥县" + }, + { + "code":"370830", + "name":"汶上县" + }, + { + "code":"370831", + "name":"泗水县" + }, + { + "code":"370832", + "name":"梁山县" + }, + { + "code":"370881", + "name":"曲阜市" + }, + { + "code":"370883", + "name":"邹城市" + } + + ] + + }, + { + "code":"370900", + "name":"泰安市", + "sub": + [ + { + "code":"370902", + "name":"泰山区" + }, + { + "code":"370911", + "name":"岱岳区" + }, + { + "code":"370921", + "name":"宁阳县" + }, + { + "code":"370923", + "name":"东平县" + }, + { + "code":"370982", + "name":"新泰市" + }, + { + "code":"370983", + "name":"肥城市" + } + + ] + + }, + { + "code":"371000", + "name":"威海市", + "sub": + [ + { + "code":"371002", + "name":"环翠区" + }, + { + "code":"371003", + "name":"文登区" + }, + { + "code":"371082", + "name":"荣成市" + }, + { + "code":"371083", + "name":"乳山市" + } + + ] + + }, + { + "code":"371100", + "name":"日照市", + "sub": + [ + { + "code":"371102", + "name":"东港区" + }, + { + "code":"371103", + "name":"岚山区" + }, + { + "code":"371121", + "name":"五莲县" + }, + { + "code":"371122", + "name":"莒县" + } + + ] + + }, + { + "code":"371200", + "name":"莱芜市", + "sub": + [ + { + "code":"371202", + "name":"莱城区" + }, + { + "code":"371203", + "name":"钢城区" + } + + ] + + }, + { + "code":"371300", + "name":"临沂市", + "sub": + [ + { + "code":"371302", + "name":"兰山区" + }, + { + "code":"371311", + "name":"罗庄区" + }, + { + "code":"371312", + "name":"河东区" + }, + { + "code":"371321", + "name":"沂南县" + }, + { + "code":"371322", + "name":"郯城县" + }, + { + "code":"371323", + "name":"沂水县" + }, + { + "code":"371324", + "name":"兰陵县" + }, + { + "code":"371325", + "name":"费县" + }, + { + "code":"371326", + "name":"平邑县" + }, + { + "code":"371327", + "name":"莒南县" + }, + { + "code":"371328", + "name":"蒙阴县" + }, + { + "code":"371329", + "name":"临沭县" + } + + ] + + }, + { + "code":"371400", + "name":"德州市", + "sub": + [ + { + "code":"371402", + "name":"德城区" + }, + { + "code":"371403", + "name":"陵城区" + }, + { + "code":"371422", + "name":"宁津县" + }, + { + "code":"371423", + "name":"庆云县" + }, + { + "code":"371424", + "name":"临邑县" + }, + { + "code":"371425", + "name":"齐河县" + }, + { + "code":"371426", + "name":"平原县" + }, + { + "code":"371427", + "name":"夏津县" + }, + { + "code":"371428", + "name":"武城县" + }, + { + "code":"371481", + "name":"乐陵市" + }, + { + "code":"371482", + "name":"禹城市" + } + + ] + + }, + { + "code":"371500", + "name":"聊城市", + "sub": + [ + { + "code":"371502", + "name":"东昌府区" + }, + { + "code":"371521", + "name":"阳谷县" + }, + { + "code":"371522", + "name":"莘县" + }, + { + "code":"371523", + "name":"茌平县" + }, + { + "code":"371524", + "name":"东阿县" + }, + { + "code":"371525", + "name":"冠县" + }, + { + "code":"371526", + "name":"高唐县" + }, + { + "code":"371581", + "name":"临清市" + } + + ] + + }, + { + "code":"371600", + "name":"滨州市", + "sub": + [ + { + "code":"371602", + "name":"滨城区" + }, + { + "code":"371603", + "name":"沾化区" + }, + { + "code":"371621", + "name":"惠民县" + }, + { + "code":"371622", + "name":"阳信县" + }, + { + "code":"371623", + "name":"无棣县" + }, + { + "code":"371625", + "name":"博兴县" + }, + { + "code":"371626", + "name":"邹平县" + } + + ] + + }, + { + "code":"371700", + "name":"菏泽市", + "sub": + [ + { + "code":"371702", + "name":"牡丹区" + }, + { + "code":"371703", + "name":"定陶区" + }, + { + "code":"371721", + "name":"曹县" + }, + { + "code":"371722", + "name":"单县" + }, + { + "code":"371723", + "name":"成武县" + }, + { + "code":"371724", + "name":"巨野县" + }, + { + "code":"371725", + "name":"郓城县" + }, + { + "code":"371726", + "name":"鄄城县" + }, + { + "code":"371728", + "name":"东明县" + } + + ] + + } + + ] + + }, + { + "code":"410000", + "name":"河南省", + "sub": + [ + { + "code":"410100", + "name":"郑州市", + "sub": + [ + { + "code":"410102", + "name":"中原区" + }, + { + "code":"410103", + "name":"二七区" + }, + { + "code":"410104", + "name":"管城回族区" + }, + { + "code":"410105", + "name":"金水区" + }, + { + "code":"410106", + "name":"上街区" + }, + { + "code":"410108", + "name":"惠济区" + }, + { + "code":"410122", + "name":"中牟县" + }, + { + "code":"410181", + "name":"巩义市" + }, + { + "code":"410182", + "name":"荥阳市" + }, + { + "code":"410183", + "name":"新密市" + }, + { + "code":"410184", + "name":"新郑市" + }, + { + "code":"410185", + "name":"登封市" + } + + ] + + }, + { + "code":"410200", + "name":"开封市", + "sub": + [ + { + "code":"410202", + "name":"龙亭区" + }, + { + "code":"410203", + "name":"顺河回族区" + }, + { + "code":"410204", + "name":"鼓楼区" + }, + { + "code":"410205", + "name":"禹王台区" + }, + { + "code":"410212", + "name":"祥符区" + }, + { + "code":"410221", + "name":"杞县" + }, + { + "code":"410222", + "name":"通许县" + }, + { + "code":"410223", + "name":"尉氏县" + }, + { + "code":"410225", + "name":"兰考县" + } + + ] + + }, + { + "code":"410300", + "name":"洛阳市", + "sub": + [ + { + "code":"410302", + "name":"老城区" + }, + { + "code":"410303", + "name":"西工区" + }, + { + "code":"410304", + "name":"瀍河回族区" + }, + { + "code":"410305", + "name":"涧西区" + }, + { + "code":"410306", + "name":"吉利区" + }, + { + "code":"410311", + "name":"洛龙区" + }, + { + "code":"410322", + "name":"孟津县" + }, + { + "code":"410323", + "name":"新安县" + }, + { + "code":"410324", + "name":"栾川县" + }, + { + "code":"410325", + "name":"嵩县" + }, + { + "code":"410326", + "name":"汝阳县" + }, + { + "code":"410327", + "name":"宜阳县" + }, + { + "code":"410328", + "name":"洛宁县" + }, + { + "code":"410329", + "name":"伊川县" + }, + { + "code":"410381", + "name":"偃师市" + } + + ] + + }, + { + "code":"410400", + "name":"平顶山市", + "sub": + [ + { + "code":"410402", + "name":"新华区" + }, + { + "code":"410403", + "name":"卫东区" + }, + { + "code":"410404", + "name":"石龙区" + }, + { + "code":"410411", + "name":"湛河区" + }, + { + "code":"410421", + "name":"宝丰县" + }, + { + "code":"410422", + "name":"叶县" + }, + { + "code":"410423", + "name":"鲁山县" + }, + { + "code":"410425", + "name":"郏县" + }, + { + "code":"410481", + "name":"舞钢市" + }, + { + "code":"410482", + "name":"汝州市" + } + + ] + + }, + { + "code":"410500", + "name":"安阳市", + "sub": + [ + { + "code":"410502", + "name":"文峰区" + }, + { + "code":"410503", + "name":"北关区" + }, + { + "code":"410505", + "name":"殷都区" + }, + { + "code":"410506", + "name":"龙安区" + }, + { + "code":"410522", + "name":"安阳县" + }, + { + "code":"410523", + "name":"汤阴县" + }, + { + "code":"410526", + "name":"滑县" + }, + { + "code":"410527", + "name":"内黄县" + }, + { + "code":"410581", + "name":"林州市" + } + + ] + + }, + { + "code":"410600", + "name":"鹤壁市", + "sub": + [ + { + "code":"410602", + "name":"鹤山区" + }, + { + "code":"410603", + "name":"山城区" + }, + { + "code":"410611", + "name":"淇滨区" + }, + { + "code":"410621", + "name":"浚县" + }, + { + "code":"410622", + "name":"淇县" + } + + ] + + }, + { + "code":"410700", + "name":"新乡市", + "sub": + [ + { + "code":"410702", + "name":"红旗区" + }, + { + "code":"410703", + "name":"卫滨区" + }, + { + "code":"410704", + "name":"凤泉区" + }, + { + "code":"410711", + "name":"牧野区" + }, + { + "code":"410721", + "name":"新乡县" + }, + { + "code":"410724", + "name":"获嘉县" + }, + { + "code":"410725", + "name":"原阳县" + }, + { + "code":"410726", + "name":"延津县" + }, + { + "code":"410727", + "name":"封丘县" + }, + { + "code":"410728", + "name":"长垣县" + }, + { + "code":"410781", + "name":"卫辉市" + }, + { + "code":"410782", + "name":"辉县市" + } + + ] + + }, + { + "code":"410800", + "name":"焦作市", + "sub": + [ + { + "code":"410802", + "name":"解放区" + }, + { + "code":"410803", + "name":"中站区" + }, + { + "code":"410804", + "name":"马村区" + }, + { + "code":"410811", + "name":"山阳区" + }, + { + "code":"410821", + "name":"修武县" + }, + { + "code":"410822", + "name":"博爱县" + }, + { + "code":"410823", + "name":"武陟县" + }, + { + "code":"410825", + "name":"温县" + }, + { + "code":"410882", + "name":"沁阳市" + }, + { + "code":"410883", + "name":"孟州市" + } + + ] + + }, + { + "code":"410900", + "name":"濮阳市", + "sub": + [ + { + "code":"410902", + "name":"华龙区" + }, + { + "code":"410922", + "name":"清丰县" + }, + { + "code":"410923", + "name":"南乐县" + }, + { + "code":"410926", + "name":"范县" + }, + { + "code":"410927", + "name":"台前县" + }, + { + "code":"410928", + "name":"濮阳县" + } + + ] + + }, + { + "code":"411000", + "name":"许昌市", + "sub": + [ + { + "code":"411002", + "name":"魏都区" + }, + { + "code":"411003", + "name":"建安区" + }, + { + "code":"411024", + "name":"鄢陵县" + }, + { + "code":"411025", + "name":"襄城县" + }, + { + "code":"411081", + "name":"禹州市" + }, + { + "code":"411082", + "name":"长葛市" + } + + ] + + }, + { + "code":"411100", + "name":"漯河市", + "sub": + [ + { + "code":"411102", + "name":"源汇区" + }, + { + "code":"411103", + "name":"郾城区" + }, + { + "code":"411104", + "name":"召陵区" + }, + { + "code":"411121", + "name":"舞阳县" + }, + { + "code":"411122", + "name":"临颍县" + } + + ] + + }, + { + "code":"411200", + "name":"三门峡市", + "sub": + [ + { + "code":"411202", + "name":"湖滨区" + }, + { + "code":"411203", + "name":"陕州区" + }, + { + "code":"411221", + "name":"渑池县" + }, + { + "code":"411224", + "name":"卢氏县" + }, + { + "code":"411281", + "name":"义马市" + }, + { + "code":"411282", + "name":"灵宝市" + } + + ] + + }, + { + "code":"411300", + "name":"南阳市", + "sub": + [ + { + "code":"411302", + "name":"宛城区" + }, + { + "code":"411303", + "name":"卧龙区" + }, + { + "code":"411321", + "name":"南召县" + }, + { + "code":"411322", + "name":"方城县" + }, + { + "code":"411323", + "name":"西峡县" + }, + { + "code":"411324", + "name":"镇平县" + }, + { + "code":"411325", + "name":"内乡县" + }, + { + "code":"411326", + "name":"淅川县" + }, + { + "code":"411327", + "name":"社旗县" + }, + { + "code":"411328", + "name":"唐河县" + }, + { + "code":"411329", + "name":"新野县" + }, + { + "code":"411330", + "name":"桐柏县" + }, + { + "code":"411381", + "name":"邓州市" + } + + ] + + }, + { + "code":"411400", + "name":"商丘市", + "sub": + [ + { + "code":"411402", + "name":"梁园区" + }, + { + "code":"411403", + "name":"睢阳区" + }, + { + "code":"411421", + "name":"民权县" + }, + { + "code":"411422", + "name":"睢县" + }, + { + "code":"411423", + "name":"宁陵县" + }, + { + "code":"411424", + "name":"柘城县" + }, + { + "code":"411425", + "name":"虞城县" + }, + { + "code":"411426", + "name":"夏邑县" + }, + { + "code":"411481", + "name":"永城市" + } + + ] + + }, + { + "code":"411500", + "name":"信阳市", + "sub": + [ + { + "code":"411502", + "name":"浉河区" + }, + { + "code":"411503", + "name":"平桥区" + }, + { + "code":"411521", + "name":"罗山县" + }, + { + "code":"411522", + "name":"光山县" + }, + { + "code":"411523", + "name":"新县" + }, + { + "code":"411524", + "name":"商城县" + }, + { + "code":"411525", + "name":"固始县" + }, + { + "code":"411526", + "name":"潢川县" + }, + { + "code":"411527", + "name":"淮滨县" + }, + { + "code":"411528", + "name":"息县" + } + + ] + + }, + { + "code":"411600", + "name":"周口市", + "sub": + [ + { + "code":"411602", + "name":"川汇区" + }, + { + "code":"411621", + "name":"扶沟县" + }, + { + "code":"411622", + "name":"西华县" + }, + { + "code":"411623", + "name":"商水县" + }, + { + "code":"411624", + "name":"沈丘县" + }, + { + "code":"411625", + "name":"郸城县" + }, + { + "code":"411626", + "name":"淮阳县" + }, + { + "code":"411627", + "name":"太康县" + }, + { + "code":"411628", + "name":"鹿邑县" + }, + { + "code":"411681", + "name":"项城市" + } + + ] + + }, + { + "code":"411700", + "name":"驻马店市", + "sub": + [ + { + "code":"411702", + "name":"驿城区" + }, + { + "code":"411721", + "name":"西平县" + }, + { + "code":"411722", + "name":"上蔡县" + }, + { + "code":"411723", + "name":"平舆县" + }, + { + "code":"411724", + "name":"正阳县" + }, + { + "code":"411725", + "name":"确山县" + }, + { + "code":"411726", + "name":"泌阳县" + }, + { + "code":"411727", + "name":"汝南县" + }, + { + "code":"411728", + "name":"遂平县" + }, + { + "code":"411729", + "name":"新蔡县" + } + + ] + + } + + ] + + }, + { + "code":"420000", + "name":"湖北省", + "sub": + [ + { + "code":"420100", + "name":"武汉市", + "sub": + [ + { + "code":"420102", + "name":"江岸区" + }, + { + "code":"420103", + "name":"江汉区" + }, + { + "code":"420104", + "name":"硚口区" + }, + { + "code":"420105", + "name":"汉阳区" + }, + { + "code":"420106", + "name":"武昌区" + }, + { + "code":"420107", + "name":"青山区" + }, + { + "code":"420111", + "name":"洪山区" + }, + { + "code":"420112", + "name":"东西湖区" + }, + { + "code":"420113", + "name":"汉南区" + }, + { + "code":"420114", + "name":"蔡甸区" + }, + { + "code":"420115", + "name":"江夏区" + }, + { + "code":"420116", + "name":"黄陂区" + }, + { + "code":"420117", + "name":"新洲区" + } + + ] + + }, + { + "code":"420200", + "name":"黄石市", + "sub": + [ + { + "code":"420202", + "name":"黄石港区" + }, + { + "code":"420203", + "name":"西塞山区" + }, + { + "code":"420204", + "name":"下陆区" + }, + { + "code":"420205", + "name":"铁山区" + }, + { + "code":"420222", + "name":"阳新县" + }, + { + "code":"420281", + "name":"大冶市" + } + + ] + + }, + { + "code":"420300", + "name":"十堰市", + "sub": + [ + { + "code":"420302", + "name":"茅箭区" + }, + { + "code":"420303", + "name":"张湾区" + }, + { + "code":"420304", + "name":"郧阳区" + }, + { + "code":"420322", + "name":"郧西县" + }, + { + "code":"420323", + "name":"竹山县" + }, + { + "code":"420324", + "name":"竹溪县" + }, + { + "code":"420325", + "name":"房县" + }, + { + "code":"420381", + "name":"丹江口市" + } + + ] + + }, + { + "code":"420500", + "name":"宜昌市", + "sub": + [ + { + "code":"420502", + "name":"西陵区" + }, + { + "code":"420503", + "name":"伍家岗区" + }, + { + "code":"420504", + "name":"点军区" + }, + { + "code":"420505", + "name":"猇亭区" + }, + { + "code":"420506", + "name":"夷陵区" + }, + { + "code":"420525", + "name":"远安县" + }, + { + "code":"420526", + "name":"兴山县" + }, + { + "code":"420527", + "name":"秭归县" + }, + { + "code":"420528", + "name":"长阳土家族自治县" + }, + { + "code":"420529", + "name":"五峰土家族自治县" + }, + { + "code":"420581", + "name":"宜都市" + }, + { + "code":"420582", + "name":"当阳市" + }, + { + "code":"420583", + "name":"枝江市" + } + + ] + + }, + { + "code":"420600", + "name":"襄阳市", + "sub": + [ + { + "code":"420602", + "name":"襄城区" + }, + { + "code":"420606", + "name":"樊城区" + }, + { + "code":"420607", + "name":"襄州区" + }, + { + "code":"420624", + "name":"南漳县" + }, + { + "code":"420625", + "name":"谷城县" + }, + { + "code":"420626", + "name":"保康县" + }, + { + "code":"420682", + "name":"老河口市" + }, + { + "code":"420683", + "name":"枣阳市" + }, + { + "code":"420684", + "name":"宜城市" + } + + ] + + }, + { + "code":"420700", + "name":"鄂州市", + "sub": + [ + { + "code":"420702", + "name":"梁子湖区" + }, + { + "code":"420703", + "name":"华容区" + }, + { + "code":"420704", + "name":"鄂城区" + } + + ] + + }, + { + "code":"420800", + "name":"荆门市", + "sub": + [ + { + "code":"420802", + "name":"东宝区" + }, + { + "code":"420804", + "name":"掇刀区" + }, + { + "code":"420821", + "name":"京山县" + }, + { + "code":"420822", + "name":"沙洋县" + }, + { + "code":"420881", + "name":"钟祥市" + } + + ] + + }, + { + "code":"420900", + "name":"孝感市", + "sub": + [ + { + "code":"420902", + "name":"孝南区" + }, + { + "code":"420921", + "name":"孝昌县" + }, + { + "code":"420922", + "name":"大悟县" + }, + { + "code":"420923", + "name":"云梦县" + }, + { + "code":"420981", + "name":"应城市" + }, + { + "code":"420982", + "name":"安陆市" + }, + { + "code":"420984", + "name":"汉川市" + } + + ] + + }, + { + "code":"421000", + "name":"荆州市", + "sub": + [ + { + "code":"421002", + "name":"沙市区" + }, + { + "code":"421003", + "name":"荆州区" + }, + { + "code":"421022", + "name":"公安县" + }, + { + "code":"421023", + "name":"监利县" + }, + { + "code":"421024", + "name":"江陵县" + }, + { + "code":"421081", + "name":"石首市" + }, + { + "code":"421083", + "name":"洪湖市" + }, + { + "code":"421087", + "name":"松滋市" + } + + ] + + }, + { + "code":"421100", + "name":"黄冈市", + "sub": + [ + { + "code":"421102", + "name":"黄州区" + }, + { + "code":"421121", + "name":"团风县" + }, + { + "code":"421122", + "name":"红安县" + }, + { + "code":"421123", + "name":"罗田县" + }, + { + "code":"421124", + "name":"英山县" + }, + { + "code":"421125", + "name":"浠水县" + }, + { + "code":"421126", + "name":"蕲春县" + }, + { + "code":"421127", + "name":"黄梅县" + }, + { + "code":"421181", + "name":"麻城市" + }, + { + "code":"421182", + "name":"武穴市" + } + + ] + + }, + { + "code":"421200", + "name":"咸宁市", + "sub": + [ + { + "code":"421202", + "name":"咸安区" + }, + { + "code":"421221", + "name":"嘉鱼县" + }, + { + "code":"421222", + "name":"通城县" + }, + { + "code":"421223", + "name":"崇阳县" + }, + { + "code":"421224", + "name":"通山县" + }, + { + "code":"421281", + "name":"赤壁市" + } + + ] + + }, + { + "code":"421300", + "name":"随州市", + "sub": + [ + { + "code":"421303", + "name":"曾都区" + }, + { + "code":"421321", + "name":"随县" + }, + { + "code":"421381", + "name":"广水市" + } + + ] + + }, + { + "code":"422800", + "name":"恩施土家族苗族自治州", + "sub": + [ + { + "code":"422801", + "name":"恩施市" + }, + { + "code":"422802", + "name":"利川市" + }, + { + "code":"422822", + "name":"建始县" + }, + { + "code":"422823", + "name":"巴东县" + }, + { + "code":"422825", + "name":"宣恩县" + }, + { + "code":"422826", + "name":"咸丰县" + }, + { + "code":"422827", + "name":"来凤县" + }, + { + "code":"422828", + "name":"鹤峰县" + } + + ] + + } + + ] + + }, + { + "code":"430000", + "name":"湖南省", + "sub": + [ + { + "code":"430100", + "name":"长沙市", + "sub": + [ + { + "code":"430102", + "name":"芙蓉区" + }, + { + "code":"430103", + "name":"天心区" + }, + { + "code":"430104", + "name":"岳麓区" + }, + { + "code":"430105", + "name":"开福区" + }, + { + "code":"430111", + "name":"雨花区" + }, + { + "code":"430112", + "name":"望城区" + }, + { + "code":"430121", + "name":"长沙县" + }, + { + "code":"430181", + "name":"浏阳市" + }, + { + "code":"430182", + "name":"宁乡市" + } + + ] + + }, + { + "code":"430200", + "name":"株洲市", + "sub": + [ + { + "code":"430202", + "name":"荷塘区" + }, + { + "code":"430203", + "name":"芦淞区" + }, + { + "code":"430204", + "name":"石峰区" + }, + { + "code":"430211", + "name":"天元区" + }, + { + "code":"430221", + "name":"株洲县" + }, + { + "code":"430223", + "name":"攸县" + }, + { + "code":"430224", + "name":"茶陵县" + }, + { + "code":"430225", + "name":"炎陵县" + }, + { + "code":"430281", + "name":"醴陵市" + } + + ] + + }, + { + "code":"430300", + "name":"湘潭市", + "sub": + [ + { + "code":"430302", + "name":"雨湖区" + }, + { + "code":"430304", + "name":"岳塘区" + }, + { + "code":"430321", + "name":"湘潭县" + }, + { + "code":"430381", + "name":"湘乡市" + }, + { + "code":"430382", + "name":"韶山市" + } + + ] + + }, + { + "code":"430400", + "name":"衡阳市", + "sub": + [ + { + "code":"430405", + "name":"珠晖区" + }, + { + "code":"430406", + "name":"雁峰区" + }, + { + "code":"430407", + "name":"石鼓区" + }, + { + "code":"430408", + "name":"蒸湘区" + }, + { + "code":"430412", + "name":"南岳区" + }, + { + "code":"430421", + "name":"衡阳县" + }, + { + "code":"430422", + "name":"衡南县" + }, + { + "code":"430423", + "name":"衡山县" + }, + { + "code":"430424", + "name":"衡东县" + }, + { + "code":"430426", + "name":"祁东县" + }, + { + "code":"430481", + "name":"耒阳市" + }, + { + "code":"430482", + "name":"常宁市" + } + + ] + + }, + { + "code":"430500", + "name":"邵阳市", + "sub": + [ + { + "code":"430502", + "name":"双清区" + }, + { + "code":"430503", + "name":"大祥区" + }, + { + "code":"430511", + "name":"北塔区" + }, + { + "code":"430521", + "name":"邵东县" + }, + { + "code":"430522", + "name":"新邵县" + }, + { + "code":"430523", + "name":"邵阳县" + }, + { + "code":"430524", + "name":"隆回县" + }, + { + "code":"430525", + "name":"洞口县" + }, + { + "code":"430527", + "name":"绥宁县" + }, + { + "code":"430528", + "name":"新宁县" + }, + { + "code":"430529", + "name":"城步苗族自治县" + }, + { + "code":"430581", + "name":"武冈市" + } + + ] + + }, + { + "code":"430600", + "name":"岳阳市", + "sub": + [ + { + "code":"430602", + "name":"岳阳楼区" + }, + { + "code":"430603", + "name":"云溪区" + }, + { + "code":"430611", + "name":"君山区" + }, + { + "code":"430621", + "name":"岳阳县" + }, + { + "code":"430623", + "name":"华容县" + }, + { + "code":"430624", + "name":"湘阴县" + }, + { + "code":"430626", + "name":"平江县" + }, + { + "code":"430681", + "name":"汨罗市" + }, + { + "code":"430682", + "name":"临湘市" + } + + ] + + }, + { + "code":"430700", + "name":"常德市", + "sub": + [ + { + "code":"430702", + "name":"武陵区" + }, + { + "code":"430703", + "name":"鼎城区" + }, + { + "code":"430721", + "name":"安乡县" + }, + { + "code":"430722", + "name":"汉寿县" + }, + { + "code":"430723", + "name":"澧县" + }, + { + "code":"430724", + "name":"临澧县" + }, + { + "code":"430725", + "name":"桃源县" + }, + { + "code":"430726", + "name":"石门县" + }, + { + "code":"430781", + "name":"津市市" + } + + ] + + }, + { + "code":"430800", + "name":"张家界市", + "sub": + [ + { + "code":"430802", + "name":"永定区" + }, + { + "code":"430811", + "name":"武陵源区" + }, + { + "code":"430821", + "name":"慈利县" + }, + { + "code":"430822", + "name":"桑植县" + } + + ] + + }, + { + "code":"430900", + "name":"益阳市", + "sub": + [ + { + "code":"430902", + "name":"资阳区" + }, + { + "code":"430903", + "name":"赫山区" + }, + { + "code":"430921", + "name":"南县" + }, + { + "code":"430922", + "name":"桃江县" + }, + { + "code":"430923", + "name":"安化县" + }, + { + "code":"430981", + "name":"沅江市" + } + + ] + + }, + { + "code":"431000", + "name":"郴州市", + "sub": + [ + { + "code":"431002", + "name":"北湖区" + }, + { + "code":"431003", + "name":"苏仙区" + }, + { + "code":"431021", + "name":"桂阳县" + }, + { + "code":"431022", + "name":"宜章县" + }, + { + "code":"431023", + "name":"永兴县" + }, + { + "code":"431024", + "name":"嘉禾县" + }, + { + "code":"431025", + "name":"临武县" + }, + { + "code":"431026", + "name":"汝城县" + }, + { + "code":"431027", + "name":"桂东县" + }, + { + "code":"431028", + "name":"安仁县" + }, + { + "code":"431081", + "name":"资兴市" + } + + ] + + }, + { + "code":"431100", + "name":"永州市", + "sub": + [ + { + "code":"431102", + "name":"零陵区" + }, + { + "code":"431103", + "name":"冷水滩区" + }, + { + "code":"431121", + "name":"祁阳县" + }, + { + "code":"431122", + "name":"东安县" + }, + { + "code":"431123", + "name":"双牌县" + }, + { + "code":"431124", + "name":"道县" + }, + { + "code":"431125", + "name":"江永县" + }, + { + "code":"431126", + "name":"宁远县" + }, + { + "code":"431127", + "name":"蓝山县" + }, + { + "code":"431128", + "name":"新田县" + }, + { + "code":"431129", + "name":"江华瑶族自治县" + } + + ] + + }, + { + "code":"431200", + "name":"怀化市", + "sub": + [ + { + "code":"431202", + "name":"鹤城区" + }, + { + "code":"431221", + "name":"中方县" + }, + { + "code":"431222", + "name":"沅陵县" + }, + { + "code":"431223", + "name":"辰溪县" + }, + { + "code":"431224", + "name":"溆浦县" + }, + { + "code":"431225", + "name":"会同县" + }, + { + "code":"431226", + "name":"麻阳苗族自治县" + }, + { + "code":"431227", + "name":"新晃侗族自治县" + }, + { + "code":"431228", + "name":"芷江侗族自治县" + }, + { + "code":"431229", + "name":"靖州苗族侗族自治县" + }, + { + "code":"431230", + "name":"通道侗族自治县" + }, + { + "code":"431281", + "name":"洪江市" + } + + ] + + }, + { + "code":"431300", + "name":"娄底市", + "sub": + [ + { + "code":"431302", + "name":"娄星区" + }, + { + "code":"431321", + "name":"双峰县" + }, + { + "code":"431322", + "name":"新化县" + }, + { + "code":"431381", + "name":"冷水江市" + }, + { + "code":"431382", + "name":"涟源市" + } + + ] + + }, + { + "code":"433100", + "name":"湘西土家族苗族自治州", + "sub": + [ + { + "code":"433101", + "name":"吉首市" + }, + { + "code":"433122", + "name":"泸溪县" + }, + { + "code":"433123", + "name":"凤凰县" + }, + { + "code":"433124", + "name":"花垣县" + }, + { + "code":"433125", + "name":"保靖县" + }, + { + "code":"433126", + "name":"古丈县" + }, + { + "code":"433127", + "name":"永顺县" + }, + { + "code":"433130", + "name":"龙山县" + } + + ] + + } + + ] + + }, + { + "code":"440000", + "name":"广东省", + "sub": + [ + { + "code":"440100", + "name":"广州市", + "sub": + [ + { + "code":"440103", + "name":"荔湾区" + }, + { + "code":"440104", + "name":"越秀区" + }, + { + "code":"440105", + "name":"海珠区" + }, + { + "code":"440106", + "name":"天河区" + }, + { + "code":"440111", + "name":"白云区" + }, + { + "code":"440112", + "name":"黄埔区" + }, + { + "code":"440113", + "name":"番禺区" + }, + { + "code":"440114", + "name":"花都区" + }, + { + "code":"440115", + "name":"南沙区" + }, + { + "code":"440117", + "name":"从化区" + }, + { + "code":"440118", + "name":"增城区" + } + + ] + + }, + { + "code":"440200", + "name":"韶关市", + "sub": + [ + { + "code":"440203", + "name":"武江区" + }, + { + "code":"440204", + "name":"浈江区" + }, + { + "code":"440205", + "name":"曲江区" + }, + { + "code":"440222", + "name":"始兴县" + }, + { + "code":"440224", + "name":"仁化县" + }, + { + "code":"440229", + "name":"翁源县" + }, + { + "code":"440232", + "name":"乳源瑶族自治县" + }, + { + "code":"440233", + "name":"新丰县" + }, + { + "code":"440281", + "name":"乐昌市" + }, + { + "code":"440282", + "name":"南雄市" + } + + ] + + }, + { + "code":"440300", + "name":"深圳市", + "sub": + [ + { + "code":"440303", + "name":"罗湖区" + }, + { + "code":"440304", + "name":"福田区" + }, + { + "code":"440305", + "name":"南山区" + }, + { + "code":"440306", + "name":"宝安区" + }, + { + "code":"440307", + "name":"龙岗区" + }, + { + "code":"440308", + "name":"盐田区" + }, + { + "code":"440309", + "name":"龙华区" + }, + { + "code":"440310", + "name":"坪山区" + } + + ] + + }, + { + "code":"440400", + "name":"珠海市", + "sub": + [ + { + "code":"440402", + "name":"香洲区" + }, + { + "code":"440403", + "name":"斗门区" + }, + { + "code":"440404", + "name":"金湾区" + } + + ] + + }, + { + "code":"440500", + "name":"汕头市", + "sub": + [ + { + "code":"440507", + "name":"龙湖区" + }, + { + "code":"440511", + "name":"金平区" + }, + { + "code":"440512", + "name":"濠江区" + }, + { + "code":"440513", + "name":"潮阳区" + }, + { + "code":"440514", + "name":"潮南区" + }, + { + "code":"440515", + "name":"澄海区" + }, + { + "code":"440523", + "name":"南澳县" + } + + ] + + }, + { + "code":"440600", + "name":"佛山市", + "sub": + [ + { + "code":"440604", + "name":"禅城区" + }, + { + "code":"440605", + "name":"南海区" + }, + { + "code":"440606", + "name":"顺德区" + }, + { + "code":"440607", + "name":"三水区" + }, + { + "code":"440608", + "name":"高明区" + } + + ] + + }, + { + "code":"440700", + "name":"江门市", + "sub": + [ + { + "code":"440703", + "name":"蓬江区" + }, + { + "code":"440704", + "name":"江海区" + }, + { + "code":"440705", + "name":"新会区" + }, + { + "code":"440781", + "name":"台山市" + }, + { + "code":"440783", + "name":"开平市" + }, + { + "code":"440784", + "name":"鹤山市" + }, + { + "code":"440785", + "name":"恩平市" + } + + ] + + }, + { + "code":"440800", + "name":"湛江市", + "sub": + [ + { + "code":"440802", + "name":"赤坎区" + }, + { + "code":"440803", + "name":"霞山区" + }, + { + "code":"440804", + "name":"坡头区" + }, + { + "code":"440811", + "name":"麻章区" + }, + { + "code":"440823", + "name":"遂溪县" + }, + { + "code":"440825", + "name":"徐闻县" + }, + { + "code":"440881", + "name":"廉江市" + }, + { + "code":"440882", + "name":"雷州市" + }, + { + "code":"440883", + "name":"吴川市" + } + + ] + + }, + { + "code":"440900", + "name":"茂名市", + "sub": + [ + { + "code":"440902", + "name":"茂南区" + }, + { + "code":"440904", + "name":"电白区" + }, + { + "code":"440981", + "name":"高州市" + }, + { + "code":"440982", + "name":"化州市" + }, + { + "code":"440983", + "name":"信宜市" + } + + ] + + }, + { + "code":"441200", + "name":"肇庆市", + "sub": + [ + { + "code":"441202", + "name":"端州区" + }, + { + "code":"441203", + "name":"鼎湖区" + }, + { + "code":"441204", + "name":"高要区" + }, + { + "code":"441223", + "name":"广宁县" + }, + { + "code":"441224", + "name":"怀集县" + }, + { + "code":"441225", + "name":"封开县" + }, + { + "code":"441226", + "name":"德庆县" + }, + { + "code":"441284", + "name":"四会市" + } + + ] + + }, + { + "code":"441300", + "name":"惠州市", + "sub": + [ + { + "code":"441302", + "name":"惠城区" + }, + { + "code":"441303", + "name":"惠阳区" + }, + { + "code":"441322", + "name":"博罗县" + }, + { + "code":"441323", + "name":"惠东县" + }, + { + "code":"441324", + "name":"龙门县" + } + + ] + + }, + { + "code":"441400", + "name":"梅州市", + "sub": + [ + { + "code":"441402", + "name":"梅江区" + }, + { + "code":"441403", + "name":"梅县区" + }, + { + "code":"441422", + "name":"大埔县" + }, + { + "code":"441423", + "name":"丰顺县" + }, + { + "code":"441424", + "name":"五华县" + }, + { + "code":"441426", + "name":"平远县" + }, + { + "code":"441427", + "name":"蕉岭县" + }, + { + "code":"441481", + "name":"兴宁市" + } + + ] + + }, + { + "code":"441500", + "name":"汕尾市", + "sub": + [ + { + "code":"441502", + "name":"城区" + }, + { + "code":"441521", + "name":"海丰县" + }, + { + "code":"441523", + "name":"陆河县" + }, + { + "code":"441581", + "name":"陆丰市" + } + + ] + + }, + { + "code":"441600", + "name":"河源市", + "sub": + [ + { + "code":"441602", + "name":"源城区" + }, + { + "code":"441621", + "name":"紫金县" + }, + { + "code":"441622", + "name":"龙川县" + }, + { + "code":"441623", + "name":"连平县" + }, + { + "code":"441624", + "name":"和平县" + }, + { + "code":"441625", + "name":"东源县" + } + + ] + + }, + { + "code":"441700", + "name":"阳江市", + "sub": + [ + { + "code":"441702", + "name":"江城区" + }, + { + "code":"441704", + "name":"阳东区" + }, + { + "code":"441721", + "name":"阳西县" + }, + { + "code":"441781", + "name":"阳春市" + } + + ] + + }, + { + "code":"441800", + "name":"清远市", + "sub": + [ + { + "code":"441802", + "name":"清城区" + }, + { + "code":"441803", + "name":"清新区" + }, + { + "code":"441821", + "name":"佛冈县" + }, + { + "code":"441823", + "name":"阳山县" + }, + { + "code":"441825", + "name":"连山壮族瑶族自治县" + }, + { + "code":"441826", + "name":"连南瑶族自治县" + }, + { + "code":"441881", + "name":"英德市" + }, + { + "code":"441882", + "name":"连州市" + } + + ] + + }, + { + "code":"441900", + "name":"东莞市", + "sub": + [{ + "code":"441900101", + "name":"石碣镇" + },{ + "code":"441900102", + "name":"石龙镇" + }, + { + "code":"441900103", + "name":"茶山镇" + }, + { + "code":"441900104", + "name":"石排镇" + }, + { + "code":"441900105", + "name":"企石镇" + }, + { + "code":"441900106", + "name":"横沥镇" + }, + { + "code":"441900107", + "name":"桥头镇" + }, + { + "code":"441900108", + "name":"谢岗镇" + }, + { + "code":"441900109", + "name":"东坑镇" + }, + { + "code":"441900110", + "name":"常平镇" + }, + { + "code":"441900111", + "name":"寮步镇" + }, + { + "code":"441900112", + "name":"樟木头镇" + }, + { + "code":"441900113", + "name":"大朗镇" + }, + { + "code":"441900114", + "name":"黄江镇" + }, + { + "code":"441900115", + "name":"清溪镇" + }, + { + "code":"441900116", + "name":"塘厦镇" + }, + { + "code":"441900117", + "name":"凤岗镇" + }, + { + "code":"441900118", + "name":"大岭山镇" + }, + { + "code":"441900119", + "name":"长安镇" + }, + { + "code":"441900120", + "name":"虎门镇" + }, + { + "code":"441900121", + "name":"厚街镇" + }, + { + "code":"441900122", + "name":"沙田镇" + }, + { + "code":"441900123", + "name":"道滘镇" + }, + { + "code":"441900124", + "name":"洪梅镇" + }, + { + "code":"441900125", + "name":"麻涌镇" + }, + { + "code":"441900126", + "name":"望牛墩镇" + }, + { + "code":"441900127", + "name":"中堂镇" + }, + { + "code":"441900128", + "name":"高埗镇" + }, + { + "code":"441900129", + "name":"麻涌镇" + } + ] + + }, + { + "code":"442000", + "name":"中山市", + "sub": + [ + {"code":"442000", + "name":"中山市" + } + ] + + }, + { + "code":"445100", + "name":"潮州市", + "sub": + [ + { + "code":"445102", + "name":"湘桥区" + }, + { + "code":"445103", + "name":"潮安区" + }, + { + "code":"445122", + "name":"饶平县" + } + + ] + + }, + { + "code":"445200", + "name":"揭阳市", + "sub": + [ + { + "code":"445202", + "name":"榕城区" + }, + { + "code":"445203", + "name":"揭东区" + }, + { + "code":"445222", + "name":"揭西县" + }, + { + "code":"445224", + "name":"惠来县" + }, + { + "code":"445281", + "name":"普宁市" + } + + ] + + }, + { + "code":"445300", + "name":"云浮市", + "sub": + [ + { + "code":"445302", + "name":"云城区" + }, + { + "code":"445303", + "name":"云安区" + }, + { + "code":"445321", + "name":"新兴县" + }, + { + "code":"445322", + "name":"郁南县" + }, + { + "code":"445381", + "name":"罗定市" + } + + ] + + } + + ] + + }, + { + "code":"450000", + "name":"广西壮族自治区", + "sub": + [ + { + "code":"450100", + "name":"南宁市", + "sub": + [ + { + "code":"450102", + "name":"兴宁区" + }, + { + "code":"450103", + "name":"青秀区" + }, + { + "code":"450105", + "name":"江南区" + }, + { + "code":"450107", + "name":"西乡塘区" + }, + { + "code":"450108", + "name":"良庆区" + }, + { + "code":"450109", + "name":"邕宁区" + }, + { + "code":"450110", + "name":"武鸣区" + }, + { + "code":"450123", + "name":"隆安县" + }, + { + "code":"450124", + "name":"马山县" + }, + { + "code":"450125", + "name":"上林县" + }, + { + "code":"450126", + "name":"宾阳县" + }, + { + "code":"450127", + "name":"横县" + } + + ] + + }, + { + "code":"450200", + "name":"柳州市", + "sub": + [ + { + "code":"450202", + "name":"城中区" + }, + { + "code":"450203", + "name":"鱼峰区" + }, + { + "code":"450204", + "name":"柳南区" + }, + { + "code":"450205", + "name":"柳北区" + }, + { + "code":"450206", + "name":"柳江区" + }, + { + "code":"450222", + "name":"柳城县" + }, + { + "code":"450223", + "name":"鹿寨县" + }, + { + "code":"450224", + "name":"融安县" + }, + { + "code":"450225", + "name":"融水苗族自治县" + }, + { + "code":"450226", + "name":"三江侗族自治县" + } + + ] + + }, + { + "code":"450300", + "name":"桂林市", + "sub": + [ + { + "code":"450302", + "name":"秀峰区" + }, + { + "code":"450303", + "name":"叠彩区" + }, + { + "code":"450304", + "name":"象山区" + }, + { + "code":"450305", + "name":"七星区" + }, + { + "code":"450311", + "name":"雁山区" + }, + { + "code":"450312", + "name":"临桂区" + }, + { + "code":"450321", + "name":"阳朔县" + }, + { + "code":"450323", + "name":"灵川县" + }, + { + "code":"450324", + "name":"全州县" + }, + { + "code":"450325", + "name":"兴安县" + }, + { + "code":"450326", + "name":"永福县" + }, + { + "code":"450327", + "name":"灌阳县" + }, + { + "code":"450328", + "name":"龙胜各族自治县" + }, + { + "code":"450329", + "name":"资源县" + }, + { + "code":"450330", + "name":"平乐县" + }, + { + "code":"450331", + "name":"荔浦县" + }, + { + "code":"450332", + "name":"恭城瑶族自治县" + } + + ] + + }, + { + "code":"450400", + "name":"梧州市", + "sub": + [ + { + "code":"450403", + "name":"万秀区" + }, + { + "code":"450405", + "name":"长洲区" + }, + { + "code":"450406", + "name":"龙圩区" + }, + { + "code":"450421", + "name":"苍梧县" + }, + { + "code":"450422", + "name":"藤县" + }, + { + "code":"450423", + "name":"蒙山县" + }, + { + "code":"450481", + "name":"岑溪市" + } + + ] + + }, + { + "code":"450500", + "name":"北海市", + "sub": + [ + { + "code":"450502", + "name":"海城区" + }, + { + "code":"450503", + "name":"银海区" + }, + { + "code":"450512", + "name":"铁山港区" + }, + { + "code":"450521", + "name":"合浦县" + } + + ] + + }, + { + "code":"450600", + "name":"防城港市", + "sub": + [ + { + "code":"450602", + "name":"港口区" + }, + { + "code":"450603", + "name":"防城区" + }, + { + "code":"450621", + "name":"上思县" + }, + { + "code":"450681", + "name":"东兴市" + } + + ] + + }, + { + "code":"450700", + "name":"钦州市", + "sub": + [ + { + "code":"450702", + "name":"钦南区" + }, + { + "code":"450703", + "name":"钦北区" + }, + { + "code":"450721", + "name":"灵山县" + }, + { + "code":"450722", + "name":"浦北县" + } + + ] + + }, + { + "code":"450800", + "name":"贵港市", + "sub": + [ + { + "code":"450802", + "name":"港北区" + }, + { + "code":"450803", + "name":"港南区" + }, + { + "code":"450804", + "name":"覃塘区" + }, + { + "code":"450821", + "name":"平南县" + }, + { + "code":"450881", + "name":"桂平市" + } + + ] + + }, + { + "code":"450900", + "name":"玉林市", + "sub": + [ + { + "code":"450902", + "name":"玉州区" + }, + { + "code":"450903", + "name":"福绵区" + }, + { + "code":"450921", + "name":"容县" + }, + { + "code":"450922", + "name":"陆川县" + }, + { + "code":"450923", + "name":"博白县" + }, + { + "code":"450924", + "name":"兴业县" + }, + { + "code":"450981", + "name":"北流市" + } + + ] + + }, + { + "code":"451000", + "name":"百色市", + "sub": + [ + { + "code":"451002", + "name":"右江区" + }, + { + "code":"451021", + "name":"田阳县" + }, + { + "code":"451022", + "name":"田东县" + }, + { + "code":"451023", + "name":"平果县" + }, + { + "code":"451024", + "name":"德保县" + }, + { + "code":"451026", + "name":"那坡县" + }, + { + "code":"451027", + "name":"凌云县" + }, + { + "code":"451028", + "name":"乐业县" + }, + { + "code":"451029", + "name":"田林县" + }, + { + "code":"451030", + "name":"西林县" + }, + { + "code":"451031", + "name":"隆林各族自治县" + }, + { + "code":"451081", + "name":"靖西市" + } + + ] + + }, + { + "code":"451100", + "name":"贺州市", + "sub": + [ + { + "code":"451102", + "name":"八步区" + }, + { + "code":"451103", + "name":"平桂区" + }, + { + "code":"451121", + "name":"昭平县" + }, + { + "code":"451122", + "name":"钟山县" + }, + { + "code":"451123", + "name":"富川瑶族自治县" + } + + ] + + }, + { + "code":"451200", + "name":"河池市", + "sub": + [ + { + "code":"451202", + "name":"金城江区" + }, + { + "code":"451203", + "name":"宜州区" + }, + { + "code":"451221", + "name":"南丹县" + }, + { + "code":"451222", + "name":"天峨县" + }, + { + "code":"451223", + "name":"凤山县" + }, + { + "code":"451224", + "name":"东兰县" + }, + { + "code":"451225", + "name":"罗城仫佬族自治县" + }, + { + "code":"451226", + "name":"环江毛南族自治县" + }, + { + "code":"451227", + "name":"巴马瑶族自治县" + }, + { + "code":"451228", + "name":"都安瑶族自治县" + }, + { + "code":"451229", + "name":"大化瑶族自治县" + } + + ] + + }, + { + "code":"451300", + "name":"来宾市", + "sub": + [ + { + "code":"451302", + "name":"兴宾区" + }, + { + "code":"451321", + "name":"忻城县" + }, + { + "code":"451322", + "name":"象州县" + }, + { + "code":"451323", + "name":"武宣县" + }, + { + "code":"451324", + "name":"金秀瑶族自治县" + }, + { + "code":"451381", + "name":"合山市" + } + + ] + + }, + { + "code":"451400", + "name":"崇左市", + "sub": + [ + { + "code":"451402", + "name":"江州区" + }, + { + "code":"451421", + "name":"扶绥县" + }, + { + "code":"451422", + "name":"宁明县" + }, + { + "code":"451423", + "name":"龙州县" + }, + { + "code":"451424", + "name":"大新县" + }, + { + "code":"451425", + "name":"天等县" + }, + { + "code":"451481", + "name":"凭祥市" + } + + ] + + } + + ] + + }, + { + "code":"460000", + "name":"海南省", + "sub": + [ + { + "code":"460100", + "name":"海口市", + "sub": + [ + { + "code":"460105", + "name":"秀英区" + }, + { + "code":"460106", + "name":"龙华区" + }, + { + "code":"460107", + "name":"琼山区" + }, + { + "code":"460108", + "name":"美兰区" + } + + ] + + }, + { + "code":"460200", + "name":"三亚市", + "sub": + [ + { + "code":"460202", + "name":"海棠区" + }, + { + "code":"460203", + "name":"吉阳区" + }, + { + "code":"460204", + "name":"天涯区" + }, + { + "code":"460205", + "name":"崖州区" + } + + ] + + }, + { + "code":"460300", + "name":"三沙市", + "sub": + [ + + ] + + }, + { + "code":"460400", + "name":"儋州市", + "sub": + [ + + ] + + } + + ] + + }, + { + "code":"500000", + "name":"重庆市", + "sub": + [ + { + "code":"500000", + "name":"重庆市", + "sub": + [ + { + "code":"500101", + "name":"万州区" + }, + { + "code":"500102", + "name":"涪陵区" + }, + { + "code":"500103", + "name":"渝中区" + }, + { + "code":"500104", + "name":"大渡口区" + }, + { + "code":"500105", + "name":"江北区" + }, + { + "code":"500106", + "name":"沙坪坝区" + }, + { + "code":"500107", + "name":"九龙坡区" + }, + { + "code":"500108", + "name":"南岸区" + }, + { + "code":"500109", + "name":"北碚区" + }, + { + "code":"500110", + "name":"綦江区" + }, + { + "code":"500111", + "name":"大足区" + }, + { + "code":"500112", + "name":"渝北区" + }, + { + "code":"500113", + "name":"巴南区" + }, + { + "code":"500114", + "name":"黔江区" + }, + { + "code":"500115", + "name":"长寿区" + }, + { + "code":"500116", + "name":"江津区" + }, + { + "code":"500117", + "name":"合川区" + }, + { + "code":"500118", + "name":"永川区" + }, + { + "code":"500119", + "name":"南川区" + }, + { + "code":"500120", + "name":"璧山区" + }, + { + "code":"500151", + "name":"铜梁区" + }, + { + "code":"500152", + "name":"潼南区" + }, + { + "code":"500153", + "name":"荣昌区" + }, + { + "code":"500154", + "name":"开州区" + }, + { + "code":"500155", + "name":"梁平区" + }, + { + "code":"500156", + "name":"武隆区" + }, + { + "code":"500229", + "name":"城口县" + }, + { + "code":"500230", + "name":"丰都县" + }, + { + "code":"500231", + "name":"垫江县" + }, + { + "code":"500233", + "name":"忠县" + }, + { + "code":"500235", + "name":"云阳县" + }, + { + "code":"500236", + "name":"奉节县" + }, + { + "code":"500237", + "name":"巫山县" + }, + { + "code":"500238", + "name":"巫溪县" + }, + { + "code":"500240", + "name":"石柱土家族自治县" + }, + { + "code":"500241", + "name":"秀山土家族苗族自治县" + }, + { + "code":"500242", + "name":"酉阳土家族苗族自治县" + }, + { + "code":"500243", + "name":"彭水苗族土家族自治县" + } + + ] + + } + + ] + + }, + { + "code":"510000", + "name":"四川省", + "sub": + [ + { + "code":"510100", + "name":"成都市", + "sub": + [ + { + "code":"510104", + "name":"锦江区" + }, + { + "code":"510105", + "name":"青羊区" + }, + { + "code":"510106", + "name":"金牛区" + }, + { + "code":"510107", + "name":"武侯区" + }, + { + "code":"510108", + "name":"成华区" + }, + { + "code":"510112", + "name":"龙泉驿区" + }, + { + "code":"510113", + "name":"青白江区" + }, + { + "code":"510114", + "name":"新都区" + }, + { + "code":"510115", + "name":"温江区" + }, + { + "code":"510116", + "name":"双流区" + }, + { + "code":"510117", + "name":"郫都区" + }, + { + "code":"510121", + "name":"金堂县" + }, + { + "code":"510129", + "name":"大邑县" + }, + { + "code":"510131", + "name":"蒲江县" + }, + { + "code":"510132", + "name":"新津县" + }, + { + "code":"510181", + "name":"都江堰市" + }, + { + "code":"510182", + "name":"彭州市" + }, + { + "code":"510183", + "name":"邛崃市" + }, + { + "code":"510184", + "name":"崇州市" + }, + { + "code":"510185", + "name":"简阳市" + } + + ] + + }, + { + "code":"510300", + "name":"自贡市", + "sub": + [ + { + "code":"510302", + "name":"自流井区" + }, + { + "code":"510303", + "name":"贡井区" + }, + { + "code":"510304", + "name":"大安区" + }, + { + "code":"510311", + "name":"沿滩区" + }, + { + "code":"510321", + "name":"荣县" + }, + { + "code":"510322", + "name":"富顺县" + } + + ] + + }, + { + "code":"510400", + "name":"攀枝花市", + "sub": + [ + { + "code":"510402", + "name":"东区" + }, + { + "code":"510403", + "name":"西区" + }, + { + "code":"510411", + "name":"仁和区" + }, + { + "code":"510421", + "name":"米易县" + }, + { + "code":"510422", + "name":"盐边县" + } + + ] + + }, + { + "code":"510500", + "name":"泸州市", + "sub": + [ + { + "code":"510502", + "name":"江阳区" + }, + { + "code":"510503", + "name":"纳溪区" + }, + { + "code":"510504", + "name":"龙马潭区" + }, + { + "code":"510521", + "name":"泸县" + }, + { + "code":"510522", + "name":"合江县" + }, + { + "code":"510524", + "name":"叙永县" + }, + { + "code":"510525", + "name":"古蔺县" + } + + ] + + }, + { + "code":"510600", + "name":"德阳市", + "sub": + [ + { + "code":"510603", + "name":"旌阳区" + }, + { + "code":"510604", + "name":"罗江区" + }, + { + "code":"510623", + "name":"中江县" + }, + { + "code":"510681", + "name":"广汉市" + }, + { + "code":"510682", + "name":"什邡市" + }, + { + "code":"510683", + "name":"绵竹市" + } + + ] + + }, + { + "code":"510700", + "name":"绵阳市", + "sub": + [ + { + "code":"510703", + "name":"涪城区" + }, + { + "code":"510704", + "name":"游仙区" + }, + { + "code":"510705", + "name":"安州区" + }, + { + "code":"510722", + "name":"三台县" + }, + { + "code":"510723", + "name":"盐亭县" + }, + { + "code":"510725", + "name":"梓潼县" + }, + { + "code":"510726", + "name":"北川羌族自治县" + }, + { + "code":"510727", + "name":"平武县" + }, + { + "code":"510781", + "name":"江油市" + } + + ] + + }, + { + "code":"510800", + "name":"广元市", + "sub": + [ + { + "code":"510802", + "name":"利州区" + }, + { + "code":"510811", + "name":"昭化区" + }, + { + "code":"510812", + "name":"朝天区" + }, + { + "code":"510821", + "name":"旺苍县" + }, + { + "code":"510822", + "name":"青川县" + }, + { + "code":"510823", + "name":"剑阁县" + }, + { + "code":"510824", + "name":"苍溪县" + } + + ] + + }, + { + "code":"510900", + "name":"遂宁市", + "sub": + [ + { + "code":"510903", + "name":"船山区" + }, + { + "code":"510904", + "name":"安居区" + }, + { + "code":"510921", + "name":"蓬溪县" + }, + { + "code":"510922", + "name":"射洪县" + }, + { + "code":"510923", + "name":"大英县" + } + + ] + + }, + { + "code":"511000", + "name":"内江市", + "sub": + [ + { + "code":"511002", + "name":"市中区" + }, + { + "code":"511011", + "name":"东兴区" + }, + { + "code":"511024", + "name":"威远县" + }, + { + "code":"511025", + "name":"资中县" + }, + { + "code":"511083", + "name":"隆昌市" + } + + ] + + }, + { + "code":"511100", + "name":"乐山市", + "sub": + [ + { + "code":"511102", + "name":"市中区" + }, + { + "code":"511111", + "name":"沙湾区" + }, + { + "code":"511112", + "name":"五通桥区" + }, + { + "code":"511113", + "name":"金口河区" + }, + { + "code":"511123", + "name":"犍为县" + }, + { + "code":"511124", + "name":"井研县" + }, + { + "code":"511126", + "name":"夹江县" + }, + { + "code":"511129", + "name":"沐川县" + }, + { + "code":"511132", + "name":"峨边彝族自治县" + }, + { + "code":"511133", + "name":"马边彝族自治县" + }, + { + "code":"511181", + "name":"峨眉山市" + } + + ] + + }, + { + "code":"511300", + "name":"南充市", + "sub": + [ + { + "code":"511302", + "name":"顺庆区" + }, + { + "code":"511303", + "name":"高坪区" + }, + { + "code":"511304", + "name":"嘉陵区" + }, + { + "code":"511321", + "name":"南部县" + }, + { + "code":"511322", + "name":"营山县" + }, + { + "code":"511323", + "name":"蓬安县" + }, + { + "code":"511324", + "name":"仪陇县" + }, + { + "code":"511325", + "name":"西充县" + }, + { + "code":"511381", + "name":"阆中市" + } + + ] + + }, + { + "code":"511400", + "name":"眉山市", + "sub": + [ + { + "code":"511402", + "name":"东坡区" + }, + { + "code":"511403", + "name":"彭山区" + }, + { + "code":"511421", + "name":"仁寿县" + }, + { + "code":"511423", + "name":"洪雅县" + }, + { + "code":"511424", + "name":"丹棱县" + }, + { + "code":"511425", + "name":"青神县" + } + + ] + + }, + { + "code":"511500", + "name":"宜宾市", + "sub": + [ + { + "code":"511502", + "name":"翠屏区" + }, + { + "code":"511503", + "name":"南溪区" + }, + { + "code":"511521", + "name":"宜宾县" + }, + { + "code":"511523", + "name":"江安县" + }, + { + "code":"511524", + "name":"长宁县" + }, + { + "code":"511525", + "name":"高县" + }, + { + "code":"511526", + "name":"珙县" + }, + { + "code":"511527", + "name":"筠连县" + }, + { + "code":"511528", + "name":"兴文县" + }, + { + "code":"511529", + "name":"屏山县" + } + + ] + + }, + { + "code":"511600", + "name":"广安市", + "sub": + [ + { + "code":"511602", + "name":"广安区" + }, + { + "code":"511603", + "name":"前锋区" + }, + { + "code":"511621", + "name":"岳池县" + }, + { + "code":"511622", + "name":"武胜县" + }, + { + "code":"511623", + "name":"邻水县" + }, + { + "code":"511681", + "name":"华蓥市" + } + + ] + + }, + { + "code":"511700", + "name":"达州市", + "sub": + [ + { + "code":"511702", + "name":"通川区" + }, + { + "code":"511703", + "name":"达川区" + }, + { + "code":"511722", + "name":"宣汉县" + }, + { + "code":"511723", + "name":"开江县" + }, + { + "code":"511724", + "name":"大竹县" + }, + { + "code":"511725", + "name":"渠县" + }, + { + "code":"511781", + "name":"万源市" + } + + ] + + }, + { + "code":"511800", + "name":"雅安市", + "sub": + [ + { + "code":"511802", + "name":"雨城区" + }, + { + "code":"511803", + "name":"名山区" + }, + { + "code":"511822", + "name":"荥经县" + }, + { + "code":"511823", + "name":"汉源县" + }, + { + "code":"511824", + "name":"石棉县" + }, + { + "code":"511825", + "name":"天全县" + }, + { + "code":"511826", + "name":"芦山县" + }, + { + "code":"511827", + "name":"宝兴县" + } + + ] + + }, + { + "code":"511900", + "name":"巴中市", + "sub": + [ + { + "code":"511902", + "name":"巴州区" + }, + { + "code":"511903", + "name":"恩阳区" + }, + { + "code":"511921", + "name":"通江县" + }, + { + "code":"511922", + "name":"南江县" + }, + { + "code":"511923", + "name":"平昌县" + } + + ] + + }, + { + "code":"512000", + "name":"资阳市", + "sub": + [ + { + "code":"512002", + "name":"雁江区" + }, + { + "code":"512021", + "name":"安岳县" + }, + { + "code":"512022", + "name":"乐至县" + } + + ] + + }, + { + "code":"513200", + "name":"阿坝藏族羌族自治州", + "sub": + [ + { + "code":"513201", + "name":"马尔康市" + }, + { + "code":"513221", + "name":"汶川县" + }, + { + "code":"513222", + "name":"理县" + }, + { + "code":"513223", + "name":"茂县" + }, + { + "code":"513224", + "name":"松潘县" + }, + { + "code":"513225", + "name":"九寨沟县" + }, + { + "code":"513226", + "name":"金川县" + }, + { + "code":"513227", + "name":"小金县" + }, + { + "code":"513228", + "name":"黑水县" + }, + { + "code":"513230", + "name":"壤塘县" + }, + { + "code":"513231", + "name":"阿坝县" + }, + { + "code":"513232", + "name":"若尔盖县" + }, + { + "code":"513233", + "name":"红原县" + } + + ] + + }, + { + "code":"513300", + "name":"甘孜藏族自治州", + "sub": + [ + { + "code":"513301", + "name":"康定市" + }, + { + "code":"513322", + "name":"泸定县" + }, + { + "code":"513323", + "name":"丹巴县" + }, + { + "code":"513324", + "name":"九龙县" + }, + { + "code":"513325", + "name":"雅江县" + }, + { + "code":"513326", + "name":"道孚县" + }, + { + "code":"513327", + "name":"炉霍县" + }, + { + "code":"513328", + "name":"甘孜县" + }, + { + "code":"513329", + "name":"新龙县" + }, + { + "code":"513330", + "name":"德格县" + }, + { + "code":"513331", + "name":"白玉县" + }, + { + "code":"513332", + "name":"石渠县" + }, + { + "code":"513333", + "name":"色达县" + }, + { + "code":"513334", + "name":"理塘县" + }, + { + "code":"513335", + "name":"巴塘县" + }, + { + "code":"513336", + "name":"乡城县" + }, + { + "code":"513337", + "name":"稻城县" + }, + { + "code":"513338", + "name":"得荣县" + } + + ] + + }, + { + "code":"513400", + "name":"凉山彝族自治州", + "sub": + [ + { + "code":"513401", + "name":"西昌市" + }, + { + "code":"513422", + "name":"木里藏族自治县" + }, + { + "code":"513423", + "name":"盐源县" + }, + { + "code":"513424", + "name":"德昌县" + }, + { + "code":"513425", + "name":"会理县" + }, + { + "code":"513426", + "name":"会东县" + }, + { + "code":"513427", + "name":"宁南县" + }, + { + "code":"513428", + "name":"普格县" + }, + { + "code":"513429", + "name":"布拖县" + }, + { + "code":"513430", + "name":"金阳县" + }, + { + "code":"513431", + "name":"昭觉县" + }, + { + "code":"513432", + "name":"喜德县" + }, + { + "code":"513433", + "name":"冕宁县" + }, + { + "code":"513434", + "name":"越西县" + }, + { + "code":"513435", + "name":"甘洛县" + }, + { + "code":"513436", + "name":"美姑县" + }, + { + "code":"513437", + "name":"雷波县" + } + + ] + + } + + ] + + }, + { + "code":"520000", + "name":"贵州省", + "sub": + [ + { + "code":"520100", + "name":"贵阳市", + "sub": + [ + { + "code":"520102", + "name":"南明区" + }, + { + "code":"520103", + "name":"云岩区" + }, + { + "code":"520111", + "name":"花溪区" + }, + { + "code":"520112", + "name":"乌当区" + }, + { + "code":"520113", + "name":"白云区" + }, + { + "code":"520115", + "name":"观山湖区" + }, + { + "code":"520121", + "name":"开阳县" + }, + { + "code":"520122", + "name":"息烽县" + }, + { + "code":"520123", + "name":"修文县" + }, + { + "code":"520181", + "name":"清镇市" + } + + ] + + }, + { + "code":"520200", + "name":"六盘水市", + "sub": + [ + { + "code":"520201", + "name":"钟山区" + }, + { + "code":"520203", + "name":"六枝特区" + }, + { + "code":"520221", + "name":"水城县" + }, + { + "code":"520281", + "name":"盘州市" + } + + ] + + }, + { + "code":"520300", + "name":"遵义市", + "sub": + [ + { + "code":"520302", + "name":"红花岗区" + }, + { + "code":"520303", + "name":"汇川区" + }, + { + "code":"520304", + "name":"播州区" + }, + { + "code":"520322", + "name":"桐梓县" + }, + { + "code":"520323", + "name":"绥阳县" + }, + { + "code":"520324", + "name":"正安县" + }, + { + "code":"520325", + "name":"道真仡佬族苗族自治县" + }, + { + "code":"520326", + "name":"务川仡佬族苗族自治县" + }, + { + "code":"520327", + "name":"凤冈县" + }, + { + "code":"520328", + "name":"湄潭县" + }, + { + "code":"520329", + "name":"余庆县" + }, + { + "code":"520330", + "name":"习水县" + }, + { + "code":"520381", + "name":"赤水市" + }, + { + "code":"520382", + "name":"仁怀市" + } + + ] + + }, + { + "code":"520400", + "name":"安顺市", + "sub": + [ + { + "code":"520402", + "name":"西秀区" + }, + { + "code":"520403", + "name":"平坝区" + }, + { + "code":"520422", + "name":"普定县" + }, + { + "code":"520423", + "name":"镇宁布依族苗族自治县" + }, + { + "code":"520424", + "name":"关岭布依族苗族自治县" + }, + { + "code":"520425", + "name":"紫云苗族布依族自治县" + } + + ] + + }, + { + "code":"520500", + "name":"毕节市", + "sub": + [ + { + "code":"520502", + "name":"七星关区" + }, + { + "code":"520521", + "name":"大方县" + }, + { + "code":"520522", + "name":"黔西县" + }, + { + "code":"520523", + "name":"金沙县" + }, + { + "code":"520524", + "name":"织金县" + }, + { + "code":"520525", + "name":"纳雍县" + }, + { + "code":"520526", + "name":"威宁彝族回族苗族自治县" + }, + { + "code":"520527", + "name":"赫章县" + } + + ] + + }, + { + "code":"520600", + "name":"铜仁市", + "sub": + [ + { + "code":"520602", + "name":"碧江区" + }, + { + "code":"520603", + "name":"万山区" + }, + { + "code":"520621", + "name":"江口县" + }, + { + "code":"520622", + "name":"玉屏侗族自治县" + }, + { + "code":"520623", + "name":"石阡县" + }, + { + "code":"520624", + "name":"思南县" + }, + { + "code":"520625", + "name":"印江土家族苗族自治县" + }, + { + "code":"520626", + "name":"德江县" + }, + { + "code":"520627", + "name":"沿河土家族自治县" + }, + { + "code":"520628", + "name":"松桃苗族自治县" + } + + ] + + }, + { + "code":"522300", + "name":"黔西南布依族苗族自治州", + "sub": + [ + { + "code":"522301", + "name":"兴义市" + }, + { + "code":"522322", + "name":"兴仁县" + }, + { + "code":"522323", + "name":"普安县" + }, + { + "code":"522324", + "name":"晴隆县" + }, + { + "code":"522325", + "name":"贞丰县" + }, + { + "code":"522326", + "name":"望谟县" + }, + { + "code":"522327", + "name":"册亨县" + }, + { + "code":"522328", + "name":"安龙县" + } + + ] + + }, + { + "code":"522600", + "name":"黔东南苗族侗族自治州", + "sub": + [ + { + "code":"522601", + "name":"凯里市" + }, + { + "code":"522622", + "name":"黄平县" + }, + { + "code":"522623", + "name":"施秉县" + }, + { + "code":"522624", + "name":"三穗县" + }, + { + "code":"522625", + "name":"镇远县" + }, + { + "code":"522626", + "name":"岑巩县" + }, + { + "code":"522627", + "name":"天柱县" + }, + { + "code":"522628", + "name":"锦屏县" + }, + { + "code":"522629", + "name":"剑河县" + }, + { + "code":"522630", + "name":"台江县" + }, + { + "code":"522631", + "name":"黎平县" + }, + { + "code":"522632", + "name":"榕江县" + }, + { + "code":"522633", + "name":"从江县" + }, + { + "code":"522634", + "name":"雷山县" + }, + { + "code":"522635", + "name":"麻江县" + }, + { + "code":"522636", + "name":"丹寨县" + } + + ] + + }, + { + "code":"522700", + "name":"黔南布依族苗族自治州", + "sub": + [ + { + "code":"522701", + "name":"都匀市" + }, + { + "code":"522702", + "name":"福泉市" + }, + { + "code":"522722", + "name":"荔波县" + }, + { + "code":"522723", + "name":"贵定县" + }, + { + "code":"522725", + "name":"瓮安县" + }, + { + "code":"522726", + "name":"独山县" + }, + { + "code":"522727", + "name":"平塘县" + }, + { + "code":"522728", + "name":"罗甸县" + }, + { + "code":"522729", + "name":"长顺县" + }, + { + "code":"522730", + "name":"龙里县" + }, + { + "code":"522731", + "name":"惠水县" + }, + { + "code":"522732", + "name":"三都水族自治县" + } + + ] + + } + + ] + + }, + { + "code":"530000", + "name":"云南省", + "sub": + [ + { + "code":"530100", + "name":"昆明市", + "sub": + [ + { + "code":"530102", + "name":"五华区" + }, + { + "code":"530103", + "name":"盘龙区" + }, + { + "code":"530111", + "name":"官渡区" + }, + { + "code":"530112", + "name":"西山区" + }, + { + "code":"530113", + "name":"东川区" + }, + { + "code":"530114", + "name":"呈贡区" + }, + { + "code":"530115", + "name":"晋宁区" + }, + { + "code":"530124", + "name":"富民县" + }, + { + "code":"530125", + "name":"宜良县" + }, + { + "code":"530126", + "name":"石林彝族自治县" + }, + { + "code":"530127", + "name":"嵩明县" + }, + { + "code":"530128", + "name":"禄劝彝族苗族自治县" + }, + { + "code":"530129", + "name":"寻甸回族彝族自治县" + }, + { + "code":"530181", + "name":"安宁市" + } + + ] + + }, + { + "code":"530300", + "name":"曲靖市", + "sub": + [ + { + "code":"530302", + "name":"麒麟区" + }, + { + "code":"530303", + "name":"沾益区" + }, + { + "code":"530321", + "name":"马龙县" + }, + { + "code":"530322", + "name":"陆良县" + }, + { + "code":"530323", + "name":"师宗县" + }, + { + "code":"530324", + "name":"罗平县" + }, + { + "code":"530325", + "name":"富源县" + }, + { + "code":"530326", + "name":"会泽县" + }, + { + "code":"530381", + "name":"宣威市" + } + + ] + + }, + { + "code":"530400", + "name":"玉溪市", + "sub": + [ + { + "code":"530402", + "name":"红塔区" + }, + { + "code":"530403", + "name":"江川区" + }, + { + "code":"530422", + "name":"澄江县" + }, + { + "code":"530423", + "name":"通海县" + }, + { + "code":"530424", + "name":"华宁县" + }, + { + "code":"530425", + "name":"易门县" + }, + { + "code":"530426", + "name":"峨山彝族自治县" + }, + { + "code":"530427", + "name":"新平彝族傣族自治县" + }, + { + "code":"530428", + "name":"元江哈尼族彝族傣族自治县" + } + + ] + + }, + { + "code":"530500", + "name":"保山市", + "sub": + [ + { + "code":"530502", + "name":"隆阳区" + }, + { + "code":"530521", + "name":"施甸县" + }, + { + "code":"530523", + "name":"龙陵县" + }, + { + "code":"530524", + "name":"昌宁县" + }, + { + "code":"530581", + "name":"腾冲市" + } + + ] + + }, + { + "code":"530600", + "name":"昭通市", + "sub": + [ + { + "code":"530602", + "name":"昭阳区" + }, + { + "code":"530621", + "name":"鲁甸县" + }, + { + "code":"530622", + "name":"巧家县" + }, + { + "code":"530623", + "name":"盐津县" + }, + { + "code":"530624", + "name":"大关县" + }, + { + "code":"530625", + "name":"永善县" + }, + { + "code":"530626", + "name":"绥江县" + }, + { + "code":"530627", + "name":"镇雄县" + }, + { + "code":"530628", + "name":"彝良县" + }, + { + "code":"530629", + "name":"威信县" + }, + { + "code":"530630", + "name":"水富县" + } + + ] + + }, + { + "code":"530700", + "name":"丽江市", + "sub": + [ + { + "code":"530702", + "name":"古城区" + }, + { + "code":"530721", + "name":"玉龙纳西族自治县" + }, + { + "code":"530722", + "name":"永胜县" + }, + { + "code":"530723", + "name":"华坪县" + }, + { + "code":"530724", + "name":"宁蒗彝族自治县" + } + + ] + + }, + { + "code":"530800", + "name":"普洱市", + "sub": + [ + { + "code":"530802", + "name":"思茅区" + }, + { + "code":"530821", + "name":"宁洱哈尼族彝族自治县" + }, + { + "code":"530822", + "name":"墨江哈尼族自治县" + }, + { + "code":"530823", + "name":"景东彝族自治县" + }, + { + "code":"530824", + "name":"景谷傣族彝族自治县" + }, + { + "code":"530825", + "name":"镇沅彝族哈尼族拉祜族自治县" + }, + { + "code":"530826", + "name":"江城哈尼族彝族自治县" + }, + { + "code":"530827", + "name":"孟连傣族拉祜族佤族自治县" + }, + { + "code":"530828", + "name":"澜沧拉祜族自治县" + }, + { + "code":"530829", + "name":"西盟佤族自治县" + } + + ] + + }, + { + "code":"530900", + "name":"临沧市", + "sub": + [ + { + "code":"530902", + "name":"临翔区" + }, + { + "code":"530921", + "name":"凤庆县" + }, + { + "code":"530922", + "name":"云县" + }, + { + "code":"530923", + "name":"永德县" + }, + { + "code":"530924", + "name":"镇康县" + }, + { + "code":"530925", + "name":"双江拉祜族佤族布朗族傣族自治县" + }, + { + "code":"530926", + "name":"耿马傣族佤族自治县" + }, + { + "code":"530927", + "name":"沧源佤族自治县" + } + + ] + + }, + { + "code":"532300", + "name":"楚雄彝族自治州", + "sub": + [ + { + "code":"532301", + "name":"楚雄市" + }, + { + "code":"532322", + "name":"双柏县" + }, + { + "code":"532323", + "name":"牟定县" + }, + { + "code":"532324", + "name":"南华县" + }, + { + "code":"532325", + "name":"姚安县" + }, + { + "code":"532326", + "name":"大姚县" + }, + { + "code":"532327", + "name":"永仁县" + }, + { + "code":"532328", + "name":"元谋县" + }, + { + "code":"532329", + "name":"武定县" + }, + { + "code":"532331", + "name":"禄丰县" + } + + ] + + }, + { + "code":"532500", + "name":"红河哈尼族彝族自治州", + "sub": + [ + { + "code":"532501", + "name":"个旧市" + }, + { + "code":"532502", + "name":"开远市" + }, + { + "code":"532503", + "name":"蒙自市" + }, + { + "code":"532504", + "name":"弥勒市" + }, + { + "code":"532523", + "name":"屏边苗族自治县" + }, + { + "code":"532524", + "name":"建水县" + }, + { + "code":"532525", + "name":"石屏县" + }, + { + "code":"532527", + "name":"泸西县" + }, + { + "code":"532528", + "name":"元阳县" + }, + { + "code":"532529", + "name":"红河县" + }, + { + "code":"532530", + "name":"金平苗族瑶族傣族自治县" + }, + { + "code":"532531", + "name":"绿春县" + }, + { + "code":"532532", + "name":"河口瑶族自治县" + } + + ] + + }, + { + "code":"532600", + "name":"文山壮族苗族自治州", + "sub": + [ + { + "code":"532601", + "name":"文山市" + }, + { + "code":"532622", + "name":"砚山县" + }, + { + "code":"532623", + "name":"西畴县" + }, + { + "code":"532624", + "name":"麻栗坡县" + }, + { + "code":"532625", + "name":"马关县" + }, + { + "code":"532626", + "name":"丘北县" + }, + { + "code":"532627", + "name":"广南县" + }, + { + "code":"532628", + "name":"富宁县" + } + + ] + + }, + { + "code":"532800", + "name":"西双版纳傣族自治州", + "sub": + [ + { + "code":"532801", + "name":"景洪市" + }, + { + "code":"532822", + "name":"勐海县" + }, + { + "code":"532823", + "name":"勐腊县" + } + + ] + + }, + { + "code":"532900", + "name":"大理白族自治州", + "sub": + [ + { + "code":"532901", + "name":"大理市" + }, + { + "code":"532922", + "name":"漾濞彝族自治县" + }, + { + "code":"532923", + "name":"祥云县" + }, + { + "code":"532924", + "name":"宾川县" + }, + { + "code":"532925", + "name":"弥渡县" + }, + { + "code":"532926", + "name":"南涧彝族自治县" + }, + { + "code":"532927", + "name":"巍山彝族回族自治县" + }, + { + "code":"532928", + "name":"永平县" + }, + { + "code":"532929", + "name":"云龙县" + }, + { + "code":"532930", + "name":"洱源县" + }, + { + "code":"532931", + "name":"剑川县" + }, + { + "code":"532932", + "name":"鹤庆县" + } + + ] + + }, + { + "code":"533100", + "name":"德宏傣族景颇族自治州", + "sub": + [ + { + "code":"533102", + "name":"瑞丽市" + }, + { + "code":"533103", + "name":"芒市" + }, + { + "code":"533122", + "name":"梁河县" + }, + { + "code":"533123", + "name":"盈江县" + }, + { + "code":"533124", + "name":"陇川县" + } + + ] + + }, + { + "code":"533300", + "name":"怒江傈僳族自治州", + "sub": + [ + { + "code":"533301", + "name":"泸水市" + }, + { + "code":"533323", + "name":"福贡县" + }, + { + "code":"533324", + "name":"贡山独龙族怒族自治县" + }, + { + "code":"533325", + "name":"兰坪白族普米族自治县" + } + + ] + + }, + { + "code":"533400", + "name":"迪庆藏族自治州", + "sub": + [ + { + "code":"533401", + "name":"香格里拉市" + }, + { + "code":"533422", + "name":"德钦县" + }, + { + "code":"533423", + "name":"维西傈僳族自治县" + } + + ] + + } + + ] + + }, + { + "code":"540000", + "name":"西藏自治区", + "sub": + [ + { + "code":"540100", + "name":"拉萨市", + "sub": + [ + { + "code":"540102", + "name":"城关区" + }, + { + "code":"540103", + "name":"堆龙德庆区" + }, + { + "code":"540104", + "name":"达孜区" + }, + { + "code":"540121", + "name":"林周县" + }, + { + "code":"540122", + "name":"当雄县" + }, + { + "code":"540123", + "name":"尼木县" + }, + { + "code":"540124", + "name":"曲水县" + }, + { + "code":"540127", + "name":"墨竹工卡县" + } + + ] + + }, + { + "code":"540200", + "name":"日喀则市", + "sub": + [ + { + "code":"540202", + "name":"桑珠孜区" + }, + { + "code":"540221", + "name":"南木林县" + }, + { + "code":"540222", + "name":"江孜县" + }, + { + "code":"540223", + "name":"定日县" + }, + { + "code":"540224", + "name":"萨迦县" + }, + { + "code":"540225", + "name":"拉孜县" + }, + { + "code":"540226", + "name":"昂仁县" + }, + { + "code":"540227", + "name":"谢通门县" + }, + { + "code":"540228", + "name":"白朗县" + }, + { + "code":"540229", + "name":"仁布县" + }, + { + "code":"540230", + "name":"康马县" + }, + { + "code":"540231", + "name":"定结县" + }, + { + "code":"540232", + "name":"仲巴县" + }, + { + "code":"540233", + "name":"亚东县" + }, + { + "code":"540234", + "name":"吉隆县" + }, + { + "code":"540235", + "name":"聂拉木县" + }, + { + "code":"540236", + "name":"萨嘎县" + }, + { + "code":"540237", + "name":"岗巴县" + } + + ] + + }, + { + "code":"540300", + "name":"昌都市", + "sub": + [ + { + "code":"540302", + "name":"卡若区" + }, + { + "code":"540321", + "name":"江达县" + }, + { + "code":"540322", + "name":"贡觉县" + }, + { + "code":"540323", + "name":"类乌齐县" + }, + { + "code":"540324", + "name":"丁青县" + }, + { + "code":"540325", + "name":"察雅县" + }, + { + "code":"540326", + "name":"八宿县" + }, + { + "code":"540327", + "name":"左贡县" + }, + { + "code":"540328", + "name":"芒康县" + }, + { + "code":"540329", + "name":"洛隆县" + }, + { + "code":"540330", + "name":"边坝县" + } + + ] + + }, + { + "code":"540400", + "name":"林芝市", + "sub": + [ + { + "code":"540402", + "name":"巴宜区" + }, + { + "code":"540421", + "name":"工布江达县" + }, + { + "code":"540422", + "name":"米林县" + }, + { + "code":"540423", + "name":"墨脱县" + }, + { + "code":"540424", + "name":"波密县" + }, + { + "code":"540425", + "name":"察隅县" + }, + { + "code":"540426", + "name":"朗县" + } + + ] + + }, + { + "code":"540500", + "name":"山南市", + "sub": + [ + { + "code":"540502", + "name":"乃东区" + }, + { + "code":"540521", + "name":"扎囊县" + }, + { + "code":"540522", + "name":"贡嘎县" + }, + { + "code":"540523", + "name":"桑日县" + }, + { + "code":"540524", + "name":"琼结县" + }, + { + "code":"540525", + "name":"曲松县" + }, + { + "code":"540526", + "name":"措美县" + }, + { + "code":"540527", + "name":"洛扎县" + }, + { + "code":"540528", + "name":"加查县" + }, + { + "code":"540529", + "name":"隆子县" + }, + { + "code":"540530", + "name":"错那县" + }, + { + "code":"540531", + "name":"浪卡子县" + } + + ] + + }, + { + "code":"540600", + "name":"那曲市", + "sub": + [ + { + "code":"540602", + "name":"色尼区" + }, + { + "code":"540621", + "name":"嘉黎县" + }, + { + "code":"540622", + "name":"比如县" + }, + { + "code":"540623", + "name":"聂荣县" + }, + { + "code":"540624", + "name":"安多县" + }, + { + "code":"540625", + "name":"申扎县" + }, + { + "code":"540626", + "name":"索县" + }, + { + "code":"540627", + "name":"班戈县" + }, + { + "code":"540628", + "name":"巴青县" + }, + { + "code":"540629", + "name":"尼玛县" + }, + { + "code":"540630", + "name":"双湖县" + } + + ] + + }, + { + "code":"542500", + "name":"阿里地区", + "sub": + [ + { + "code":"542521", + "name":"普兰县" + }, + { + "code":"542522", + "name":"札达县" + }, + { + "code":"542523", + "name":"噶尔县" + }, + { + "code":"542524", + "name":"日土县" + }, + { + "code":"542525", + "name":"革吉县" + }, + { + "code":"542526", + "name":"改则县" + }, + { + "code":"542527", + "name":"措勤县" + } + + ] + + } + + ] + + }, + { + "code":"610000", + "name":"陕西省", + "sub": + [ + { + "code":"610100", + "name":"西安市", + "sub": + [ + { + "code":"610102", + "name":"新城区" + }, + { + "code":"610103", + "name":"碑林区" + }, + { + "code":"610104", + "name":"莲湖区" + }, + { + "code":"610111", + "name":"灞桥区" + }, + { + "code":"610112", + "name":"未央区" + }, + { + "code":"610113", + "name":"雁塔区" + }, + { + "code":"610114", + "name":"阎良区" + }, + { + "code":"610115", + "name":"临潼区" + }, + { + "code":"610116", + "name":"长安区" + }, + { + "code":"610117", + "name":"高陵区" + }, + { + "code":"610118", + "name":"鄠邑区" + }, + { + "code":"610122", + "name":"蓝田县" + }, + { + "code":"610124", + "name":"周至县" + } + + ] + + }, + { + "code":"610200", + "name":"铜川市", + "sub": + [ + { + "code":"610202", + "name":"王益区" + }, + { + "code":"610203", + "name":"印台区" + }, + { + "code":"610204", + "name":"耀州区" + }, + { + "code":"610222", + "name":"宜君县" + } + + ] + + }, + { + "code":"610300", + "name":"宝鸡市", + "sub": + [ + { + "code":"610302", + "name":"渭滨区" + }, + { + "code":"610303", + "name":"金台区" + }, + { + "code":"610304", + "name":"陈仓区" + }, + { + "code":"610322", + "name":"凤翔县" + }, + { + "code":"610323", + "name":"岐山县" + }, + { + "code":"610324", + "name":"扶风县" + }, + { + "code":"610326", + "name":"眉县" + }, + { + "code":"610327", + "name":"陇县" + }, + { + "code":"610328", + "name":"千阳县" + }, + { + "code":"610329", + "name":"麟游县" + }, + { + "code":"610330", + "name":"凤县" + }, + { + "code":"610331", + "name":"太白县" + } + + ] + + }, + { + "code":"610400", + "name":"咸阳市", + "sub": + [ + { + "code":"610402", + "name":"秦都区" + }, + { + "code":"610403", + "name":"杨陵区" + }, + { + "code":"610404", + "name":"渭城区" + }, + { + "code":"610422", + "name":"三原县" + }, + { + "code":"610423", + "name":"泾阳县" + }, + { + "code":"610424", + "name":"乾县" + }, + { + "code":"610425", + "name":"礼泉县" + }, + { + "code":"610426", + "name":"永寿县" + }, + { + "code":"610427", + "name":"彬县" + }, + { + "code":"610428", + "name":"长武县" + }, + { + "code":"610429", + "name":"旬邑县" + }, + { + "code":"610430", + "name":"淳化县" + }, + { + "code":"610431", + "name":"武功县" + }, + { + "code":"610481", + "name":"兴平市" + } + + ] + + }, + { + "code":"610500", + "name":"渭南市", + "sub": + [ + { + "code":"610502", + "name":"临渭区" + }, + { + "code":"610503", + "name":"华州区" + }, + { + "code":"610522", + "name":"潼关县" + }, + { + "code":"610523", + "name":"大荔县" + }, + { + "code":"610524", + "name":"合阳县" + }, + { + "code":"610525", + "name":"澄城县" + }, + { + "code":"610526", + "name":"蒲城县" + }, + { + "code":"610527", + "name":"白水县" + }, + { + "code":"610528", + "name":"富平县" + }, + { + "code":"610581", + "name":"韩城市" + }, + { + "code":"610582", + "name":"华阴市" + } + + ] + + }, + { + "code":"610600", + "name":"延安市", + "sub": + [ + { + "code":"610602", + "name":"宝塔区" + }, + { + "code":"610603", + "name":"安塞区" + }, + { + "code":"610621", + "name":"延长县" + }, + { + "code":"610622", + "name":"延川县" + }, + { + "code":"610623", + "name":"子长县" + }, + { + "code":"610625", + "name":"志丹县" + }, + { + "code":"610626", + "name":"吴起县" + }, + { + "code":"610627", + "name":"甘泉县" + }, + { + "code":"610628", + "name":"富县" + }, + { + "code":"610629", + "name":"洛川县" + }, + { + "code":"610630", + "name":"宜川县" + }, + { + "code":"610631", + "name":"黄龙县" + }, + { + "code":"610632", + "name":"黄陵县" + } + + ] + + }, + { + "code":"610700", + "name":"汉中市", + "sub": + [ + { + "code":"610702", + "name":"汉台区" + }, + { + "code":"610703", + "name":"南郑区" + }, + { + "code":"610722", + "name":"城固县" + }, + { + "code":"610723", + "name":"洋县" + }, + { + "code":"610724", + "name":"西乡县" + }, + { + "code":"610725", + "name":"勉县" + }, + { + "code":"610726", + "name":"宁强县" + }, + { + "code":"610727", + "name":"略阳县" + }, + { + "code":"610728", + "name":"镇巴县" + }, + { + "code":"610729", + "name":"留坝县" + }, + { + "code":"610730", + "name":"佛坪县" + } + + ] + + }, + { + "code":"610800", + "name":"榆林市", + "sub": + [ + { + "code":"610802", + "name":"榆阳区" + }, + { + "code":"610803", + "name":"横山区" + }, + { + "code":"610822", + "name":"府谷县" + }, + { + "code":"610824", + "name":"靖边县" + }, + { + "code":"610825", + "name":"定边县" + }, + { + "code":"610826", + "name":"绥德县" + }, + { + "code":"610827", + "name":"米脂县" + }, + { + "code":"610828", + "name":"佳县" + }, + { + "code":"610829", + "name":"吴堡县" + }, + { + "code":"610830", + "name":"清涧县" + }, + { + "code":"610831", + "name":"子洲县" + }, + { + "code":"610881", + "name":"神木市" + } + + ] + + }, + { + "code":"610900", + "name":"安康市", + "sub": + [ + { + "code":"610902", + "name":"汉滨区" + }, + { + "code":"610921", + "name":"汉阴县" + }, + { + "code":"610922", + "name":"石泉县" + }, + { + "code":"610923", + "name":"宁陕县" + }, + { + "code":"610924", + "name":"紫阳县" + }, + { + "code":"610925", + "name":"岚皋县" + }, + { + "code":"610926", + "name":"平利县" + }, + { + "code":"610927", + "name":"镇坪县" + }, + { + "code":"610928", + "name":"旬阳县" + }, + { + "code":"610929", + "name":"白河县" + } + + ] + + }, + { + "code":"611000", + "name":"商洛市", + "sub": + [ + { + "code":"611002", + "name":"商州区" + }, + { + "code":"611021", + "name":"洛南县" + }, + { + "code":"611022", + "name":"丹凤县" + }, + { + "code":"611023", + "name":"商南县" + }, + { + "code":"611024", + "name":"山阳县" + }, + { + "code":"611025", + "name":"镇安县" + }, + { + "code":"611026", + "name":"柞水县" + } + + ] + + } + + ] + + }, + { + "code":"620000", + "name":"甘肃省", + "sub": + [ + { + "code":"620100", + "name":"兰州市", + "sub": + [ + { + "code":"620102", + "name":"城关区" + }, + { + "code":"620103", + "name":"七里河区" + }, + { + "code":"620104", + "name":"西固区" + }, + { + "code":"620105", + "name":"安宁区" + }, + { + "code":"620111", + "name":"红古区" + }, + { + "code":"620121", + "name":"永登县" + }, + { + "code":"620122", + "name":"皋兰县" + }, + { + "code":"620123", + "name":"榆中县" + } + + ] + + }, + { + "code":"620200", + "name":"嘉峪关市", + "sub": + [ + + ] + + }, + { + "code":"620300", + "name":"金昌市", + "sub": + [ + { + "code":"620302", + "name":"金川区" + }, + { + "code":"620321", + "name":"永昌县" + } + + ] + + }, + { + "code":"620400", + "name":"白银市", + "sub": + [ + { + "code":"620402", + "name":"白银区" + }, + { + "code":"620403", + "name":"平川区" + }, + { + "code":"620421", + "name":"靖远县" + }, + { + "code":"620422", + "name":"会宁县" + }, + { + "code":"620423", + "name":"景泰县" + } + + ] + + }, + { + "code":"620500", + "name":"天水市", + "sub": + [ + { + "code":"620502", + "name":"秦州区" + }, + { + "code":"620503", + "name":"麦积区" + }, + { + "code":"620521", + "name":"清水县" + }, + { + "code":"620522", + "name":"秦安县" + }, + { + "code":"620523", + "name":"甘谷县" + }, + { + "code":"620524", + "name":"武山县" + }, + { + "code":"620525", + "name":"张家川回族自治县" + } + + ] + + }, + { + "code":"620600", + "name":"武威市", + "sub": + [ + { + "code":"620602", + "name":"凉州区" + }, + { + "code":"620621", + "name":"民勤县" + }, + { + "code":"620622", + "name":"古浪县" + }, + { + "code":"620623", + "name":"天祝藏族自治县" + } + + ] + + }, + { + "code":"620700", + "name":"张掖市", + "sub": + [ + { + "code":"620702", + "name":"甘州区" + }, + { + "code":"620721", + "name":"肃南裕固族自治县" + }, + { + "code":"620722", + "name":"民乐县" + }, + { + "code":"620723", + "name":"临泽县" + }, + { + "code":"620724", + "name":"高台县" + }, + { + "code":"620725", + "name":"山丹县" + } + + ] + + }, + { + "code":"620800", + "name":"平凉市", + "sub": + [ + { + "code":"620802", + "name":"崆峒区" + }, + { + "code":"620821", + "name":"泾川县" + }, + { + "code":"620822", + "name":"灵台县" + }, + { + "code":"620823", + "name":"崇信县" + }, + { + "code":"620824", + "name":"华亭县" + }, + { + "code":"620825", + "name":"庄浪县" + }, + { + "code":"620826", + "name":"静宁县" + } + + ] + + }, + { + "code":"620900", + "name":"酒泉市", + "sub": + [ + { + "code":"620902", + "name":"肃州区" + }, + { + "code":"620921", + "name":"金塔县" + }, + { + "code":"620922", + "name":"瓜州县" + }, + { + "code":"620923", + "name":"肃北蒙古族自治县" + }, + { + "code":"620924", + "name":"阿克塞哈萨克族自治县" + }, + { + "code":"620981", + "name":"玉门市" + }, + { + "code":"620982", + "name":"敦煌市" + } + + ] + + }, + { + "code":"621000", + "name":"庆阳市", + "sub": + [ + { + "code":"621002", + "name":"西峰区" + }, + { + "code":"621021", + "name":"庆城县" + }, + { + "code":"621022", + "name":"环县" + }, + { + "code":"621023", + "name":"华池县" + }, + { + "code":"621024", + "name":"合水县" + }, + { + "code":"621025", + "name":"正宁县" + }, + { + "code":"621026", + "name":"宁县" + }, + { + "code":"621027", + "name":"镇原县" + } + + ] + + }, + { + "code":"621100", + "name":"定西市", + "sub": + [ + { + "code":"621102", + "name":"安定区" + }, + { + "code":"621121", + "name":"通渭县" + }, + { + "code":"621122", + "name":"陇西县" + }, + { + "code":"621123", + "name":"渭源县" + }, + { + "code":"621124", + "name":"临洮县" + }, + { + "code":"621125", + "name":"漳县" + }, + { + "code":"621126", + "name":"岷县" + } + + ] + + }, + { + "code":"621200", + "name":"陇南市", + "sub": + [ + { + "code":"621202", + "name":"武都区" + }, + { + "code":"621221", + "name":"成县" + }, + { + "code":"621222", + "name":"文县" + }, + { + "code":"621223", + "name":"宕昌县" + }, + { + "code":"621224", + "name":"康县" + }, + { + "code":"621225", + "name":"西和县" + }, + { + "code":"621226", + "name":"礼县" + }, + { + "code":"621227", + "name":"徽县" + }, + { + "code":"621228", + "name":"两当县" + } + + ] + + }, + { + "code":"622900", + "name":"临夏回族自治州", + "sub": + [ + { + "code":"622901", + "name":"临夏市" + }, + { + "code":"622921", + "name":"临夏县" + }, + { + "code":"622922", + "name":"康乐县" + }, + { + "code":"622923", + "name":"永靖县" + }, + { + "code":"622924", + "name":"广河县" + }, + { + "code":"622925", + "name":"和政县" + }, + { + "code":"622926", + "name":"东乡族自治县" + }, + { + "code":"622927", + "name":"积石山保安族东乡族撒拉族自治县" + } + + ] + + }, + { + "code":"623000", + "name":"甘南藏族自治州", + "sub": + [ + { + "code":"623001", + "name":"合作市" + }, + { + "code":"623021", + "name":"临潭县" + }, + { + "code":"623022", + "name":"卓尼县" + }, + { + "code":"623023", + "name":"舟曲县" + }, + { + "code":"623024", + "name":"迭部县" + }, + { + "code":"623025", + "name":"玛曲县" + }, + { + "code":"623026", + "name":"碌曲县" + }, + { + "code":"623027", + "name":"夏河县" + } + + ] + + } + + ] + + }, + { + "code":"630000", + "name":"青海省", + "sub": + [ + { + "code":"630100", + "name":"西宁市", + "sub": + [ + { + "code":"630102", + "name":"城东区" + }, + { + "code":"630103", + "name":"城中区" + }, + { + "code":"630104", + "name":"城西区" + }, + { + "code":"630105", + "name":"城北区" + }, + { + "code":"630121", + "name":"大通回族土族自治县" + }, + { + "code":"630122", + "name":"湟中县" + }, + { + "code":"630123", + "name":"湟源县" + } + + ] + + }, + { + "code":"630200", + "name":"海东市", + "sub": + [ + { + "code":"630202", + "name":"乐都区" + }, + { + "code":"630203", + "name":"平安区" + }, + { + "code":"630222", + "name":"民和回族土族自治县" + }, + { + "code":"630223", + "name":"互助土族自治县" + }, + { + "code":"630224", + "name":"化隆回族自治县" + }, + { + "code":"630225", + "name":"循化撒拉族自治县" + } + + ] + + }, + { + "code":"632200", + "name":"海北藏族自治州", + "sub": + [ + { + "code":"632221", + "name":"门源回族自治县" + }, + { + "code":"632222", + "name":"祁连县" + }, + { + "code":"632223", + "name":"海晏县" + }, + { + "code":"632224", + "name":"刚察县" + } + + ] + + }, + { + "code":"632300", + "name":"黄南藏族自治州", + "sub": + [ + { + "code":"632321", + "name":"同仁县" + }, + { + "code":"632322", + "name":"尖扎县" + }, + { + "code":"632323", + "name":"泽库县" + }, + { + "code":"632324", + "name":"河南蒙古族自治县" + } + + ] + + }, + { + "code":"632500", + "name":"海南藏族自治州", + "sub": + [ + { + "code":"632521", + "name":"共和县" + }, + { + "code":"632522", + "name":"同德县" + }, + { + "code":"632523", + "name":"贵德县" + }, + { + "code":"632524", + "name":"兴海县" + }, + { + "code":"632525", + "name":"贵南县" + } + + ] + + }, + { + "code":"632600", + "name":"果洛藏族自治州", + "sub": + [ + { + "code":"632621", + "name":"玛沁县" + }, + { + "code":"632622", + "name":"班玛县" + }, + { + "code":"632623", + "name":"甘德县" + }, + { + "code":"632624", + "name":"达日县" + }, + { + "code":"632625", + "name":"久治县" + }, + { + "code":"632626", + "name":"玛多县" + } + + ] + + }, + { + "code":"632700", + "name":"玉树藏族自治州", + "sub": + [ + { + "code":"632701", + "name":"玉树市" + }, + { + "code":"632722", + "name":"杂多县" + }, + { + "code":"632723", + "name":"称多县" + }, + { + "code":"632724", + "name":"治多县" + }, + { + "code":"632725", + "name":"囊谦县" + }, + { + "code":"632726", + "name":"曲麻莱县" + } + + ] + + }, + { + "code":"632800", + "name":"海西蒙古族藏族自治州", + "sub": + [ + { + "code":"632801", + "name":"格尔木市" + }, + { + "code":"632802", + "name":"德令哈市" + }, + { + "code":"632821", + "name":"乌兰县" + }, + { + "code":"632822", + "name":"都兰县" + }, + { + "code":"632823", + "name":"天峻县" + } + + ] + + } + + ] + + }, + { + "code":"640000", + "name":"宁夏回族自治区", + "sub": + [ + { + "code":"640100", + "name":"银川市", + "sub": + [ + { + "code":"640104", + "name":"兴庆区" + }, + { + "code":"640105", + "name":"西夏区" + }, + { + "code":"640106", + "name":"金凤区" + }, + { + "code":"640121", + "name":"永宁县" + }, + { + "code":"640122", + "name":"贺兰县" + }, + { + "code":"640181", + "name":"灵武市" + } + + ] + + }, + { + "code":"640200", + "name":"石嘴山市", + "sub": + [ + { + "code":"640202", + "name":"大武口区" + }, + { + "code":"640205", + "name":"惠农区" + }, + { + "code":"640221", + "name":"平罗县" + } + + ] + + }, + { + "code":"640300", + "name":"吴忠市", + "sub": + [ + { + "code":"640302", + "name":"利通区" + }, + { + "code":"640303", + "name":"红寺堡区" + }, + { + "code":"640323", + "name":"盐池县" + }, + { + "code":"640324", + "name":"同心县" + }, + { + "code":"640381", + "name":"青铜峡市" + } + + ] + + }, + { + "code":"640400", + "name":"固原市", + "sub": + [ + { + "code":"640402", + "name":"原州区" + }, + { + "code":"640422", + "name":"西吉县" + }, + { + "code":"640423", + "name":"隆德县" + }, + { + "code":"640424", + "name":"泾源县" + }, + { + "code":"640425", + "name":"彭阳县" + } + + ] + + }, + { + "code":"640500", + "name":"中卫市", + "sub": + [ + { + "code":"640502", + "name":"沙坡头区" + }, + { + "code":"640521", + "name":"中宁县" + }, + { + "code":"640522", + "name":"海原县" + } + + ] + + } + + ] + + }, + { + "code":"650000", + "name":"新疆维吾尔自治区", + "sub": + [ + { + "code":"650100", + "name":"乌鲁木齐市", + "sub": + [ + { + "code":"650102", + "name":"天山区" + }, + { + "code":"650103", + "name":"沙依巴克区" + }, + { + "code":"650104", + "name":"新市区" + }, + { + "code":"650105", + "name":"水磨沟区" + }, + { + "code":"650106", + "name":"头屯河区" + }, + { + "code":"650107", + "name":"达坂城区" + }, + { + "code":"650109", + "name":"米东区" + }, + { + "code":"650121", + "name":"乌鲁木齐县" + } + + ] + + }, + { + "code":"650200", + "name":"克拉玛依市", + "sub": + [ + { + "code":"650202", + "name":"独山子区" + }, + { + "code":"650203", + "name":"克拉玛依区" + }, + { + "code":"650204", + "name":"白碱滩区" + }, + { + "code":"650205", + "name":"乌尔禾区" + } + + ] + + }, + { + "code":"650400", + "name":"吐鲁番市", + "sub": + [ + { + "code":"650402", + "name":"高昌区" + }, + { + "code":"650421", + "name":"鄯善县" + }, + { + "code":"650422", + "name":"托克逊县" + } + + ] + + }, + { + "code":"650500", + "name":"哈密市", + "sub": + [ + { + "code":"650502", + "name":"伊州区" + }, + { + "code":"650521", + "name":"巴里坤哈萨克自治县" + }, + { + "code":"650522", + "name":"伊吾县" + } + + ] + + }, + { + "code":"652300", + "name":"昌吉回族自治州", + "sub": + [ + { + "code":"652301", + "name":"昌吉市" + }, + { + "code":"652302", + "name":"阜康市" + }, + { + "code":"652323", + "name":"呼图壁县" + }, + { + "code":"652324", + "name":"玛纳斯县" + }, + { + "code":"652325", + "name":"奇台县" + }, + { + "code":"652327", + "name":"吉木萨尔县" + }, + { + "code":"652328", + "name":"木垒哈萨克自治县" + } + + ] + + }, + { + "code":"652700", + "name":"博尔塔拉蒙古自治州", + "sub": + [ + { + "code":"652701", + "name":"博乐市" + }, + { + "code":"652702", + "name":"阿拉山口市" + }, + { + "code":"652722", + "name":"精河县" + }, + { + "code":"652723", + "name":"温泉县" + } + + ] + + }, + { + "code":"652800", + "name":"巴音郭楞蒙古自治州", + "sub": + [ + { + "code":"652801", + "name":"库尔勒市" + }, + { + "code":"652822", + "name":"轮台县" + }, + { + "code":"652823", + "name":"尉犁县" + }, + { + "code":"652824", + "name":"若羌县" + }, + { + "code":"652825", + "name":"且末县" + }, + { + "code":"652826", + "name":"焉耆回族自治县" + }, + { + "code":"652827", + "name":"和静县" + }, + { + "code":"652828", + "name":"和硕县" + }, + { + "code":"652829", + "name":"博湖县" + } + + ] + + }, + { + "code":"652900", + "name":"阿克苏地区", + "sub": + [ + { + "code":"652901", + "name":"阿克苏市" + }, + { + "code":"652922", + "name":"温宿县" + }, + { + "code":"652923", + "name":"库车县" + }, + { + "code":"652924", + "name":"沙雅县" + }, + { + "code":"652925", + "name":"新和县" + }, + { + "code":"652926", + "name":"拜城县" + }, + { + "code":"652927", + "name":"乌什县" + }, + { + "code":"652928", + "name":"阿瓦提县" + }, + { + "code":"652929", + "name":"柯坪县" + } + + ] + + }, + { + "code":"653000", + "name":"克孜勒苏柯尔克孜自治州", + "sub": + [ + { + "code":"653001", + "name":"阿图什市" + }, + { + "code":"653022", + "name":"阿克陶县" + }, + { + "code":"653023", + "name":"阿合奇县" + }, + { + "code":"653024", + "name":"乌恰县" + } + + ] + + }, + { + "code":"653100", + "name":"喀什地区", + "sub": + [ + { + "code":"653101", + "name":"喀什市" + }, + { + "code":"653121", + "name":"疏附县" + }, + { + "code":"653122", + "name":"疏勒县" + }, + { + "code":"653123", + "name":"英吉沙县" + }, + { + "code":"653124", + "name":"泽普县" + }, + { + "code":"653125", + "name":"莎车县" + }, + { + "code":"653126", + "name":"叶城县" + }, + { + "code":"653127", + "name":"麦盖提县" + }, + { + "code":"653128", + "name":"岳普湖县" + }, + { + "code":"653129", + "name":"伽师县" + }, + { + "code":"653130", + "name":"巴楚县" + }, + { + "code":"653131", + "name":"塔什库尔干塔吉克自治县" + } + + ] + + }, + { + "code":"653200", + "name":"和田地区", + "sub": + [ + { + "code":"653201", + "name":"和田市" + }, + { + "code":"653221", + "name":"和田县" + }, + { + "code":"653222", + "name":"墨玉县" + }, + { + "code":"653223", + "name":"皮山县" + }, + { + "code":"653224", + "name":"洛浦县" + }, + { + "code":"653225", + "name":"策勒县" + }, + { + "code":"653226", + "name":"于田县" + }, + { + "code":"653227", + "name":"民丰县" + } + + ] + + }, + { + "code":"654000", + "name":"伊犁哈萨克自治州", + "sub": + [ + { + "code":"654002", + "name":"伊宁市" + }, + { + "code":"654003", + "name":"奎屯市" + }, + { + "code":"654004", + "name":"霍尔果斯市" + }, + { + "code":"654021", + "name":"伊宁县" + }, + { + "code":"654022", + "name":"察布查尔锡伯自治县" + }, + { + "code":"654023", + "name":"霍城县" + }, + { + "code":"654024", + "name":"巩留县" + }, + { + "code":"654025", + "name":"新源县" + }, + { + "code":"654026", + "name":"昭苏县" + }, + { + "code":"654027", + "name":"特克斯县" + }, + { + "code":"654028", + "name":"尼勒克县" + } + + ] + + }, + { + "code":"654200", + "name":"塔城地区", + "sub": + [ + { + "code":"654201", + "name":"塔城市" + }, + { + "code":"654202", + "name":"乌苏市" + }, + { + "code":"654221", + "name":"额敏县" + }, + { + "code":"654223", + "name":"沙湾县" + }, + { + "code":"654224", + "name":"托里县" + }, + { + "code":"654225", + "name":"裕民县" + }, + { + "code":"654226", + "name":"和布克赛尔蒙古自治县" + } + + ] + + }, + { + "code":"654300", + "name":"阿勒泰地区", + "sub": + [ + { + "code":"654301", + "name":"阿勒泰市" + }, + { + "code":"654321", + "name":"布尔津县" + }, + { + "code":"654322", + "name":"富蕴县" + }, + { + "code":"654323", + "name":"福海县" + }, + { + "code":"654324", + "name":"哈巴河县" + }, + { + "code":"654325", + "name":"青河县" + }, + { + "code":"654326", + "name":"吉木乃县" + } + + ] + + } + + ] + + }, + { + "code":"710000", + "name":"台湾省", + "sub": + [ + { + "code":"710000", + "name":"台湾省", + "sub": + [ + { + "code":"710000", + "name":"台湾省" + } + + ] + + } + + ] + + }, + { + "code":"810000", + "name":"香港特别行政区", + "sub": + [ + { + "code":"810000", + "name":"香港特别行政区", + "sub": + [ + { + "code":"810000", + "name":"香港特别行政区" + } + + ] + + } + + ] + + }, + { + "code":"820000", + "name":"澳门特别行政区", + "sub": + [ + { + "code":"820000", + "name":"澳门特别行政区", + "sub": + [ + { + "code":"820000", + "name":"澳门特别行政区" + } + + ] + + } + + ] + + } + +] \ No newline at end of file diff --git a/assets/images/logo.png b/assets/images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..5849376f0f1c54a765922a4d716ad721b10d78d8 GIT binary patch literal 8505 zcma)C^;gu*+y3m*jdZ7kbf<)blnYCTG)hZLNG*aOAxKDfOP2yt3rI-q($Xm*4KB^{ z<$3>t_lKD|bDueLuDNICj%y|ws;x$ZPlpcx0Fj2eirzyD|F7U;Kh%1Qt~da|3TUW2 zGw{zo$ieY>IU6u=PYpct2oH?Tq;lfdO65x!)L&-zrK|1#^)Uo zq?xyRd5`1io1G@a7JSzq4X}T2FMFlcQ2LD{CQ$G*%gHz0=sS?VNysjerC8PKo80$&c{+@hv@%CWAIXT|QZP3l3gn7$j1Qv*n($dK zgQh;3XwQgM6T{1ZMa+C!7=P+4FNMY{Ot==n1RS@J+KSkK^PSA3VX~mbzj=QsV_x*( zSj)l$G5qy#y9Z7PV5Lsf7?+P=eRv%&KCrcVtlpEs8I?YcrDG{hid9HjfsZMHsVho+ z-UbplO6j9t5fuQLJOzTN;VYbXrxY=plN;3Uw;d>f`;kyV*uoU2aiZ!!P>VV4Nxv)D zLl$<_=gg|d%?B3-=p#tgz&NEuUXV#`j5<6;T{8il0(E&E&}BYpPb^fnv(!ldP%sF$ zO8jFEUQK?)`vC3frCrH2w-?M87>*#l^TI-kjqXYY@N`%u5=?a4VZveF(g8>5v{wObRXD=D&f=SAS0ms1*(jff)?IN0sE$Tr@P6` z>l$LAUNET2Akn4q&}wi4A9g!&Kd|M1^C##irDN%CW3r#)!Pv@l!TEzuDFC+@9S86r zGV~l)6oZ6Ih=)BnqABI<7y85ar<)u>5XiJYru6#^e-rY1->Jirw+NGE)G?DppSF!d7I}$V=?yFy7T6lkr_@_FBYr8y8%~j zkosi)HVgB?a>96SI1Te(*j85D2_)z=`0~ez4M~4g__sV)U#@J9Z`|B@2|Y1b9$&=E z4SO&PoaQ%x8Zp^tiBjMCC3m|$wwVYSSBMeTZ45s()NrL@E1-JK;J&xRtAm2Nyvn$m zfF8`RiWy^Ix!ZbA;tA&N`@ed+5Ya`*UUBB*h=#BS9Bh^dmTQuZu!3{OV@N}xUnI*u zcU3yc#e31>n3UmiIYqK#NWdT0mB7`KbnMR2L=qgyroL$pDkHYqe8Twm4dkgztoz6y z{bLAZ@FM3Qp|x!>_42r7Ygsx2bBvo{0pwT5fFt4=#dK4?*DvWo-l{>3X>~ls_XXl* zE{H}NX4lxix9vS%6b5`Mx_@jp(`I@P`95E1j1%>j=Fb^q^%OhcZiFAu94tqTFZZTr zTK~Gupxs>zug_KH&gvrmLofy#?FO^9hU%#Kz|!gpS?f z2;t9^V_fpd1uEfAv_H0Q&?}V*FMRm@2&c^0XB-iF* z$Y4?qll9BJjw@&!{iV!zA=48C#kqOW^)k22565u)+n$p@c0yL}wwGjK2yL1asTzp@$*A>2qb$^Wv2Sab2EPt#+9kZ58EH#*;_mOsr?bwmsn`mUu9D#)*-Zo z_WKN)-4n!Ze|T5EH*-aOF<)r)8Xw$@%j zm~R%Cx~DEbubnj=PgVagGYhPWAng%sksLB%Wsh9&D~#e?{#>{3s)(nU;&HyWh~n~B z`|Zx3)KEGJUGhqdYp!Qg!re_SxANxS=Tw4!I&&?xtbD+ZFSUo-57m8V@`<}fn$}PY zffyWhtCEU!K`OF((E0qD2-=yyqi8SMmrP^i27P?ry|Ga83yEuN%3hIXkx3b@P)9Zh zxgDw`G8W}L^5B>ve_NFxKD)<_d}}BZ%DILj95i)t4l>aZE#94|9X`d`-b$b~I899> zX=ArSUlKag@6K~6XMD1~IJoMYm{0!1sbm~L&Z8qkvJ`mFaI$E!@`&*5zKYYGrb$i} zIqd5%Pyg$LU(8NbN99vJwwNN>7t1$R+LlwPv!4v9^TiN8^Qgv8_IcCaK5mAfM12BG#;(!)n| zk>KHe)z#qd)K!EaT5dYSYpt8MpO4)nz1k*1{OFirozuAwNnQ&O#ht4Yk-=z zsZD)LwNyr+P8-hu_5)e>X^DCn1EJ7Pcv4_t+`Tv#*YoktgGbWCBbdBJt=rYgx}y^j z2C4+@xvy~><(@n?GkIBlv05oo&zX{VcvpT3dG=d0L`rpa@LvPl!2%j~Lc2b_8)qxx z=hVwH>G)jLwr-oc2rj2%wyb31!4k@DRMWI z>HAe)SS=&I`F5QSH;caw3ugZiwL})@~T-M>ILxpXnM$83)k-@HiC>*3I zN4W@YzoVK9;Y$&xsty`f%4}S{O$2cD3y_oaN0t1F06U{ndV7qT2J8{pJ9|REgD(Y- zM&i7sVoRKVk{vDXc$Iv9*A@4jF9X{~@^ z?Bt6JGi{KTRQ^C2s}~{{vs3V=v~Xvne5~Y3w5x?Dy%y3DYO8YOu<-WM#Nx4R-;NcU?uqyiY zhs}`7Ks?v0POm=`1Y5-%#fCtntS>E7S?4y-8}%y`_XHPd&svJc`G+sD#mS#CL2a|? zsdiIAUdbafQ1M~G*afky?@-*oAM6eLHR+5vrvH@;nn%=W;Lr{?NamgkQy5C#@cPj7 ze$Lo9x-~KaqRU+E=AY9xR+%_TF#H&lCg=8|XT>Vep0SwVeUl~o%t_+S!u98Ds2wHv zrA(F<&2GTrrCG;J9GfSU+}eF3U{8UE(aydSRIR}%%OpuSJ*E!Irycp&qv^7h%9GTl zwpu*+5%_x}kqi-(oOl=#?!fYcILC<+I7;^no4ytKc4e4esmg(>MAmJ++gR@~$7^TG zP;x1@bch3>I(80y9yCDT781WNt}jq)?+&6K*SkI^lx;uZnC-gT2>!rfL~h#GT}p9+4Yt5lBqYT$yTd6@$1lZ>SiU zlB>4cMO)hV*5dsO#w}@Dqc9L`8-cdfl}tDN@z8Kk3v1IQ3hB%4C)-o{B3>gLl#3N) z`eF{3(mj8Wj@+p)$(x8@c|>hT_A+xV(&Qo7b+I-lM1bN@s;RRE58*El zjRw?w$@VxdGR!V(n}&F)lUnL{K5jhVNmy%vivpn(IgM!3oyO|bO?ZOBC8hQF_2sh5 ztT$YAMQbFGBdhEX>)cgyEKo8J!`zozuNuFVI`@zyE{jQXREqWJH(boY#TAVFPVRrh zTFi(KkI^FM0_?)!E(#>_mUV7!7``vn*`qT#GGs3sQ*M)FC=3LCI$of0w|TvxL)5vn zWk7KQxh_ut$^quU&27HDM0OwO`DGvXm$ykMdlELik%GT>P_0D}w#Rf@8MEC2S0nzy zvS&Zz-i4DlLR30o4JUb*KcU>O#hjiMc-7)fvUsMWkm#Otp1s=Sy$Z1&nCu>KsR1p1 zI_X-W@4{WKHN)kT#z8iB|?Hs4+&j*$(O!pR-Nn71ZqhkeNHJ3yDo_kXhZjpE_zj02908$tuH74I3*#8)$l{HA)DvGgPem9(ZGjA;IgpKL=n?nCRh)cs%}>9ocQJ?P!gj>sSds~U2t;seb=9ctQAqh9)0 zwuLD26|l@Xc7k{{_UWDQ)&{%ck#4@_5xE4rD%$a9bpl4tO?Wu35B8CCVXfIAw>P7d zV^LqZnOO_tQ?=p2ZEE9TU(-$vMAHU&uLalPb}ryi8uE9gtdgh9954Hx_AUfrVO&-rWos3wxl9L7{ILR8MVa&u^2QGlZD&Wi1*= zumZbYU&zqJ&tg2ie!F^?R<=-qxbZnYNmYZPH?Db32HY|3uy^0;C;zCvH@*iLmMSXs z!ZryUSUt8N=(BeCRurm>Js57cf~`5)Zi1J zmk>nKvQE%{)O;#peNPWx8@<+CZ+_;m`B>Ll&&RjPN|KnAj$j0hQ_}=0v&FVHvgJZ9 zz|8@vMxu)zy(#{jfMgBUmUJajtV#i#_#;fJDlF|zI1AOTAdH#`$*liP`kzGIvDn!~ z+p*E=ZeZA_X0Y2Cm9o?8s}JMSJ*m0LC-HT2eAZzR0p1|%5k%Om%@cupaGeho@9^+F zV@@u;HG%*>xsr3&uGU4hbaOZ2{zk<1jtFkb9E+Z((3SofcYM_=-rz!#pE=0QCiE45 zO)_Y6j0uiwM8lhxw(OGM`$mAM)Tv_WqT=eTR}LPGeZv^}L^3bjbzp!+9%CP!uiy>h zt{#^RKT*QllVyryA!PI^Iq{{<{s7f~ErwL`zVrKR6!I1a*(%sE`?>GMhk!5)r1H~7 zAp6d9>GY8xp=kc(6EUtZUr#r1GUZO62Zrjc>o0i)xB=XY@_Bw|`PIjI>GefcN5xRt zzOgy^NqD6T*?a>6wBJL-NSnfc`Q_x?QvMPUN>TwO$jks)=! zOo)2IH^k5G`%B3%4EM+P-hVU0=npma1Fsi|R2gEqXvPv5@q`Od@1KTa7x4A?vhj=5 zgN1}k5w{jL;`3Pu?6H<7qV|mQt)s3>yQ_jns{J0C=Z}AqJ`bZaWTlpfdXcJ81qiLi zo*b(!XN0ude5kp=Q6dn@iQ%-i1bZ|62C|)!Vp4jEQ~1){%p>M<6+!vL*VlsoaDPE* z0+qdAvQ@K}P)^GDPNQ}vs_IrGj2c$~@Oh{>p}33KVvkYq}F zUSk^8KsbhWO|^+=b6UbXa|i+}k}x+Gr*o6xA8av*MaT8oJgS*=M@s$%(TIJ&dSrpD zD#WiBvlSU;e@<}WwAjeFfX%Q_6IJYbiFq8x4M`P5n>3$eRaj=<&+S;I^;|1w-`x;* zxJlhio*3#;w+g=f`6?;sPw2nw8K`qAIE|}&1_A244u_Awnapvin(wER|HMmn6EGL! zKy$f7japSPYQ%+h{FI98NkQVNy?Ry6>Kn=6Mv|3%*RowJxv#YOabD!?u&A4s1(+m} zz9b)CNCWp%Mm9(5!g4lgjepkdLoc5i7SMMVWxcF+#j?$O<@i3h{rI9lbBHzop;<{@>4Rk%-v<9gE_z}#ps(6 z%t}e9g{#Te`)b>yWYu#WU1IbDbyiAml_^KSOP$df;tR5VWp%mVa~!#p^{>K#VbSjH zI$_B4rNFZ%xE8AkIu525u>nsKUetj<^om4BlL6N+_Z8A>79MhWX|=;-{~}5GSlT_= zxWgp`P2_N$jiuokmZ*f4pNcrL$Hq~qr~IpoC|p! zSuqt$^0!1=#6v&T$0RT6lR(RF-7L1Rp^LEc@rP62c$DT)a z-sxWUOuF5%BE*LU!tGLnZDYJ}?~E{(H&+EF9e9Y{#-Qy85F%`B`>OX;5Rs<9Sxu?5 zqUC`+3X-^-IqE8&J#>;r$drz&^uT%yeBw*emg zTO*?snnsHD6}0yaj~R!<^yQvZAgiT)tp89qbOi^6Pj8WYb6f%uVu}PuFr^j@W^^ko z))QE?<%(oqVQ}g!+v^1vQUk=U#3rS1&&VyJoT$Gvhy{J@y$N@_tZeqVw2+6H24p#4CQE zik7I60Nxi`OXB!E;B=UP(Q#K#`gh;S`a)R0;Sahmu@wmOy5AuhlVY^%$c_u?-hMw2 zer0ODeV<`nA8De{O@_CIqXrej@fu2oytOAKLVATv15vPRMBf)zm)Hkg2;7l77Af6f z5ick&#d^q{6m*Yy%nVp_Jj%5#{OObWb=>fbz8IyCUB+$OsC%!hNeT-;?Y-b|<#sWP z9pcy?iWjVB6N3b12jkg=lqe-egeiy%$0gPT)3`1ST4N+aHw>RTj=%_3HA% z*bc&TONOL>N6q`HGlmYi)cnO9Re6g$!!TynYvbj=~=7^6r8V)FJ{^xbWrS|ZsyTmbSI**Bs))V^c%LE-^2HN z9CPMu4esEo)6SzKr3p~rk!T$%+&-|77lXdV^~7GHZ_9$#GPu9WnMO)ICVQaRBPJT} zRSB60Dv4>R!DC6zDWflA*E6Yp-%V$ee|o^Al<`WgtmTnjtzRp>?nIqth0J$M#O2y% zDc~V0(WD?UF|CVBA^rq$%J86?sCu*=^2NGOoWwU`<0s}NL?UtBf85?LnM!dDYx8ZE zGQ%+Lzb%AC*Ub$rfvc{{QaHUF9E!I*<&<6Wi82W-sJ$kKs`w;It>1*4AH6PeHa0Dy z0REnTrTbT~xrNWNw#DZfo(dy+`Mk<)qr>%6p@Oer1C#ZTyd`cJ*pHoVHbkZQI*w@2 zG#7O#-PkCuD}()mR+NeMNRx!x#;UQi?GY)*!UH51@dR}Pr>}p zr^^PKZM0R^0IH_UN&&e(nHOk*m_@GR&`hEub&t2wp8OIlwNptmjU!aBu#O+{vc^Nk z;L$u>`P@^$eTDH#`G+wu-22Xoh*_c&8@);qKWE0bNeOyub2W5y|G2zpK=#r&6mL-Q zV%7qmUv#6kkHYx`-}NJkx>2n>M9z*8-ext45Ol#dT9R&5^cI;%1zWRJor+x03@6hl zh!(_$Q61Yj!p{R? ziFT~B5)|!SH=tlR4!1L=QD~yRJXDOtV;WG#fpuQ|b6FP_z05%!#N96Q=zQDU&$|M{ zndh18elk1sl85(d*73zfw}m>F$|z+D#E~a1k#Z#4=%$X51=oM>lfZ4D9v#OQXBd|UATK3B=Ga% zr)K@(UgE&lo_5axJ|!WXidhVt(8^l6oLbOX?TmL_6vsw}KQ{wsWka|4WqB`AetzMT7Tfplds#OO8)=fk-i3pNKAL|p|EB09TJ>V91~B*fI{Tc~;O%>ush|U? z;;88lN-o6<``}(pp4q=^q@Yt?Hi!L&62+6CrS-m9Z9?$agb-?bbTFYtcEO9osEQqU zHG9`TeE0(MZ=@L!{*Oc_gKNSd(MoNCnhbdpHM4Lq(q9?~^y{E9cT*&v4N9Qog}mgjrt=(%8P zzvAc?pRX(4Zf`_h0wdg(j-7KTpR6oQ(Oe2bAJJ?#3yZ)0cr-y)E2KC@ohW^!q>#P^r+W7A!fTM~a9 zYF0?~ALe?bRHarzE7h)Fq7A(Phin)+hx0FD&h)JMr8gGzCyuhp@)e!Xav9=ZS&+O} zUzHqz)W6SeXYRp9Rzy8=Gl$8IThvfqMFG}Hj=AVYob(;ZWgpq$aQn&JQMieTjaejY zK`+fV!`s=u%88a|E~ zg*7-7)>BD1c!)A+9!Q(o+%%8jBYilajW&lz8x^_e^hVAV*{AOC!A6Zx4CRPk)awke zqX>#U`1l<(-e~Sj+f}j>t6w>^FP0l(+%pw^q z-p49JDN!5rJMX)WG55sqV=QZ}ca;r16i#+HSk^dNEG0*D6fsRFg%I3svvtAJ|LZAM zZ;!*6SMO8kiU);TE`r5b0KQLNETh!L&|g7pD?7+5qX5sqJ#D8NdsZ#O)a_73zqkpT7_D4|{P)kq|YukyU_KPQE~0W3ksV3-lB%8P=# bdy>erftVRZi>8M!5rBrOwn~kX_51$;-LxvB literal 0 HcmV?d00001 diff --git a/docs/class-diagram.mermaid b/docs/class-diagram.mermaid new file mode 100644 index 0000000..1a04e2e --- /dev/null +++ b/docs/class-diagram.mermaid @@ -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 { + <> + classic + ocean + forest + sunset + mono + +String get id + +String get label + +Color get primary + } + + class StoreThemePresets { + <> + +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 : 扫码结果 diff --git a/docs/intro_v38_design.md b/docs/intro_v38_design.md new file mode 100644 index 0000000..da727a3 --- /dev/null +++ b/docs/intro_v38_design.md @@ -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`,含 `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 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` 为 **`Wrap` + `FilterChip` 多选**: + - 一项 `All`("全部门店");其余每项一个门店,`selected` = `_selectedOrgIds.contains(orgId)`。 + - 空选集合 = 全部(与现有"全部门店"语义一致)。 +- 状态:`List _selectedOrgIds = []`。 +- `_applyOrgFilter()`:`_selectedOrgIds.isEmpty ? _allCategories : _allCategories.where((c) => _selectedOrgIds.contains(c.orgId)).toList()`。 +- 门店来源:`initState` 中 `OrgService().getStoreListByLoginOrg()` 构建 `Map`(替代从分类推导,更完整)。 + +### 5.2 类型编辑(Category)表单 +- **新增** 门店下拉 `DropdownButtonFormField`(必填):选项来自门店列表,`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: 关闭表单 + 刷新列表 +``` diff --git a/docs/sequence-diagram.mermaid b/docs/sequence-diagram.mermaid new file mode 100644 index 0000000..f87aa14 --- /dev/null +++ b/docs/sequence-diagram.mermaid @@ -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: 返回, 展示更新归属 diff --git a/docs/system_design.md b/docs/system_design.md new file mode 100644 index 0000000..5149cad --- /dev/null +++ b/docs/system_design.md @@ -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 { + <> + classic + ocean + forest + sunset + mono + +String get id + +String get label + +Color get primary + } + + class StoreThemePresets { + <> + +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 saveStore(Map data) async { + // TODO(后端): POST /sys/store/save,body 含 themeId/splashUrl/logoUrl/省市区 + // 前端占位:仅写 StoreLocalCache,不发起真实请求 +} +Future setStoreTheme(String orgId, String themeId) async { + // TODO(后端): POST /sys/store/setTheme {orgId, themeId} +} +Future setStoreBranding(String orgId, String splashUrl, String logoUrl) async { + // TODO(后端): POST /sys/store/setBranding {orgId, splashUrl, logoUrl} + // 注意:真实上传需先调 uploadAsset 拿 URL +} + +// DeviceService — 当前后端无扫码绑定接口 +Future 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` 树;组件禁止直接 `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 顺序落地即可。 diff --git a/docs/手机端UI优化方案-v2.md b/docs/手机端UI优化方案-v2.md new file mode 100644 index 0000000..854e4ae --- /dev/null +++ b/docs/手机端UI优化方案-v2.md @@ -0,0 +1,1486 @@ +# ops-console(爱视云管理)手机端 UI 优化方案 **v2** +## —— 回归橙白 · 冻结首页与应用显示图标 + +| 项 | 内容 | +| --- | --- | +| 项目 | ops-console(包名 `com.ainetv.aishicloud`) | +| 技术栈 | Flutter 3.27.4 / Dart 3.6.2 / Material 3 / go_router / Riverpod | +| 目标设备 | Android 手机;**实测横屏 1600×900(rotation=1)**,同时兼容竖屏 | +| 用户画像 | 门店店员 / 现场运维人员,**常在现场单手操作** | +| 文档作者 | 许清楚(产品经理) | +| **版本** | **v2(本版为唯一有效版本,v1 转为历史存档)** | +| 对标基准 | WCAG 2.1 AA;Material 3 规范;Android 无障碍触控建议 | + +**版本沿革** + +| 版本 | 状态 | 说明 | +| --- | --- | --- | +| v1 `手机端UI优化方案.md` | **历史存档** | 首次产出,确立橙化方向与 WCAG 实测基线。基线数据仍然有效,但**图标章节已被 v2 覆盖**,请勿据 v1 实施图标改动 | +| **v2(本文档)** | **有效** | ① 基调改为「回归旧版橙白 + 合理化改进」;② 新增「新旧版延续性对照」;③ 图标章节按冻结区重写;④ 导航栏/设置页/详情页/弹窗四类给出代码级指引 | + +--- + +## 0. 本版修订说明(相对 v1) + +| # | 变更 | 说明 | +| --- | --- | --- | +| 1 | **基调转换** | 从「重新定义品牌视觉语言」改为「**回归并延续旧版橙白色系 + 合理化改进**」。蓝色 `#2E7DFF` 定性为**新版做偏了的偏离**,不是原有设计 | +| 2 | **新增 §2 新旧版延续性对照** | 用表格明确区分「刻意保留 / 修正偏离 / 本轮改进」三类,一眼看出不是推翻重来 | +| 3 | **§6 图标章节重写** | 先划「冻结区 / 灰区 / 可改区」,再谈规范。v1 中 `Icons.dns → Icons.cloud_*` 的建议**已撤回** | +| 4 | **新增 §8 四类重设计落地指引** | 导航栏 / 设置页 / 详情页 / 弹窗,各给现状问题 + 改造代码 | +| 5 | **配色方案不变** | 双橙策略、WCAG 实测表、浅/深两套 ColorScheme、语义色、中性色阶 —— **全部沿用 v1,一字未改** | +| 6 | **圆角与阴影不变** | 沿用 v1(仅调整论述措辞) | + +--- + +## 1. 结论:不是重新定义,是回归 + 合理化 + +### 1.1 一句话结论 + +> **旧版云端手机端就是橙白色系,与 logo `#FF914D` 一致;当前代码里的 `#2E7DFF` 科技蓝是新版做偏了的偏离。本轮的任务是「把颜色纠正回来」,并把「扁平、无触控规范、无横屏适配」这三处真正的落后项补上。** + +### 1.2 三项任务的性质区分 + +| 任务 | 性质 | 用户的态度 | +| --- | --- | --- | +| 主题色蓝 → 橙白 | **修正偏离**(回归旧版) | 确认要做 | +| 首页应用图标 / 应用显示图标 | **不做任何修改** | **硬约束,冻结** | +| 导航栏 / 设置页 / 详情页 / 弹窗 | **重新设计** | 点名要做 | +| 圆角、阴影、触控、横屏 | **合理化改进**(补落后项) | 确认要做 | + +> **关键认知**:本轮**不发明新风格**,是"把走偏的拉回来 + 把缺的补上"。因此所有"保留现设计"的地方要明确保留,不能为改而改。 + +--- + +## 2. ★ 新旧版延续性对照 + +### 2.1 基准说明(重要,请先读) + +- **「旧版」基准来自用户口述确认**(橙白色系),**非源码基准** —— 当前代码库无 git 仓库、无旧版分支、无备份目录,我无法从源码反推旧版的确切色值。 +- 因此下表中「延续」一列的判定依据是:**该特征是否与 logo 实测数据(`#FF914D` / `#FFBE58` / `#FEFEFE`、近白底 85%、无深色成分)一致**。一致即归为"延续旧版橙白",不一致即归为"新版偏离"。 +- 若后续能拿到旧版截图或源码包,建议用实际值校准下表 **2.3 节**的「延续值」列。 + +### 2.2 三类归属总表 + +| # | 视觉特征 | 旧版(橙白) | 当前 v1 代码 | 本轮归属 | 目标值 | +| --- | --- | --- | --- | --- | --- | +| 1 | 品牌主色 | 橙 `#FF914D` | **`#2E7DFF` 蓝** | **修正偏离** | 行动色 `#C2410C` + 品牌色 `#FF914D` | +| 2 | 整体底色 | 近白 | 近白(M3 默认) | 延续 | 暖白 `#FCFAF8` | +| 3 | 强调/点缀色 | 金黄 | 无 | 延续 + 补全 | 辅金 `#FFBE58` | +| 4 | 明暗倾向 | 明亮轻盈(logo 最暗仅 163) | 默认 `ThemeMode.dark` | **修正偏离** | 默认浅色(待确认) | +| 5 | 圆角风格 | 小圆角、非胶囊(logo 3%) | 卡片 12 / 输入 10 | 延续 + 改进 | 梯度化 4/8/12/16/20/28 | +| 6 | 卡片层次 | 浅阴影、轻漂浮 | `elevation: 1`(几乎扁平) | 改进 | 云感暖阴影 | +| 7 | 首页应用/入口图标 | 既有设计 | 既有设计 | **冻结** | **不动** | +| 8 | 应用显示类图标(TV 应用、Logo、封面占位) | 既有设计 | 既有设计 | **冻结** | **不动** | +| 9 | 底部导航 | 既有设计 | 既有设计(配色随蓝) | **点名重设计** | 见 §8.1 | +| 10 | 设置页 | 既有设计 | `SegmentedButton` 挤在 `trailing` | **点名重设计** | 见 §8.2 | +| 11 | 详情页 | 既有设计 | label 列写死 88、Grid 写死 2 列 | **点名重设计** | 见 §8.3 | +| 12 | 弹窗 | 既有设计 | 无限高、无把手、无安全区 | **点名重设计** | 见 §8.4 | +| 13 | 触控目标 | — | 无规范 | 改进(补缺) | ≥48×48,间距 ≥8 | +| 14 | 横屏适配 | — | **0 处断点** | 改进(补缺) | 见 §7 | + +### 2.3 【延续】刻意保留的(不要动) + +| 特征 | 保留理由 | 本轮动作 | +| --- | --- | --- | +| 首页统计卡 4 个图标(`Icons.devices` / `Icons.store` / `Icons.check_circle_outline` / `Icons.highlight_off`) | 用户点名冻结 | **零改动**(含 `size: 28`) | +| 首页快捷入口 3 个图标(`Icons.devices` / `Icons.store` / `Icons.people`) | 用户点名冻结(首页应用图标) | **零改动**(含 `CircleAvatar` 结构) | +| TV 应用图标 `Icons.apps` ×2 | 用户点名冻结(应用显示相关) | **零改动** | +| 门店主页预览 Logo 占位 `Icons.storefront` | 应用显示相关 | **零改动** | +| 主题缩略图占位 `Icons.image_outlined` | 应用显示相关 | **零改动** | +| 品牌编辑器 Logo 占位 `Icons.image` | 应用显示相关 | **零改动** | +| 底部导航 5 个 tab 的**图标语义与配对方式**(线性/填充) | 语义准确、已符合 M3 | **保留图标本身**,仅改配色与容器形态(§8.1) | +| 近白底 + 深色文字的整体明暗关系 | 与 logo 明度一致(均值 245/255) | 延续并强化(暖白 `#FCFAF8`) | +| 居中标题 AppBar | 现状如此、符合中文习惯 | 延续 | +| 登录页 `maxWidth: 420` 的内容约束 | 是正确的做法 | 延续并推广到全站(§7) | +| 首页 Grid 2 列、快捷入口 3 个的**信息架构** | 不是本轮问题 | 保留结构,仅横屏下自动增列 | + +### 2.4 【修正偏离】蓝色 → 橙白(本轮核心) + +| 位置 | 现值 | 目标 | 说明 | +| --- | --- | --- | --- | +| `lib/app/theme.dart:8` | `#2E7DFF` | `#C2410C` | 主题主色,全站最关键的偏离 | +| `login_page.dart:205/233/280` | `#2E7DFF` | `scheme.primary` | 输入框聚焦边框 | +| `login_page.dart:350` | `#2E7DFF` | `scheme.primary` | 记住密码勾选框 | +| `login_page.dart:370` | `#2E7DFF` | `scheme.primary` | 登录按钮 | +| `login_page.dart:372` | `#1A4480` | `onSurface` 12% | 按钮禁用态 | +| `login_page.dart:149` | **`#0D2137`(深蓝)** | `scheme.surfaceContainerLow` | **登录卡片底色是深蓝,与暖橙直接冲突** | +| `login_page.dart:152` | `#121212` | `scheme.surface` | 冷黑 → 暖黑 | +| `store_theme_grid.dart:77` | `#2E7DFF` | `scheme.primary` | 主题模板选中边框 | +| `region_picker.dart:189` | `Colors.blue` | `scheme.primary` | 选中勾选图标 | +| `home_page.dart:78` | `Colors.blue` | 见 §6.1 注 | **落在冻结区,需用户确认** | +| `admin_hub_page.dart:19` | `Colors.blue` | 见 §6.2 注 | **落在灰区,需用户确认** | +| `org_tree_page.dart:282` | `Colors.blue` | 语义色 | 组织类型「渠道」标签色 | +| `alerts_page.dart:19` | `Colors.blue` | `AppSemanticColors.info` | 告警「信息」级 | +| `theme_provider.dart:7` | 默认 `ThemeMode.dark` | 建议 `light` / `system` | **与 logo"明亮轻盈"背离**(待确认) | + +> **排查提醒**:`store_theme_preview.dart:110-115`、`store_theme_grid.dart:129-134`、`models/store_theme.dart:46-54` 里也有蓝色(`#3B82F6`、`#1565C0`…),但那是**下发给 TV 端的门店主题业务数据**,**不是 App 主题色,绝对不能动**。详见 §10.1。 + +### 2.5 【改进】本轮合理化提升(补落后项) + +| # | 改进项 | 现状 | 目标 | 章节 | +| --- | --- | --- | --- | --- | +| 1 | 建立圆角梯度 | 12 / 10 两个孤立值 | 4/8/12/16/20/28/999 七档 | §5.1 | +| 2 | 云感暖阴影 | `elevation: 1`(几乎无层次) | 暖棕低透明、模糊:偏移 ≈ 4:1 | §5.2 | +| 3 | 触控规范 | 无 | ≥48×48、间距 ≥8、主按钮 52 | §7.1 | +| 4 | 横屏 1600×900 适配 | **0 处断点** | 断点工具 + Rail + 双栏 + 限宽 | §7.4 | +| 5 | 弹窗结构化 | 无限高/无把手/无安全区 | 限高 + 把手 + 吸底栏 + 安全区 | §8.4 | +| 6 | 设置页控件布局 | `SegmentedButton` 塞 `trailing`(**实测会挤爆**) | 全宽独立行 | §8.2 | +| 7 | 详情页响应式 | label 列写死 88、Grid 写死 2 列 | 响应式 | §8.3 | +| 8 | 状态栏图标亮度 | 硬编码 `Brightness.light`(**真 bug**) | 跟随主题 | §9 P0-4 | +| 9 | 语义色统一管理 | 散落 20+ 处硬编码 | `ThemeExtension` | §4.5 | + +--- + +## 3. 设计语言(延续 + 改进) + +### 3.1 logo 实测数据(v1 沿用,未改) + +logo `assets/images/logo.png`(192×192,RGBA): + +| 维度 | 实测值 | 推导 | +| --- | --- | --- | +| 容器形状 | 圆角矩形,圆角 ≈ 6px / 192px = **3%** | **克制的小圆角**,非胶囊 | +| 主色 `#FF914D` | 4.4% 非透明像素,**图形中心像素** | **品牌主色(暖橙)** | +| 金黄 `#FFBE58` | 1.9% | **品牌辅助色** | +| 渐变带 | `#FFA36A` / `#FFB487` / `#FFC56C` / `#FCE2CB` | 橙→金渐变 = 云朵受光 | +| 中性 `#DBBB88` | 3.5% | 中性色阶需**偏暖,不用纯灰** | +| 近白 `#FEFEFE` | 85.0% | 大面积留白、明亮轻盈 | +| 明度 | 均值 245/255,**最暗仅 163** | 无深色成分 → **浅色是品牌主场** | +| 橙色行分布 | `[0,0,0,18,34,28,13,85,103,71,45,11,35,0,0,0]` | 中部双峰 → 云朵多圆弧叠加 | + +### 3.2 视觉关键词(延续旧版橙白的判据) + +> **云 · 轻盈 · 暖橙 · 明亮 · 圆润弧线 · 大留白** + +四条可执行判据(**与旧版橙白一致,不是新发明**): +1. 中性色一律**暖偏移**(R>G>B),禁用纯灰 `#9E9E9E` / 纯黑 `#000000`; +2. 阴影用**暖棕低透明**而非纯黑,大模糊、小偏移 → "托起"而非"压住"; +3. 圆角**中等偏大但不成胶囊**,**顶部 ≥ 底部**(云朵上缘多弧、下缘平缓); +4. 品牌亮橙 `#FF914D` **只做大色块/渐变/插画/图表**,不做文字与图标色。 + +--- + +## 4. 配色体系(**完全沿用 v1,未作修改**) + +### 4.1 关键约束:logo 亮橙不能配白字(WCAG 实测) + +| 组合 | 对比度 | 结论 | +| --- | --- | --- | +| 白字 `#FFFFFF` on `#FF914D` | **2.23** | ❌ 不达标(正文需 4.5,大字需 3.0) | +| 白字 on `#FFBE58` | **1.65** | ❌ 严重不达标 | +| 深棕 `#3D1A00` on `#FF914D` | **6.99** | ✅ 达标 | +| 黑字 `#000000` on `#FF914D` | **9.41** | ✅ 达标(但纯黑偏硬,推荐深棕) | + +### 4.2 双橙策略(本方案配色的核心,与旧版橙白一致) + +| 角色 | 色值 | 用途 | 配字 | +| --- | --- | --- | --- | +| **品牌色 Brand** | `#FF914D` | 插画/渐变/图表/Banner 大色块/Splash/进度条 | 深棕 `#3D1A00`(6.99 ✅) | +| **行动色 Primary** | `#C2410C` | 主按钮/导航选中/可点文字/图标 | 白 `#FFFFFF`(5.18 ✅) | +| **辅金 Accent** | `#FFBE58` | 高亮/到期提醒/星级/推荐位 | 深棕(9.48 ✅) | + +`#C2410C` = `#FF914D` 沿同色相(H≈23°)压暗到可承载白字。 +> **一句话记忆:看用亮橙 `#FF914D`,点用深橙 `#C2410C`。** + +### 4.3 浅色 ColorScheme(Light) + +```dart +static const ColorScheme _light = ColorScheme( + brightness: Brightness.light, + primary: Color(0xFFC2410C), onPrimary: Color(0xFFFFFFFF), // 5.18 ✅ + primaryContainer: Color(0xFFFFD3B3), onPrimaryContainer: Color(0xFF4A1D00), // 10.38 ✅ + secondary: Color(0xFF8A5A3A), onSecondary: Color(0xFFFFFFFF), // 5.83 ✅ + secondaryContainer: Color(0xFFF5DFCB), onSecondaryContainer: Color(0xFF5A3A22), // 7.90 ✅ + tertiary: Color(0xFF9A6000), onTertiary: Color(0xFFFFFFFF), // 5.19 ✅ + tertiaryContainer: Color(0xFFFFE0A3), onTertiaryContainer: Color(0xFF5C3D00), // 7.75 ✅ + error: Color(0xFFB02A20), onError: Color(0xFFFFFFFF), // 6.57 ✅ + errorContainer: Color(0xFFFFDDD8), onErrorContainer: Color(0xFF7A2A22), // 7.60 ✅ + surface: Color(0xFFFCFAF8), onSurface: Color(0xFF1A1614), // 17.26 ✅ + onSurfaceVariant: Color(0xFF6E655C), // 5.48 ✅ + surfaceContainerLowest: Color(0xFFFFFFFF), + surfaceContainerLow: Color(0xFFF7F3EF), + surfaceContainer: Color(0xFFF2EDE8), + surfaceContainerHigh: Color(0xFFECE5DF), + surfaceContainerHighest: Color(0xFFE5DED7), + outline: Color(0xFF8A8178), outlineVariant: Color(0xFFDCCFC2), // 3.67 ✅ + surfaceTint: Color(0xFFC2410C), shadow: Color(0xFF5C2E12), + scrim: Color(0xFF1A1614), + inverseSurface: Color(0xFF2E2823), onInverseSurface: Color(0xFFF7EFE9), + inversePrimary: Color(0xFFFFB487), +); +``` + +### 4.4 深色 ColorScheme(Dark) + +```dart +static const ColorScheme _dark = ColorScheme( + brightness: Brightness.dark, + primary: Color(0xFFFFB487), onPrimary: Color(0xFF542000), // 7.66 ✅ + primaryContainer: Color(0xFF63300D), onPrimaryContainer: Color(0xFFFFDCC2), // 8.30 ✅ + secondary: Color(0xFFE8C39B), onSecondary: Color(0xFF542000), // 8.03 ✅ + secondaryContainer: Color(0xFF5A3A22), onSecondaryContainer: Color(0xFFFFDCC2), // 7.89 ✅ + tertiary: Color(0xFFF2BE62), onTertiary: Color(0xFF542000), // 7.78 ✅ + tertiaryContainer: Color(0xFF5C3D00), onTertiaryContainer: Color(0xFFFFDCC2), // 7.66 ✅ + error: Color(0xFFFF8474), onError: Color(0xFF5A1610), // 5.65 ✅ + errorContainer: Color(0xFF7A2A22), onErrorContainer: Color(0xFFFFC9C2), // 6.59 ✅ + surface: Color(0xFF16130F), onSurface: Color(0xFFF3E9E2), // 15.49 ✅ + onSurfaceVariant: Color(0xFFC4B8AE), // 9.54 ✅ + surfaceContainerLowest: Color(0xFF0F0D0A), + surfaceContainerLow: Color(0xFF1E1A16), + surfaceContainer: Color(0xFF23201B), + surfaceContainerHigh: Color(0xFF2D2924), + surfaceContainerHighest: Color(0xFF38332D), + outline: Color(0xFF7A7068), outlineVariant: Color(0xFF453F39), // 3.83 ✅ + surfaceTint: Color(0xFFFFB487), shadow: Color(0xFF000000), + scrim: Color(0xFF0F0D0A), + inverseSurface: Color(0xFFF3E9E2), onInverseSurface: Color(0xFF2E2823), + inversePrimary: Color(0xFFC2410C), +); +``` + +### 4.5 语义色(**警告不用橙**——会与品牌橙语义打架,改用琥珀黄) + +| 语义 | Light 主色 | Light 容器 / 文字 | Dark 主色 | Dark 容器 / 文字 | +| --- | --- | --- | --- | --- | +| 成功 | `#1E7A4E` (5.11 ✅) | `#D3EDDD` / `#1E5138` (7.39 ✅) | `#5FD398` (9.93 ✅) | `#1E5138` / `#A8E6C4` (6.46 ✅) | +| 警告 | `#8A5C00` (5.58 ✅) | `#FFE9B8` / `#5C4100` (7.96 ✅) | `#F0BE4E` (10.74 ✅) | `#5C4100` / `#FFDCA8` (7.26 ✅) | +| 错误 | `#B02A20` (6.31 ✅) | `#FFDDD8` / `#7A2A22` (7.60 ✅) | `#FF8474` (7.76 ✅) | `#7A2A22` / `#FFC9C2` (6.59 ✅) | +| 信息 | `#0B6397` (6.21 ✅) | `#D7E9FA` / `#1A4763` (7.96 ✅) | `#7FC4F0` (9.75 ✅) | `#1A4763` / `#B8DCF5` (6.87 ✅) | + +通过 `ThemeExtension` 暴露(完整代码见 v1 §3.5 或本文档 §12 附录 B)。 + +### 4.6 暖中性色阶(全部 R>G>B 暖偏移,亮度严格单调) + +**Light**:`#FFFFFF`(1.000) > `#FCFAF8`(0.958) > `#F7F3EF`(0.901) > `#F2EDE8`(0.853) > `#ECE5DF`(0.792) > `#E5DED7`(0.738) > `#DCCFC2`(0.637) > `#C8BFB4`(0.528) > `#8A8178`(0.225) > `#6E655C`(0.134) > `#1A1614`(0.008) > `#0F0D0A`(0.004) + +**Dark**:`#F3E9E2`(0.828) > `#C4B8AE`(0.491) > `#7A7068`(0.167) > `#453F39`(0.051) > `#38332D`(0.034) > `#2D2924`(0.023) > `#23201B`(0.015) > `#1E1A16`(0.011) > `#16130F`(0.007) > `#0F0D0A`(0.004) + +### 4.7 换色对可读性的影响(实测) + +| 场景 | 旧(蓝 `#2E7DFF`) | 新(深橙 `#C2410C`) | 影响 | +| --- | --- | --- | --- | +| 主按钮白字 | 3.68(**原本就不达 AA**) | **5.18 ✅** | **提升** | +| 主色作文字/图标 | 3.68 | **4.97 ✅** | **提升** | +| 选中态图标 on 容器 | — | **7.90 ✅** | 达标 | +| 亮橙作图标(**禁止**) | — | 1.66 ❌ | **新增约束** | + +> 换色顺带修复了一个一直存在的无障碍问题。这是"回归橙白"的额外收益。 + +--- + +## 5. 圆角与阴影(沿用 v1) + +### 5.1 圆角梯度 + +logo 的 3% **不能等比照搬**(等比后按钮仅 1.4dp、卡片 4.2dp,生硬且与"云"无关)。 +**可继承的是三条定性特征:非胶囊 / 四角一致 / 克制感**;定量落点 =「容器短边的 5%~10%」→ 12~28dp。 + +```dart +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)); +} +``` + +> **注意**:M3 `FilledButton` 默认圆角 20(接近胶囊),必须显式覆盖为 `lg`(16),否则与卡片圆角脱节。 + +### 5.2 云感阴影(替代 `elevation: 1`) + +现状 `elevation: 1` 的阴影由引擎按固定曲线绘制,**颜色可改、模糊/偏移曲线不可控**。A/B 双轨: + +- **A(全局低风险)**:`ThemeData(shadowColor: #5C2E12)` + `CardThemeData(elevation: 0)` +- **B(推荐)**:新建 `CloudCard` 用 `BoxShadow` 精确控制 + +```dart +class AppShadows { + static const List xs = [BoxShadow(color: Color(0x0F5C2E12), blurRadius: 8, offset: Offset(0, 2), spreadRadius: -1)]; // 6% + static const List sm = [BoxShadow(color: Color(0x145C2E12), blurRadius: 12, offset: Offset(0, 4), spreadRadius: -2)]; // 8% + static const List md = [BoxShadow(color: Color(0x1A5C2E12), blurRadius: 20, offset: Offset(0, 8), spreadRadius: -4)]; // 10% + static const List lg = [BoxShadow(color: Color(0x245C2E12), blurRadius: 32, offset: Offset(0, 12), spreadRadius: -6)]; // 14% + static const List xl = [BoxShadow(color: Color(0x245C2E12), blurRadius: 48, offset: Offset(0, 20), spreadRadius: -8)]; // 14% + /// 深色:暖色阴影在暗底不可见(实测对比仅 1.05),改用纯黑 + 描边 + static const List dark = [BoxShadow(color: Color(0x66000000), blurRadius: 24, offset: Offset(0, 8), spreadRadius: -4)]; +} +``` + +三条依据: +1. **模糊:偏移 ≈ 4:1**(M2/M3 默认约 2:1 偏硬)→ 光是漫射的,像云影; +2. **暖棕 `#5C2E12` 而非纯黑** → 这是"暖"的关键; +3. **深色模式换纯黑 α=40% + `outlineVariant` 描边**(实测暖棕 6% 在 `#16130F` 上对比仅 1.05,完全不可见)。 + +> M3 elevation→染色曲线实测 `1→5% / 3→8% / 6→11%`。保留 `surfaceTintColor = primary`,全站卡片可白拿 5~8% 暖橙染色 —— 零成本的品牌渗透。 + +--- + +## 6. ★ 图标风格(**本版重大修订:冻结区先行**) + +> ### ⛔ 实施前必读 +> 用户原话:「**首页的应用图标及与应用显示相关的图标维持原有设计不做任何修改。**」 +> 本章所有规范**只在 §6.3「C 区|可改区」内生效**。A 区、B 区一律不动。 +> **v1 中「全项目 `Icons.xxx` → `_outlined`」的批量替换建议,必须先剔除 A/B 区才能执行。** + +### 6.0 冻结区判定规则(按「图标实例」判定,不是按文件) + +**判据:看这个图标"代表什么",而不是"在哪个文件里"。** + +| 图标代表的对象 | 判定 | 举例 | +| --- | --- | --- | +| **应用本体 / 展示内容本体**(APP 图标、Logo、封面、缩略图、视频、门店主页 Logo) | **冻结** | `Icons.apps`、`Icons.storefront`、`Icons.video_library` | +| **模块入口**(首页快捷入口、管理中心网格入口) | **冻结**(用户点名"首页应用图标") | `Icons.devices`、`Icons.store`、`Icons.people` | +| **通用交互动作**(搜索、清除、新增、关闭、下拉、勾选、返回、编辑、删除) | **可改** | `Icons.search`、`Icons.clear`、`Icons.add` | +| **状态 / 属性**(在线、离线、告警、时间、地址、电话、分组) | **可改**(设备图标除外,见灰区) | `Icons.location_on`、`Icons.phone`、`Icons.access_time` | + +> **⚠️ 这条规则的关键后果**:同一个文件里可能既有冻结项又有可改项。 +> 例:`tv_app_page.dart` 在 `admin/app/` 目录下,但只有 `:301` `:304` 的 `Icons.apps`(应用本体)冻结; +> `:245` `Icons.search`、`:249` `Icons.clear`、`:341` `Icons.add`(通用交互)**不在冻结区,可统一**。 +> **不要按文件整体冻结或整体放行。** + +**四个分区一览** + +| 区 | 名称 | 数量 | 性质 | +| --- | --- | --- | --- | +| **A** | 绝对冻结 | **12** | 用户硬约束,禁止任何修改 | +| **B** | 灰区(建议同冻结) | **26** | 性质同 A,需用户一句话确认 | +| **C** | 可改区 | 其余全部 | 本轮重设计范围 | +| **D** | 后端契约冻结 | **10** | **不是设计问题**,改了会破坏与 Web 后台的一致性(§6.2.1) | + +> 另有 **1 项待确认**(首页统计卡四色硬编码),见 §6.1 末。 + +### 6.1 A 区|**绝对冻结清单(禁止任何修改)** + +#### A1|首页 `lib/screens/home/home_page.dart`(用户点名「首页应用图标」) + +| 文件:行 | 图标 | 尺寸 | 位置 / 用途 | +| --- | --- | --- | --- | +| `home_page.dart:77` | `Icons.devices` | 28 | 统计卡「设备总数」 | +| `home_page.dart:83` | `Icons.store` | 28 | 统计卡「门店总数」 | +| `home_page.dart:89` | `Icons.check_circle_outline` | 28 | 统计卡「在线设备」 | +| `home_page.dart:95` | `Icons.highlight_off` | 28 | 统计卡「离线设备」 | +| `home_page.dart:114` | `Icons.devices` | 默认(24) | 快捷入口「设备管理」(`CircleAvatar` 内) | +| `home_page.dart:121` | `Icons.store` | 默认(24) | 快捷入口「门店管理」(`CircleAvatar` 内) | +| `home_page.dart:128` | `Icons.people` | 默认(24) | 快捷入口「用户管理」(`CircleAvatar` 内) | + +**冻结范围**:`IconData` 图形、`size`、布局结构、入口数量与顺序、快捷入口的 `CircleAvatar` 结构。 + +#### A2|应用显示相关图标(用户点名「与应用显示相关的图标」) + +| 文件:行 | 图标 | 尺寸 | 用途 | +| --- | --- | --- | --- | +| `lib/screens/admin/app/tv_app_page.dart:301` | `Icons.apps` | 40 | TV 应用**网络图标加载失败时的占位** | +| `lib/screens/admin/app/tv_app_page.dart:304` | `Icons.apps` | 默认(24) | TV 应用**无 iconUrl 时的默认应用图标**(`CircleAvatar` 内) | +| `lib/widgets/store_theme_preview.dart:105` | `Icons.storefront` | 26 | **门店主页头部预览**的 Logo 占位图标 | +| `lib/widgets/store_theme_group_list.dart:174` | `Icons.image_outlined` | 28 | **主题页缩略图预览**占位图标 | +| `lib/widgets/branding_editor.dart:155` | `Icons.image` | 28 | **启动图 / Logo** 角标加载失败占位 | + +> **A 区合计:12 处(A1 七处 + A2 五处)** + +#### ⚠️ A 区待确认项:首页统计卡的四色硬编码 + +`home_page.dart:78` `Colors.blue`、`:84` `Colors.purple`、`:90` `Colors.green`、`:96` `Colors.red` —— 这四个是**统计卡图标的配色**,严格说属于"图标设计的一部分",落在冻结语义内;但其中 `Colors.blue` 是全站最显眼的蓝色之一,若不动会与"回归橙白"直接打架。 + +**本轮默认动作:冻结不动**(尊重字面指令)。**建议向用户确认**,两种选法: + +| 选项 | 做法 | 后果 | +| --- | --- | --- | +| **① 保持现状(默认)** | 四色硬编码原样保留 | 首页仍有蓝/紫/绿/红四色,与新版橙白主题并存,观感割裂 | +| **② 改为语义色(推荐)** | 设备=`primary`、门店=`tertiary`、在线=`success`、离线=`onSurfaceVariant` | 图标图形与尺寸零改动(符合冻结),仅配色纳入新体系,与"回归橙白"一致 | + +### 6.2 B 区|**建议一并按冻结处理(灰区,需用户一句话确认)** + +**理由**:下列图标性质与 A2 相同 —— 都是**下发给 TV / 门店端展示内容的缩略图占位图形**,属于"应用显示相关",只是不在 `admin/app/` 目录下。保守起见本轮不动。 + +| 文件:行 | 图标 | 尺寸 | 用途 | +| --- | --- | --- | --- | +| `lib/screens/admin/intro/intro_category_page.dart:375` | `Icons.image` | 40 | 分类封面**加载失败**占位 | +| `intro_category_page.dart:378` | `Icons.folder` | 40 | 分类**无封面**占位 | +| `intro_category_page.dart:436` | `Icons.image` | 40 | 内容封面**加载失败**占位 | +| `intro_category_page.dart:439` | `Icons.article` | 40 | 内容**无封面**占位 | +| `intro_category_page.dart:1219` | `Icons.broken_image` | 48 | 预览图**损坏**占位 | +| `intro_category_page.dart:1226` | `Icons.image` | 48 | 预览图**空态**占位 | +| `lib/screens/admin/intro/intro_store_content_page.dart:332` | `Icons.image` | 32 | 分类封面**加载失败**占位 | +| `intro_store_content_page.dart:335` | `Icons.folder` | 32 | 分类**无封面**占位 | +| `intro_store_content_page.dart:407` | `Icons.image` | 32 | 内容封面**加载失败**占位 | +| `intro_store_content_page.dart:410` | `Icons.article` | 32 | 内容**无封面**占位 | +| `intro_store_content_page.dart:1100` | `Icons.broken_image` | 48 | 预览图**损坏**占位 | +| `intro_store_content_page.dart:1107` | `Icons.image` | 48 | 预览图**空态**占位 | +| `lib/screens/admin/media/media_list_page.dart:336` | `Icons.video_library` | 24 | 媒资缩略图占位 | +| `media_list_page.dart:347` | `Icons.video_library` | 24 | 媒资缩略图占位 | +| `lib/screens/admin/media/media_rank_page.dart:209` | `Icons.video_library` | 默认 | 排行缩略图占位 | +| `media_rank_page.dart:215` | `Icons.video_library` | 默认 | 排行缩略图占位 | +| `media_rank_page.dart:323` | `Icons.video_library` | 20 | 排行缩略图占位 | +| `media_rank_page.dart:330` | `Icons.video_library` | 20 | 排行缩略图占位 | + +#### B 区另两类(性质不同,但同样建议先不动) + +| 文件:行 | 图标 | 说明 | +| --- | --- | --- | +| `lib/widgets/device_list_tile.dart:26` | `Icons.dns` | 设备在线图标 | +| `device_list_tile.dart:28` | `Icons.dns_outlined` | 设备离线图标 | +| `device_list_tile.dart:30` | `Icons.warning_amber` | 设备告警图标 | +| `lib/screens/admin/admin_hub_page.dart:18` | `Icons.devices` | 管理入口网格「设备管理」 | +| `admin_hub_page.dart:24` | `Icons.vpn_key` | 管理入口网格「激活码」 | +| `admin_hub_page.dart:30` | `Icons.people` | 管理入口网格「用户管理」 | +| `admin_hub_page.dart:36` | `Icons.business` | 管理入口网格「公司介绍」 | +| `admin_hub_page.dart:42` | `Icons.subtitles` | 管理入口网格「滚动字幕」 | + +- **设备图标**:`device_list_page` 是底部导航第 2 个 tab(**不是"首页"**),也不是"应用显示" → 严格说不在冻结区。但 `Icons.dns`(机柜)是**设备本体的代表图形**,性质接近 A2;且 **v1 曾建议改成 `Icons.cloud_*`**,本轮予以**撤回**,列入灰区待确认。 +- **管理中心网格**:与首页快捷入口**同性质(模块入口图标)**,只是不在首页。若冻结逻辑成立,理应一并冻结。 + +> **B 区合计:26 处(18 处内容占位 + 3 处设备图标 + 5 处管理入口)** +> **建议**:请用户一句话确认「B 区是否同 A 区一并冻结」。若确认,本轮**连 A 区共 38 处图标全部不动**,图标改动范围收缩到 C 区。 + +### 6.2.1 D 区|**因后端契约冻结(不是设计选择,是接口约定)** + +> **⚠️ 这一区与用户的冻结指令无关,但风险等级最高 —— 改了会直接造成线上功能错乱,而实施者极易误改。** + +`lib/screens/admin/sys/menu_page.dart:277-300` 的 `_iconData(String icon)` 是**后台 `sys_menu` 表 `icon` 字段 → `IconData` 的映射表**: + +| 文件:行 | 图标 | 后端 key | +| --- | --- | --- | +| `menu_page.dart:280` | `Icons.home` | `'home'` | +| `menu_page.dart:282` | `Icons.settings` | `'settings'` | +| `menu_page.dart:284` | `Icons.person` | `'person'` | +| `menu_page.dart:286` | `Icons.people` | `'people'` | +| `menu_page.dart:288` | `Icons.menu` | `'menu'` | +| `menu_page.dart:290` | `Icons.list` | `'list'` | +| `menu_page.dart:292` | `Icons.dashboard` | `'dashboard'` | +| `menu_page.dart:294` | `Icons.devices` | `'devices'` | +| `menu_page.dart:296` | `Icons.notifications` | `'notifications'` | +| `menu_page.dart:298` | `Icons.folder` | `default` | + +**冻结理由**:这些 `IconData` 由**后端数据决定**,与 Web 后台的菜单图标配置是一套约定。若实施 P1-12「统一 `_outlined`」时把 `Icons.home` 改成 `Icons.home_outlined`,**移动端与 Web 后台的菜单图标将不再一致**,且后端若新增 key 会更难对齐。 + +**处置**:**绝对冻结,10 处全部不动**,且**不进 B 区待确认流程**(没有商量余地)。若确需统一风格,正确做法是**扩展映射表**(新增 key → outlined 图标),而非替换现有映射。 + +### 6.3 C 区|**可改清单(本轮重设计范围)** + +#### C1|用户点名的四类 + +| 类别 | 文件 | 涉及图标(文件:行) | +| --- | --- | --- | +| **导航栏** | `lib/widgets/app_bottom_nav.dart` | `:32` `Icons.home_outlined` / `:33` `Icons.home`
`:37` `Icons.devices_outlined` / `:38` `Icons.devices`
`:42` `Icons.store_outlined` / `:43` `Icons.store`
`:47` `Icons.admin_panel_settings_outlined` / `:48` `Icons.admin_panel_settings`
`:52` `Icons.person_outline` / `:53` `Icons.person` | +| **设置页** | `lib/screens/profile/settings_page.dart` | `:166` `Icons.person`、`:177` `Icons.dark_mode_outlined`、`:184` `Icons.dark_mode`、`:188` `Icons.light_mode`、`:192` `Icons.settings_suggest`、`:203` `Icons.language_outlined`、`:233` `Icons.verified_user_outlined`、`:268` `Icons.lock_outline`、`:275` `Icons.info_outline`、`:294` `Icons.logout` | +| **详情页** | `lib/screens/home/device_detail_page.dart` | `:320` `Icons.memory`、`:328` `Icons.storage`、`:337` `Icons.sd_storage`、`:346` `Icons.network_check`、`:507` `Icons.dvr`、`:544` `Icons.meeting_room` | +| | `lib/screens/store/store_detail_page.dart` | 16 行桩页,无图标,按新规范实现 | +| **弹窗** | `lib/widgets/org_picker_sheet.dart` | `:109` `Icons.close`、`:126` `Icons.search`、`:130` `Icons.clear`、`:192` `Icons.check_circle`、`:321` `Icons.account_tree`、`:322` `Icons.arrow_drop_down` | +| | `lib/widgets/device_command_sheet.dart` | 纯 `ActionChip`,无图标 | +| | `lib/widgets/region_picker.dart` | `:189` `Icons.check`(含 `Colors.blue`)、`:190` `Icons.chevron_right` | +| | `lib/screens/store/store_list_page.dart` 弹窗内 | `:1265` `Icons.store`、`:1278` `Icons.location_on`、`:1292` `Icons.arrow_drop_down`、`:1303` `Icons.home`、`:1348` `Icons.devices`、`:1361` `Icons.vpn_key`、`:1376` `Icons.phone`、`:1220` `Icons.check_circle_outline`/`Icons.error_outline` | + +#### C2|通用交互图标(**全项目统一,含冻结区文件内部**) + +以下图标出现在任何位置(**包括 A/B 区所在的文件里**)都可统一,因为它们代表通用交互动作,不是"应用/内容本体": + +`Icons.search` · `Icons.clear` · `Icons.close` · `Icons.add` · `Icons.edit` · `Icons.delete` · `Icons.refresh` · `Icons.check` · `Icons.chevron_right` · `Icons.arrow_drop_down` · `Icons.arrow_upward` · `Icons.arrow_downward` · `Icons.expand_less` / `Icons.expand_more` · `Icons.upload` · `Icons.copy` · `Icons.save` · `Icons.password` · `Icons.restart_alt` + +> **注意**:`tv_app_page.dart:341` 的 `Icons.add`、`app_version_page.dart:353` 的 `Icons.add`、`:270` 的 `Icons.search` —— 都在 `admin/app/` 目录下,但**属于 C2,可改**。 + +#### C3|状态/属性图标(可改) + +`Icons.location_on` · `Icons.phone` · `Icons.access_time` · `Icons.event` · `Icons.vpn_key` · `Icons.folder` · `Icons.folder_outlined` · `Icons.tag` · `Icons.label` · `Icons.category` · `Icons.account_tree` · `Icons.account_tree_outlined` · `Icons.error_outline` · `Icons.warning_amber` · `Icons.check_circle_outline` · `Icons.circle`(StatusChip 小圆点) + +### 6.4 对 v1 图标建议的撤回与修正 + +| v1 建议 | v2 处置 | 原因 | +| --- | --- | --- | +| `Icons.dns` / `Icons.dns_outlined` → `Icons.cloud_done_outlined` / `Icons.cloud_off_outlined` | **❌ 撤回**(转 B 区待确认) | 设备图标属"设备本体代表图形",性质接近冻结区;且 `device_list_page` 是底部导航 tab,是否属"首页"存在歧义。**在用户澄清前不动** | +| 全项目 `Icons.xxx` → `Icons.xxx_outlined` 批量替换 | **⚠️ 限定范围后保留** | **必须先剔除 A 区 12 处 + B 区 26 处**,仅对 C 区执行。执行前须用 §6.1/§6.2 清单逐行比对 | +| 语义色图标映射表(在线/离线/告警→云图标) | **❌ 整表撤回** | 该表核心就是换图标,与冻结指令冲突 | +| 统一 `IconThemeData(size: 24, fill: 0)` | **⚠️ 修正** | `fill/weight/grade/opticalSize` 需可变字体支持,Flutter 内置 `MaterialIcons` **不支持**,当前是 **no-op**。P0 靠统一使用 `_outlined` 变体落地(仅 C 区);P2 再引入 Material Symbols 字体让四轴生效 | + +### 6.5 冻结区外的统一图标语言(仅 C 区生效) + +| 维度 | 规范 | +| --- | --- | +| 风格 | **线性(Outlined)为默认**,填充态仅用于"选中" | +| 描边 | 1.8 ~ 2.0 dp(24dp 栅格下) | +| 端点/连接 | **圆角端点 + 圆角连接** —— 呼应云朵弧线最直接的手法 | +| 栅格 | 24dp 网格 + 2dp 内边距 | +| 禁用 | 直角矩形类(`Icons.square`、`Icons.stop`) | + +```dart +// 尺寸栅格 +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、引导页 +} + +// 主题层(注意 fill 当前为 no-op,见 §6.4) +iconTheme: IconThemeData( + size: AppIconSize.regular, + color: scheme.onSurfaceVariant, + fill: 0.0, weight: 400, grade: 0, opticalSize: 24, +), +``` + +--- + +## 7. 移动端布局与触控(沿用 v1) + +### 7.1 触控规范 + +| 项 | 规范 | +| --- | --- | +| 最小触控区 | **48×48 dp** | +| 相邻可点元素间距 | **≥ 8 dp** | +| 主操作按钮高度 | ≥ 52 dp | +| 输入框高度 | ≥ 56 dp | +| 列表项高度 | 单行 ≥56,多行 ≥72 | + +```dart +materialTapTargetSize: MaterialTapTargetSize.padded, +visualDensity: VisualDensity.standard, +iconButtonTheme: IconButtonThemeData( + style: IconButton.styleFrom( + minimumSize: const Size(48, 48), // 默认仅 40×40 + tapTargetSize: MaterialTapTargetSize.padded, + padding: EdgeInsets.zero, + ), +), +filledButtonTheme: FilledButtonThemeData(style: FilledButton.styleFrom( + minimumSize: const Size(64, 52), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.lg)), +)), +``` + +> **误触风险最高处**:`device_list_tile.dart:81` 的 `IconButton` 与 `StatusChip` 挤在同一 `Row` 内、**间距为 0**。建议补 8dp 间距,或把"发送指令"改为长按/左滑。 + +### 7.2 单手操作 + +- AppBar `actions` **最多 2 个**(右上角是单手最难触及区); +- 破坏性操作(删除)放**弹窗吸底栏**,不放 AppBar; +- 长表单主操作按钮**吸底**,不埋在滚动区末尾; +- FAB 包 `SafeArea`。 + +### 7.3 窄屏排版 + +| 元素 | 规则 | +| --- | --- | +| 列表项 | 卡片式,`margin: symmetric(horizontal: 12, vertical: 6)`,圆角 `lg`(16),阴影 `xs` | +| 卡片内边距 | 16(横屏 20) | +| 标题 | `titleSmall` + `w600`,1 行省略 | +| 副信息行 | 图标 20 + 文字 12/13,最多 4 行 | +| 表单 | **单列**,`labelText` 常驻,字段间距 12 | + +### 7.4 横屏 1600×900 适配 + +**现状**:`OrientationBuilder` **0 处**、断点 **0 处**、`home_page.dart:67` 与 `device_detail_page.dart:312` 写死 `crossAxisCount: 2` → 1600px 下卡片被拉到约 780px 宽。 + +**方案**:新建 `lib/utils/responsive.dart`,**用 `shortestSide` 而非 `width`**(1600×900 的 shortSide = 900 → `medium`): + +```dart +enum AppWindowSize { compact, medium, expanded } + +class AppResponsive { + AppResponsive(this.size, this.orientation); + final Size size; + final Orientation orientation; + factory AppResponsive.of(BuildContext context) => + AppResponsive(MediaQuery.sizeOf(context), MediaQuery.orientationOf(context)); + + bool get isLandscape => orientation == Orientation.landscape; + AppWindowSize get windowSize => switch (size.shortestSide) { + < 600 => AppWindowSize.compact, + < 840 => AppWindowSize.medium, // ★ 手机横屏 900 落这里 + _ => AppWindowSize.expanded, + }; + bool get useMasterDetail => size.width >= 840 && isLandscape; + int gridCount({double minTileWidth = 320}) => + (size.width / minTileWidth).floor().clamp(2, 6); + double get contentMaxWidth => switch (windowSize) { + AppWindowSize.compact => double.infinity, + AppWindowSize.medium => 560, + AppWindowSize.expanded => 720, + }; + double get gutter => switch (windowSize) { + AppWindowSize.compact => 12, AppWindowSize.medium => 24, _ => 32, + }; + double get cardPadding => windowSize == AppWindowSize.compact ? 16 : 20; +} + +extension AppResponsiveX on BuildContext { + AppResponsive get responsive => AppResponsive.of(this); +} +``` + +**六项改造**:① 底部导航横屏改 `NavigationRail`(§8.1);② 列表页双栏主从;③ Grid 列数自适应;④ 内容限宽居中;⑤ 弹窗 `maxWidth: 640` 居中;⑥ AppBar 补 `scrolledUnderElevation: 3` + 横屏 `toolbarHeight: 48`。 + +--- + +## 8. ★ 可重设计范围落地指引:导航栏 / 设置页 / 详情页 / 弹窗 + +### 8.1 导航栏(`lib/widgets/app_bottom_nav.dart`) + +**现状** +- M3 `NavigationBar`,5 个 tab;图标已正确使用线性/填充配对(`home_outlined` / `home`)→ **语义准确,保留** +- `indicatorColor` 默认取 `colorScheme.secondaryContainer`(Flutter 3.27 `navigation_bar.dart:1382` 已核实)→ **改主题后自动变暖橙 `#F5DFCB`,零改动生效** +- 横屏下 80dp 高度吃掉 900 的 8.9% + +**改动(4 项)** + +**① 主题层统一导航样式** + +```dart +navigationBarTheme: NavigationBarThemeData( + height: 72, // 80 → 72 + elevation: 0, + backgroundColor: scheme.surfaceContainer, + surfaceTintColor: scheme.surfaceTint, + indicatorColor: scheme.secondaryContainer, // → #F5DFCB(浅)/ #5A3A22(深) + indicatorShape: const StadiumBorder(), // 胶囊指示器,呼应云弧 + labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, + iconTheme: WidgetStateProperty.resolveWith((states) => IconThemeData( + size: 24, + // 选中图标落在 indicator(secondaryContainer) 上 → 用 onSecondaryContainer(7.90 ✅) + 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, + )), +), +``` + +**② 横屏切换 `NavigationRail`** + +```dart +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), +), +``` + +**③ 组件层按断点切换** + +```dart +// app_bottom_nav.dart +@override +Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + final r = context.responsive; + + if (r.isLandscape && r.size.width >= 840) { // ★ 横屏改 Rail + return Scaffold( + body: Row( + children: [ + NavigationRail( + selectedIndex: shell.currentIndex, + onDestinationSelected: _onTap, + destinations: _destinations(t), + ), + const VerticalDivider(width: 1, thickness: 1), + Expanded(child: ClipRect(child: shell)), + ], + ), + ); + } + return Scaffold( + body: shell, + bottomNavigationBar: NavigationBar( + selectedIndex: shell.currentIndex, + onDestinationSelected: _onTap, + destinations: _destinations(t), + ), + ); +} + +void _onTap(int index) => + shell.goBranch(index, initialLocation: index == shell.currentIndex); + +List _destinations(AppLocale t) => [ + NavigationDestination(icon: const Icon(Icons.home_outlined), + selectedIcon: const Icon(Icons.home), label: t.home), + NavigationDestination(icon: const Icon(Icons.devices_outlined), + selectedIcon: const Icon(Icons.devices), label: t.devices), + NavigationDestination(icon: const Icon(Icons.store_outlined), + selectedIcon: const Icon(Icons.store), label: t.stores), + NavigationDestination(icon: const Icon(Icons.admin_panel_settings_outlined), + selectedIcon: const Icon(Icons.admin_panel_settings), label: t.admin), + NavigationDestination(icon: const Icon(Icons.person_outline), + selectedIcon: const Icon(Icons.person), label: t.profile), +]; +``` + +> **收益**:横屏多出 80dp 垂直空间(占 900 的 8.9%);5 个 tab 移到左侧后**拇指横向可达,比底部更易点**。 + +**④ 图标处置**:`_destinations` 里的 10 个图标(5 组线性+填充)**全部保留原样**(语义准确);仅通过主题层改变配色与指示器形态。这符合"回归延续"基调 —— 导航栏是**点名重设计**区域,但重设计的是**容器与配色**,不是图标语义。 + +--- + +### 8.2 设置页(`lib/screens/profile/settings_page.dart`) + +**现状问题(实测)** + +| # | 问题 | 位置 | 严重度 | +| --- | --- | --- | --- | +| 1 | **三处 `SegmentedButton` 塞在 `ListTile.trailing`** | `:180-199` 主题、`:206-228` 语言、`:236-263` 环境 | **🔴 阻塞** | +| 2 | `Divider()` 满宽,与卡片化脱节 | `:174` `:230` `:265` `:272` `:290` | 🟡 | +| 3 | 退出登录**无二次确认**,点击即登出 | `:292-301` | 🔴 | +| 4 | 无分区标题,10 个条目平铺 | 全文 | 🟡 | +| 5 | 「关于」已有 logo 品牌位,但未强化 | `:278-289` | 🟢 | + +> **问题 1 详解**:`SegmentedButton` 3 段的最小宽度约 3×48 = 144dp。竖屏 360dp 屏上,扣除 `leading`(24) + 间距 + 标题文字后,`trailing` 可用宽度不足 144dp,**必然挤压溢出**;语言/环境切换是 2 段(约 96dp),同样紧张。这是当前设置页最严重的布局缺陷。 + +**改动(5 项)** + +**① 控件从 `trailing` 移到标题下方(全宽)** + +```dart +/// 设置分组卡片。 +class AppSettingsSection extends StatelessWidget { + const AppSettingsSection({super.key, this.title, required this.children}); + final String? title; + final List children; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Padding( + padding: EdgeInsets.symmetric( + horizontal: context.responsive.gutter, vertical: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: Text(title!, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: scheme.primary, // 暖橙分区标题 + fontWeight: FontWeight.w600)), + ), + CloudCard( + padding: EdgeInsets.zero, + level: CloudShadowLevel.xs, + child: Column( + children: [ + for (var i = 0; i < children.length; i++) ...[ + if (i > 0) + Divider(height: 1, indent: 56, endIndent: 16, + color: scheme.outlineVariant), + children[i], + ], + ], + ), + ), + ], + ), + ); + } +} + +/// 设置项:图标 + 标题 + 副标题 +(可选)标题下方的全宽控件。 +class AppSettingsTile extends StatelessWidget { + const AppSettingsTile({ + super.key, + required this.icon, + required this.title, + this.subtitle, + this.child, // ★ SegmentedButton 等控件放这里,全宽渲染 + this.onTap, + this.danger = false, + }); + final IconData icon; + final String title; + final String? subtitle; + final Widget? child; + final VoidCallback? onTap; + final bool danger; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final c = danger ? scheme.error : scheme.onSurfaceVariant; + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Icon(icon, size: 24, color: c), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.bodyLarge?.copyWith( + color: danger ? scheme.error : scheme.onSurface)), + if (subtitle != null) ...[ + const SizedBox(height: 2), + Text(subtitle!, style: theme.textTheme.bodySmall + ?.copyWith(color: scheme.onSurfaceVariant)), + ], + ], + ), + ), + if (onTap != null && child == null) + Icon(Icons.chevron_right, size: 22, color: scheme.onSurfaceVariant), + ]), + if (child != null) ...[ + const SizedBox(height: 12), + SizedBox(width: double.infinity, child: child), // ★ 全宽,不再挤 + const SizedBox(height: 4), + ], + ], + ), + ), + ); + } +} +``` + +**② 页面结构改造** + +```dart +@override +Widget build(BuildContext context, WidgetRef ref) { + final t = AppLocalizations.of(context); + return Scaffold( + appBar: AppBar(title: Text(t.settings)), + body: ListView( + padding: const EdgeInsets.only(bottom: 24), + children: [ + if (authState.user != null) + _buildUserHeader(context, authState.user!), + const SizedBox(height: 8), + AppSettingsSection( + title: '外观与语言', + children: [ + AppSettingsTile( + icon: Icons.dark_mode_outlined, + title: t.themeMode, + subtitle: _themeModeLabel(themeMode, t), + child: SegmentedButton(/* …原逻辑不变… */), + ), + AppSettingsTile( + icon: Icons.language_outlined, + title: t.language, + subtitle: appLocale.label, + child: SegmentedButton(/* …原逻辑不变… */), + ), + ], + ), + AppSettingsSection( + title: '系统', + children: [ + AppSettingsTile( + icon: Icons.verified_user_outlined, + title: '运行环境', + subtitle: _envLabel(AppConfig.environment), + child: SegmentedButton(/* …原逻辑不变… */), + ), + AppSettingsTile( + icon: Icons.lock_outline, + title: t.changePassword, + onTap: () => _showChangePasswordDialog(context, ref), + ), + ], + ), + AppSettingsSection( + title: '关于', + children: [ + AppSettingsTile( + icon: Icons.info_outline, + title: t.about, + subtitle: '${AppConstants.appName} v1.0.0', + onTap: () => showAboutDialog(/* …保留 logo… */), + ), + AppSettingsTile( + icon: Icons.logout, + title: t.logout, + danger: true, + onTap: () => _confirmLogout(context, ref, t), // ★ 二次确认 + ), + ], + ), + ], + ), + ); +} +``` + +**③ 退出登录二次确认** + +```dart +Future _confirmLogout(BuildContext context, WidgetRef ref, AppLocale t) async { + final scheme = Theme.of(context).colorScheme; + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + icon: Icon(Icons.logout, color: scheme.error), + title: const Text('确认退出登录?'), + content: const Text('退出后需要重新输入账号密码。'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(t.cancel)), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: scheme.error, + foregroundColor: scheme.onError, + ), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('退出'), + ), + ], + ), + ); + if (ok == true) ref.read(authProvider.notifier).logout(); +} +``` + +**④ 用户头部区加品牌渐变**(设置页在可改范围内,是理想的品牌渗透点) + +```dart +Widget _buildUserHeader(BuildContext context, user) { + final scheme = Theme.of(context).colorScheme; + return Container( + margin: EdgeInsets.symmetric(horizontal: context.responsive.gutter, vertical: 8), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + // logo 的橙→金渐变直接搬过来(浅色模式) + gradient: scheme.brightness == Brightness.light + ? AppBrand.cloudGradient + : null, + color: scheme.brightness == Brightness.dark ? scheme.surfaceContainer : null, + borderRadius: BorderRadius.circular(AppRadius.xl), // 20 + boxShadow: AppShadows.sm, + ), + child: Row(children: [ + CircleAvatar( + radius: 28, + backgroundColor: scheme.brightness == Brightness.light + ? Colors.white : scheme.primaryContainer, + child: Icon(Icons.person, size: 30, + color: scheme.brightness == Brightness.light + ? AppBrand.onCloud : scheme.onPrimaryContainer), + ), + const SizedBox(width: 16), + Expanded(child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(user.displayName, style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold, color: scheme.onSurface)), + Text(user.role.isEmpty ? '${t.account}:${user.account}' : user.role, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: scheme.onSurfaceVariant)), + ], + )), + ]), + ); +} +``` + +**⑤ 修改密码弹窗**(`:27-131`)改用 `AppSettingsTile` 后布局自然对齐;`AlertDialog` 由 `dialogTheme`(圆角 28)统一接管。 + +--- + +### 8.3 详情页(`device_detail_page.dart` / `store_detail_page.dart`) + +**现状问题(实测)** + +| # | 问题 | 位置 | +| --- | --- | --- | +| 1 | `_infoRow` 的 label 列**写死 `width: 88`** | `:208-215`、`:284-292` | +| 2 | 指标 `GridView` **写死 `crossAxisCount: 2`** | `:312` | +| 3 | 操作按钮「发指令 / 改分组 / 状态 Chip」横排在内容区 | `:90-110` | +| 4 | `_sectionCard` 用满宽 `Divider` | `:183` | +| 5 | `store_detail_page.dart` 是 16 行桩页 | 全文 | + +> 横屏 1600px 下,88dp 的 label 列仅占 5.5%,比例失衡;竖屏窄屏下又偏挤。 + +**改动(5 项)** + +**① label 列宽响应式** + +```dart +double _labelWidth(BuildContext context) => switch (context.responsive.windowSize) { + AppWindowSize.compact => 96, + AppWindowSize.medium => 128, + AppWindowSize.expanded => 160, + }; + +Widget _infoRow(BuildContext context, String label, String value) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: _labelWidth(context), + child: Text(label, style: theme.textTheme.bodyMedium + ?.copyWith(color: theme.colorScheme.onSurfaceVariant)), + ), + Expanded( + child: Text(value, style: theme.textTheme.bodyMedium + ?.copyWith(fontWeight: FontWeight.w600)), + ), + ], + ), + ); +} +``` + +**② 指标 Grid 列数自适应** + +```dart +// device_detail_page.dart:311 +GridView.count( + crossAxisCount: context.responsive.gridCount(minTileWidth: 300), // 竖屏2 / 横屏4 + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: context.responsive.isLandscape ? 1.8 : 1.15, + children: [/* 4 个 MetricCard 不变 */], +) +``` + +> **注**:4 个 `MetricCard` 的图标(`:320` `Icons.memory`、`:328` `Icons.storage`、`:337` `Icons.sd_storage`、`:346` `Icons.network_check`)在 C 区,可统一为 `_outlined` 变体并接入语义色阈值(`:325` `:334` `Colors.red`、`:343` `Colors.orange` → `AppSemanticColors`)。 + +**③ 操作区改为吸底栏**(释放内容区,符合单手操作) + +```dart +// Scaffold 上 +bottomNavigationBar: SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Row(children: [ + Expanded(child: FilledButton.icon( + onPressed: () => _showCommandSheet(context, device), + icon: const Icon(Icons.send_outlined, size: 20), + label: Text(t.sendCommand), + )), + const SizedBox(width: 12), + Expanded(child: FilledButton.tonal.icon( + onPressed: () => _showEditSheet(context, ref, device), + icon: const Icon(Icons.edit_outlined, size: 20), + label: Text(t.editGroup), + )), + ]), + ), +), +``` + +> `StatusChip` 从操作行移出,改挂在 AppBar `actions` 或"基本信息"区(原 `:109` 位置)—— 释放底部空间、消除与按钮的误触风险。 + +**④ 分区卡片改 `CloudCard` + 内缩分隔线** + +```dart +Widget _sectionCard(BuildContext context, String title, List rows) { + final scheme = Theme.of(context).colorScheme; + return Padding( + padding: EdgeInsets.symmetric(horizontal: context.responsive.gutter, vertical: 6), + child: CloudCard( + level: CloudShadowLevel.xs, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Divider(height: 1, color: scheme.outlineVariant), // 随卡片内缩 + const SizedBox(height: 8), + ...rows, + ], + ), + ), + ); +} +``` + +**⑤ `store_detail_page.dart` 桩页按新规范实现** + +现为 `Center(child: Text('门店 $storeId\n功能开发中'))`。建议改为与设备详情同构的骨架(AppBar + 分区卡 + 吸底操作),保持详情页视觉一致;功能未开放处用统一的空状态组件(P2-8)。 + +--- + +### 8.4 弹窗(全项目 30 处 `showModalBottomSheet`) + +**现状四个问题(已核实 Flutter 3.27 源码)** + +| # | 问题 | 证据 | +| --- | --- | --- | +| 1 | `useSafeArea` **默认 `false`**,全项目 30 处无一处显式开启 | `bottom_sheet.dart:874` | +| 2 | `isScrollControlled: true` 但**未限高** | `store_list_page.dart:142` | +| 3 | **无 `showDragHandle`**,用户不知道能下滑关闭 | 全部 30 处 | +| 4 | **保存按钮埋在滚动区末尾** | `store_list_page.dart:1404-1416` | + +**① 主题层全局兜底(一次性覆盖 30 处)** + +```dart +bottomSheetTheme: BottomSheetThemeData( + showDragHandle: true, + useSafeArea: true, // ★ 默认 false,必须显式开启 + backgroundColor: scheme.surface, + surfaceTintColor: scheme.surfaceTint, + modalBackgroundColor: scheme.surface, + elevation: 0, + clipBehavior: Clip.antiAlias, + shape: const RoundedRectangleBorder(borderRadius: AppRadius.sheetTop), // 顶部 28 + constraints: const BoxConstraints(maxWidth: 640), // ★ 横屏不铺满 +), +``` + +**② 页面层统一封装 `showAppBottomSheet`** + +```dart +Future showAppBottomSheet({ + required BuildContext context, + required Widget body, + Widget? footer, // 吸底操作栏(表单类必传) + String? title, + double heightFactor = 0.92, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + useSafeArea: true, + constraints: BoxConstraints( + maxWidth: 640, + maxHeight: MediaQuery.sizeOf(context).height * heightFactor, + ), + builder: (ctx) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (title != null) + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), + child: Align( + alignment: Alignment.centerLeft, + child: Text(title, style: Theme.of(ctx).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.bold)), + ), + ), + Flexible( + child: SingleChildScrollView( + padding: EdgeInsets.only( + left: 20, right: 20, bottom: 20, + top: 0, + ), + child: body, + ), + ), + if (footer != null) + Container( + decoration: BoxDecoration( + color: Theme.of(ctx).colorScheme.surface, + border: Border(top: BorderSide( + color: Theme.of(ctx).colorScheme.outlineVariant)), + boxShadow: AppShadows.lg, + ), + padding: const EdgeInsets.fromLTRB(20, 12, 20, 12), + child: SafeArea(top: false, child: footer), // ★ 吸底,不受滚动影响 + ), + ], + ), + ); +} +``` + +> **键盘适配**:`body` 内保留 `padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom)`;吸底栏用 `SafeArea(top: false)` 处理底部手势条。 + +**③ 30 处弹窗分类改造模板** + +| 类型 | 数量 | 代表位置 | `heightFactor` | 是否有吸底栏 | 要点 | +| --- | --- | --- | --- | --- | --- | +| **表单类** | ~18 | `store_list_page.dart:142`(门店新增/编辑)
`org_tree_page.dart:87/103`
`device_group_page.dart:168`
`device_detail_page.dart:116`(改分组/房号)
`device_activation_page.dart:90`
`admin/sys/user:219` `role:84` `menu:82` `config:103` `schedule:85`
`admin/app/app_version:122` `tv_app:110`
`admin/intro/category:588/730/964` `intro_content:452/568/800`
`admin/media/list:109` `media_rank:129` | **0.92** | **✅ 必配** | 保存/创建按钮移出滚动区 | +| **选择类** | ~7 | `device_command_sheet.dart`
`org_picker_sheet.dart:280`
`region_picker.dart:51`
`marquee:93/587/902` | **0.70** | 视情况 | 需搜索框置顶固定 | +| **详情类** | 1 | `store_list_page.dart:158`(门店详情) | **0.80** | ✅(编辑/指令/删除) | 操作下沉到吸底栏 | +| **指令类** | 1 | `store_list_page.dart:188`(门店批量指令) | 自适应 | ❌ | `mainAxisSize: min`,不设限高 | + +**④ 门店表单改造示意(P0 最高优先)** + +```dart +// store_list_page.dart:140 改造后 +void _showAddEditSheet({StoreInfo? store}) { + final currentUser = ref.read(authProvider).user; + showAppBottomSheet( + context: context, + title: store == null ? t.addStore : t.editStore, + heightFactor: 0.92, + body: _StoreFormBody( // 原 _StoreFormSheet 的滚动内容部分 + orgService: _orgService, + store: store, + currentUserOrgId: currentUser?.orgId, + formKey: _formKey, // 由外部持有,供吸底按钮校验 + ), + footer: SizedBox( + width: double.infinity, + child: FilledButton( // ★ 吸底,始终可见 + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox(height: 20, width: 20, + child: CircularProgressIndicator(strokeWidth: 2)) + : Text(store == null ? t.create : t.save), + ), + ), + ); +} +``` + +--- + +## 9. 落地清单(按冻结区重排) + +### 9.1 P0 —— 回归橙白 + 阻塞性修复 + +| # | 文件 | 改动 | 冻结区影响 | +| --- | --- | --- | --- | +| P0-1 | `lib/app/theme.dart` | 全量重写:`primaryBlue` → `brandPrimary = #C2410C`;`ColorScheme.fromSeed` → 显式 `ColorScheme(...)`;新增 `AppRadius`/`AppShadows`/`AppIconSize`/`AppBrand`/`AppSemanticColors` | 无 | +| P0-2 | `lib/app/theme.dart` | 新增 `iconTheme`(仅 C 区生效)/`cardTheme`/`bottomSheetTheme`/`navigationBarTheme`/`navigationRailTheme`/`dialogTheme`/`visualDensity`/`iconButtonTheme`/三 ButtonTheme;`appBarTheme` 补 `scrolledUnderElevation: 3` | 无 | +| P0-3 | `lib/screens/login/login_page.dart` | 整页去硬编码:L152 `#121212`→`surface`;L149 `#0D2137`(深蓝)→`surfaceContainerLow`;L145-148 输入色→token;L205/233/280/350/370→`primary`;L372 `#1A4480`→`onSurface` 12% | 无 | +| P0-4 | `lib/screens/login/login_page.dart` | 品牌化:Logo 80→112;加 `AppBrand.cloudGradient` 装饰带;`maxWidth: 420` 保留 | 无 | +| P0-5 | `lib/main.dart:60-61` | `statusBarIconBrightness` 硬编码 `light` → 跟随 `themeMode`(**修 bug**) | 无 | +| P0-6 | `lib/widgets/store_theme_grid.dart:77` | 选中边框 `#2E7DFF` → `scheme.primary` | 无 | +| P0-7 | `lib/screens/store/store_list_page.dart` | 三处弹窗(:142/:158/:188)改 `showAppBottomSheet`;**保存按钮移出滚动区改吸底**(原 :1404-1416) | 无 | +| P0-8 | `lib/utils/responsive.dart` | **新建** `AppResponsive` + `BuildContext.responsive` | 无 | +| P0-9 | `lib/widgets/app_bottom_sheet.dart` | **新建** `showAppBottomSheet` 统一封装 | 无 | +| P0-10 | **冻结区标记** | 在 A 区 12 处 + B 区 26 处图标上方加 `// [FROZEN] 冻结:勿改图标,见 docs/手机端UI优化方案-v2.md §6` 注释;D 区 10 处加 `// [FROZEN-BACKEND-CONTRACT] 后端 sys_menu.icon 映射,勿改` | **保护性改动(仅加注释)** | + +### 9.2 P1 —— 四类重设计 + 布局触控 + +| # | 文件 | 改动 | 冻结区影响 | +| --- | --- | --- | --- | +| P1-1 | `lib/widgets/app_bottom_nav.dart` | 横屏切 `NavigationRail`;`destinations` 提取为方法;**图标保留原样** | 无 | +| P1-2 | `lib/screens/profile/settings_page.dart` | 三处 `SegmentedButton` 从 `trailing` 移到标题下方;分组卡 `AppSettingsSection`;退出登录加二次确认;用户头部加品牌渐变 | 无 | +| P1-3 | `lib/screens/home/device_detail_page.dart` | label 列宽响应式;Grid 自适应;操作区改吸底栏;`StatusChip` 移出操作行;分区卡改 `CloudCard`;`:325/334/343` 语义色 | `:320/328/337/346` 四图标在 C 区,**可改** | +| P1-4 | `lib/widgets/cloud_card.dart` | **新建** `CloudCard` | 无 | +| P1-5 | `lib/screens/home/home_page.dart` | `:67` Grid 列数自适应;`_StatCard` 换 `CloudCard`(**图标与配色不动**);`_QuickAction` 补 48 触控 | **⚠️ 图标冻结,只改容器** | +| P1-6 | `lib/widgets/device_list_tile.dart` | `:81` `IconButton` 与 `StatusChip` 补 8dp 间距;换 `CloudCard`(圆角 16 + 阴影 xs) | **⚠️ `:26/28/30` 设备图标属 B 区,本轮不动** | +| P1-7 | `lib/widgets/status_chip.dart` | `:16-20` `Colors.green/grey/orange` → 语义色 | `:43` `Icons.circle` 在 C 区,可改 | +| P1-8 | `lib/widgets/region_picker.dart` | `:189` `Colors.blue` → `scheme.primary`;`:154` 高度 0.6 改按断点 | 无 | +| P1-9 | `lib/screens/store/org_tree_page.dart` | `:280-290` 六色 → 主题色板;`:63/351` `Colors.red` → `error` | 无 | +| P1-10 | `lib/screens/device/device_activation_page.dart` | `:119/293` `Colors.orange` → `scheme.primary`(**换色后橙色=品牌色,必须改**);`:158` 语义色 | 无 | +| P1-11 | `lib/screens/alerts/alerts_page.dart` | `:15/17/19` → 语义色 | `:26/28/30` 告警图标在 C 区,可改 | +| P1-12 | **C 区图标统一** | 按 §6.3 清单,将 C 区图标统一为 `_outlined` 变体 + `AppIconSize` 栅格 | **执行前须逐行比对 §6.1 A 区 / §6.2 B 区 / §6.2.1 D 区,48 处全部排除** | +| P1-13 | 其余 20+ 处弹窗 | 补 `useSafeArea` + `constraints`(主题层已兜底把手/形状/宽度) | 无 | + +### 9.3 P2 —— 增强(含待确认项解锁后再做) + +| # | 文件 | 改动 | 前置条件 | +| --- | --- | --- | --- | +| P2-1 | `store_list_page.dart` / `device_list_page.dart` | 横屏双栏主从 | — | +| P2-2 | `login_page.dart` | 横屏左右分栏(左品牌渐变 + 右表单) | — | +| P2-3 | `pubspec.yaml` | 引入 Material Symbols 可变字体,让 `fill/weight/grade/opticalSize` 生效 | — | +| P2-4 | `store_detail_page.dart` | 按新规范实现详情页骨架 | — | +| P2-5 | `device_scan_page.dart` | 取景框按 `shortestSide` 计算(修横屏压扁) | — | +| P2-6 | 全局 | 空/加载/错误三态组件统一 | — | +| P2-7 | `main.dart:193/255` | 错误页 `Colors.red.shade50` 硬编码 → 跟随主题 | — | +| P2-8 | **B 区图标处理** | 若用户确认解冻,再统一 B 区 26 处 | **需用户确认** | +| P2-9 | **首页统计卡四色** | 若用户选择 §6.1 选项②,改为语义色 | **需用户确认** | + +--- + +## 10. 风险与权衡 + +### 10.1 换色影响面 + +**必须改(13 处)**:见 §2.4 表。 + +**绝对不能动(业务数据色)**: + +| 文件:行 | 值 | 原因 | +| --- | --- | --- | +| `store_theme_preview.dart:110-115` | `#3B82F6` `#8B5CF6` `#EC4899` `#F59E0B` `#10B981` `#6366F1` | **下发给 TV 端的门店主题模板预览色板** | +| `store_theme_grid.dart:129-134` | 同上 6 色 | 同上 | +| `models/store_theme.dart:46-54` | `#1565C0` `#00838F` `#2E7D32` `#E65100` `#455A64` | 门店主题色枚举 | + +> **最大误伤陷阱**:`#3B82F6` 与 `#2E7DFF` 都是蓝色,**正则批量替换必然误伤**。 +> 只做 `Color(0xFF2E7DFF)` **整词精确替换** + 逐处人工确认。 + +### 10.2 深色模式下橙色的 3 条硬约束 + +1. **主色必须提亮到 `#FFB487`,`onPrimary` 必须是深棕 `#542000`**(白字仅 1.73 ❌)。沿用 `#C2410C` 会让按钮在深底上像禁用态。 +2. **亮橙只做小面积强调(<8%)**:图标/进度条/选中指示/链接;容器级用深橙底 `#63300D` + `#FFDCC2` 文字。大面积亮橙在 OLED 上会光晕刺眼。 +3. **surface 必须暖化**:`#16130F`(R>G>B);不能用 `#121212`,更不能用现状的 `#0D2137`(深蓝)。 + +### 10.3 ★ 新增:冻结区实施风险(本轮最高优先级风险) + +| 风险 | 触发场景 | 后果 | 缓解 | +| --- | --- | --- | --- | +| **批量替换误伤** | 执行 P1-12「`Icons.xxx` → `Icons.xxx_outlined`」时用全局正则 | 首页应用图标、TV 应用图标被改 → **直接违反用户硬约束** | **禁止全局正则**。逐文件按 §6.1 A 区 / §6.2 B 区 / §6.2.1 D 区清单比对后手工改;改完用 §11 的 grep 验收 | +| **后端契约被改** | 同上,且未识别 `menu_page.dart:_iconData()` 是后端映射表 | 移动端与 Web 后台菜单图标不一致,属**功能缺陷**而非样式问题 | §6.2.1 单列为 D 区并明确「无商量余地」;代码内加 `// [FROZEN-BACKEND-CONTRACT]` 注释 | +| **主题色间接改动** | `_QuickAction`(`home_page.dart:196-197`)用 `primaryContainer`/`primary`,换主题后快捷入口变橙 | 是否算"修改图标设计"存在歧义 | **已判定:颜色跟随主题属"回归橙白",是目标不是副作用**;但**图形/尺寸/结构不动**。若用户不认可,把这两行改为固定值即可 | +| **统计卡四色歧义** | `home_page.dart:78/84/90/96` 硬编码蓝紫绿红 | 不动则首页仍有蓝色;动则可能违反冻结 | **默认冻结不动**,列为 §6.1 待确认项 | +| **B 区归属未定** | 26 处内容占位/设备图标/管理入口图标 | 改了可能越界,不改可能风格不统一 | **默认不动**,P2-8 待用户一句话确认 | +| **按文件整体冻结** | 实施者误以为 `admin/app/` 整个目录冻结 | `tv_app_page.dart:245/249/341` 的 search/clear/add 本可统一却没改 | 明确规则:**按图标实例判定,不按文件**(§6.0) | +| **冻结区容器与图标的边界** | 首页统计卡换 `CloudCard` 时顺手改了 `Icon.size` | 违反冻结 | 明确边界:**冻结 = 图形 + size + 布局结构 + 入口数量**;**可改 = 卡片容器材质(圆角/阴影/内边距)** | + +### 10.4 其他 + +| 风险 | 影响 | 缓解 | +| --- | --- | --- | +| 弃用 `fromSeed` | 失去 M3 动态取色 | 强品牌识别诉求优先;且现状已用 `.copyWith(primary:)` 覆盖,动态取色本就未生效 → 可接受 | +| 亮橙不能作文字/图标色 | 少一个高亮手段 | 用 `primaryContainer` + `onPrimaryContainer` 组合替代;Banner 用大色块亮橙 + 深棕字(6.99 ✅) | +| 桌面图标/启动图未同步 | 主题改了图标没改 | **不在本方案范围**,建议单独立项(`android/app/src/main/res/`) | +| 登录页去硬编码 | 该页此前硬编码是为规避「M3 动态取色导致文字背景融为一体」(见 L144 注释) | 改显式 ColorScheme 后理由不成立;但**别顺手删 try/catch 兜底** | +| 默认深色→浅色切换 | 老用户升级后界面突变 | 建议无显式偏好时跟随 `ThemeMode.system`,而非硬切 light(**待确认**) | + +--- + +## 11. 验收标准(可度量) + +| 项 | 验收方式 | 目标 | +| --- | --- | --- | +| **冻结区零改动** | `git diff` / 逐行比对 §6.1 A 区 12 处 | A 区图标的 `IconData` 与 `size` **必须逐字节一致** | +| | 逐行比对 §6.2 B 区 26 处 | 未获用户确认前**不得出现任何 diff** | +| | 逐行比对 §6.2.1 D 区 10 处 | **任何情况下都不允许 diff**(后端契约) | +| **品牌一致性** | 5 个主页面截图取样主色相 | 主色 H ∈ [20°, 26°](logo 为 22.9°) | +| **对比度** | §4 全部配对复核 | 全部达 WCAG AA(正文 4.5 / 大字 3.0 / 非文本 3.0) | +| **触控** | Android 无障碍扫描 | 所有可点元素 ≥48×48,间距 ≥8 | +| **横屏** | PGBM10 实机横屏 1600×900 | 无横向失真;弹窗宽 ≤640 居中;底部导航为 Rail | +| **弹窗** | 实机打开全部 30 处 | 全部有把手 / 限高 / 不被手势条遮挡 / 表单类有吸底栏 | +| **设置页** | 竖屏 360dp 实机 | `SegmentedButton` **无溢出、无挤压** | +| **详情页** | 竖屏 + 横屏各走查 | label 列宽自适应;指标 Grid 竖屏 2 列 / 横屏 4 列 | +| **残留检查** | `grep -rn "0xFF2E7DFF\|Colors.blue" lib/` | 除 `store_theme_*` 与 `models/store_theme.dart` 业务色外,命中为 **0** | +| **图标检查** | `grep -rn "Icons\." lib/screens/home/home_page.dart` | 与 §6.1 A1 表**逐行一致**(图形与 size 未变) | + +--- + +## 12. 附录 + +### 附录 A:冻结区速查表(打印贴在工位) + +``` +⛔ 绝对冻结(12 处,禁止任何修改) + 首页 home_page.dart + :77 Icons.devices 统计卡-设备总数 size 28 + :83 Icons.store 统计卡-门店总数 size 28 + :89 Icons.check_circle_outline 统计卡-在线设备 size 28 + :95 Icons.highlight_off 统计卡-离线设备 size 28 + :114 Icons.devices 快捷入口-设备管理 CircleAvatar + :121 Icons.store 快捷入口-门店管理 CircleAvatar + :128 Icons.people 快捷入口-用户管理 CircleAvatar + 应用显示相关 + tv_app_page.dart:301 Icons.apps size 40 应用图标加载失败占位 + tv_app_page.dart:304 Icons.apps 应用图标默认占位 + store_theme_preview.dart:105 Icons.storefront size 26 门店主页预览 Logo 占位 + store_theme_group_list.dart:174 Icons.image_outlined size 28 主题缩略图占位 + branding_editor.dart:155 Icons.image size 28 Logo/启动图占位 + +⚠️ 灰区(26 处,需用户确认后处理,默认不动) + admin/intro/intro_category_page.dart :375 :378 :436 :439 :1219 :1226 + admin/intro/intro_store_content_page.dart :332 :335 :407 :410 :1100 :1107 + admin/media/media_list_page.dart :336 :347 + admin/media/media_rank_page.dart :209 :215 :323 :330 + widgets/device_list_tile.dart :26 :28 :30 + screens/admin/admin_hub_page.dart :18 :24 :30 :36 :42 + +🚫 后端契约冻结(10 处,无商量余地,改了会破坏与 Web 后台的菜单图标一致性) + screens/admin/sys/menu_page.dart :280 :282 :284 :286 :288 :290 :292 :294 :296 :298 + 理由:_iconData() 是「后端 sys_menu.icon 字段 → IconData」的映射表,属接口约定 + +⚠️ 待确认(1 项) + home_page.dart :78 :84 :90 :96 统计卡四色硬编码(blue/purple/green/red) + +✅ 可改(C 区) + 导航栏 app_bottom_nav.dart :32-53(图标保留,改配色与容器) + 设置页 settings_page.dart :166 :177 :184 :188 :192 :203 :233 :268 :275 :294 + 详情页 device_detail_page.dart :320 :328 :337 :346 :507 :544 + 弹窗 org_picker_sheet.dart / region_picker.dart / store_list_page 弹窗内 + 通用交互图标:search clear close add edit delete refresh check + chevron_right arrow_drop_down expand_less/more upload copy save + 注:通用交互图标即便出现在冻结区文件内也可改(按实例判定,不按文件) +``` + +### 附录 B:`AppSemanticColors` 完整代码 + +```dart +class AppSemanticColors extends ThemeExtension { + 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, successContainer, onSuccessContainer; + final Color warning, warningContainer, onWarningContainer; + final Color info, infoContainer, onInfoContainer; + + static const light = AppSemanticColors( + success: Color(0xFF1E7A4E), successContainer: Color(0xFFD3EDDD), + onSuccessContainer: Color(0xFF1E5138), + warning: Color(0xFF8A5C00), warningContainer: Color(0xFFFFE9B8), + onWarningContainer: Color(0xFF5C4100), + info: Color(0xFF0B6397), infoContainer: Color(0xFFD7E9FA), + onInfoContainer: Color(0xFF1A4763), + ); + static const dark = AppSemanticColors( + success: Color(0xFF5FD398), successContainer: Color(0xFF1E5138), + onSuccessContainer: Color(0xFFA8E6C4), + warning: Color(0xFFF0BE4E), warningContainer: Color(0xFF5C4100), + onWarningContainer: Color(0xFFFFDCA8), + info: Color(0xFF7FC4F0), infoContainer: Color(0xFF1A4763), + onInfoContainer: Color(0xFFB8DCF5), + ); + + @override ThemeExtension copyWith() => this; + @override ThemeExtension lerp( + covariant ThemeExtension? other, double t) => + other ?? this; +} + +class AppBrandExtension extends ThemeExtension { + const AppBrandExtension({ + this.cloud = AppBrand.cloud, + this.cloudGold = AppBrand.cloudGold, + this.onCloud = AppBrand.onCloud, + this.gradient = AppBrand.cloudGradient, + }); + final Color cloud, cloudGold, onCloud; + final LinearGradient gradient; + + @override ThemeExtension copyWith() => this; + @override ThemeExtension lerp( + covariant ThemeExtension? other, double t) => + this; +} +``` + +### 附录 C:`theme.dart` 改造完整代码 + +见 v1 `手机端UI优化方案.md` §7.4。**v2 相对 v1 需追加的项**:`navigationBarTheme`、`navigationRailTheme`、`dialogTheme`(§8.1 / §8.2)。其余部分(`AppBrand` / `AppRadius` / `AppShadows` / `AppIconSize` / 两套 `ColorScheme` / `_base()`)**完全沿用 v1,未作修改**。 + +--- + +## 13. 需要用户拍板的 3 件事 + +| # | 事项 | 选项 | 产品建议 | +| --- | --- | --- | --- | +| 1 | **首页统计卡四色**(`home_page.dart:78/84/90/96`) | ① 保持硬编码蓝紫绿红(冻结)
② 改语义色(图形尺寸不动) | **建议 ②** —— 图标图形与尺寸零改动(符合冻结),仅配色纳入橙白体系;否则首页会残留最显眼的蓝色 | +| 2 | **B 区 26 处是否同 A 区一并冻结** | ① 一并按冻结处理
② 解冻,按新规范统一 | **建议 ①** —— 这些都是"下发给 TV/门店端展示内容的占位图形",性质与 A2 完全相同;且解冻收益很小(占位图形本来就极少被看到) | +| 3 | **默认主题模式**(`theme_provider.dart:7`) | ① 保持 `ThemeMode.dark`
② 改 `ThemeMode.light`
③ 无偏好时跟随系统 | **建议 ③** —— logo 明度均值 245/255,"明亮轻盈"才是品牌主场;但跟随系统最稳妥,避免老用户升级突变 | + +> 另:**桌面图标 / 启动图**(`android/app/src/main/res/`)不在本方案范围内,建议单独立项 —— 否则 App 内主题改了、桌面图标还是旧色,品牌一致性打折。 diff --git a/docs/手机端UI优化方案.md b/docs/手机端UI优化方案.md new file mode 100644 index 0000000..836dcdb --- /dev/null +++ b/docs/手机端UI优化方案.md @@ -0,0 +1,1475 @@ +# ops-console(爱视云管理)手机端 UI 优化方案 + +> # ⛔ 本文档已作废,请勿据此实施 +> +> **唯一有效版本:[`手机端UI优化方案-v2.md`](./手机端UI优化方案-v2.md)** +> +> 本文件仅作历史存档保留。v2 相对本文的变更: +> 1. 基调从「重新定义品牌视觉语言」改为「**回归旧版橙白 + 合理化改进**」; +> 2. 新增「新旧版延续性对照」章节; +> 3. **图标章节按冻结区重写** —— 用户要求「首页应用图标及与应用显示相关的图标维持原有设计不做任何修改」,本文 §5「图标风格」**已作废,请勿执行**(尤其不要把 `Icons.dns` 改成 `Icons.cloud_*`); +> 4. 新增「导航栏 / 设置页 / 详情页 / 弹窗」四类代码级落地指引。 +> +> 本文的**配色体系(§3)、圆角与阴影(§4)、布局与触控(§6)、`theme.dart` 改造代码(§7.4)仍然有效**,v2 已完整沿用。 + +--- + +| 项 | 内容 | +| --- | --- | +| 项目 | ops-console(包名 `com.ainetv.aishicloud`) | +| 技术栈 | Flutter 3.27.4 / Dart 3.6.2 / Material 3 / go_router / Riverpod | +| 目标设备 | Android 手机;**实测横屏 1600×900(rotation=1)**,同时兼容竖屏 | +| 用户画像 | 门店店员 / 现场运维人员,**常在现场单手操作** | +| 文档作者 | 许清楚(产品经理) | +| 对标基准 | WCAG 2.1 AA;Material 3 规范;Android 无障碍触控建议 | +| **状态** | **历史存档(v1)** —— 有效版本见文首链接 | + +--- + +## 1. 一句话结论 + +**当前主题主色 `#2E7DFF`(科技蓝)与 logo 主色 `#FF914D`(暖橙)直接冲突,这是"视觉不一致"的根源。** + +本方案以 logo 实测色 `#FF914D` / `#FFBE58` / `#FEFEFE` 为唯一取色来源,重建一套**暖橙 + 近白云感的品牌设计语言**,并同步解决三个被源码证实的硬伤: + +1. **层次缺失**:`Card` elevation 仅 1、圆角无梯度 → 整屏扁平,与"云朵漂浮"意象无关; +2. **触控与横屏未适配**:全项目 **0 处** `OrientationBuilder`、0 处响应式断点,GridView 写死 `crossAxisCount: 2` → 1600px 横屏下卡片被拉伸到 780px,信息密度崩溃; +3. **30 处底部弹窗全部裸奔**:`showModalBottomSheet` 未设 `useSafeArea`、未限高、未加拖拽把手,门店新增/编辑长表单的**保存按钮埋在滚动区末尾**。 + +--- + +## 2. 设计语言定义(由 logo 实测数据推导) + +logo `assets/images/logo.png`(192×192,RGBA)实测数据: + +| 维度 | 实测值 | 推导出的设计语言 | +| --- | --- | --- | +| 容器形状 | 圆角矩形,圆角 ≈ 6px / 192px = **3%** | **克制的小圆角**,非胶囊、非圆形 | +| 主色 `#FF914D` | 占非透明像素 4.4%,**图形中心像素** | **品牌主色(暖橙)** | +| 金黄 `#FFBE58` | 1.9% | **品牌辅助色(暖金)** | +| 渐变带 | `#FFA36A` / `#FFB487` / `#FFC56C` / `#FCE2CB` | **橙→金渐变**,是"云朵受光"的表达 | +| 中性 `#DBBB88` | 3.5% | 暖调中间色 → 中性色阶需**偏暖,不可用纯灰** | +| 近白 `#FEFEFE` | 85.0% | **大面积留白**,界面是"亮、轻、通透"的 | +| 明度 | 均值 245/255,**最暗仅 163** | 无深色成分 → **浅色模式为品牌主场** | +| 橙色行分布 | `[0,0,0,18,34,28,13,85,103,71,45,11,35,0,0,0]` | **中部双峰起伏** → 云朵多圆弧叠加轮廓 | + +### 视觉关键词(后续所有决策的判据) + +> **云 · 轻盈 · 暖橙 · 明亮 · 圆润弧线 · 大留白** + +派生出 4 条可执行的判据: +1. 中性色一律带**暖色偏移**(R>G>B),禁止使用纯灰 `#9E9E9E` / 纯黑 `#000000`; +2. 阴影用**暖棕低透明**而非纯黑,模糊半径大、偏移量小 → "托起"而非"压住"; +3. 圆角**中等偏大但不成胶囊**,且**顶部圆角 ≥ 底部圆角**(呼应云朵上缘多弧、下缘平缓); +4. 品牌亮橙 `#FF914D` **只做大色块/渐变/插画/图表**,不做文字与图标色(原因见 §3.2 实测)。 + +--- + +## 3. 配色体系(重点) + +### 3.1 关键约束:logo 亮橙不能配白字(实测) + +用 WCAG 2.1 相对亮度公式对 logo 原色实测(脚本核算,非估算): + +| 组合 | 对比度 | 结论 | +| --- | --- | --- | +| 白字 `#FFFFFF` on `#FF914D` | **2.23** | ❌ 不达标(正文需 4.5,大字需 3.0) | +| 白字 `#FFFFFF` on `#FFBE58` | **1.65** | ❌ 严重不达标 | +| 白字 `#FFFFFF` on `#FFB487` | **1.73** | ❌ 不达标 | +| 黑字 `#000000` on `#FF914D` | **9.41** | ✅ 达标 | +| 深棕 `#3D1A00` on `#FF914D` | **6.99** | ✅ 达标(推荐,比纯黑更柔和、更品牌) | + +**结论(必须写进设计约束):** + +> **在 `#FF914D` 系亮橙上,永远不要用白字;统一用深棕 `#3D1A00`。** +> 反过来,亮橙在浅色底上作为**图标/文字色**也不可用(`#FFB487` on `#FCFAF8` 仅 1.66)。 + +因此采用 **"双橙策略"**,这是本方案配色部分的核心决策: + +| 角色 | 色值 | 用途 | 上/下字色 | +| --- | --- | --- | --- | +| **品牌色(Brand)** | `#FF914D` | 插画、渐变、图表、Banner 大色块、Splash、进度条填充 | 深棕 `#3D1A00` | +| **行动色(Primary)** | `#C2410C` | 主按钮、导航选中、可点击文字、图标(需承载白字) | 白 `#FFFFFF` | +| **辅金色(Accent)** | `#FFBE58` | 高亮强调、到期提醒、星级、推荐位 | 深棕 `#3D1A00` | + +`#C2410C` = `#FF914D` 沿同色相(H≈23°)压暗到 tone-45,既保住 logo 的橙的**色相识别**,又把亮度压到能承载白字(vs 白 = **5.18** ✅)。 + +> **一句话记忆**:**"看"用亮橙 `#FF914D`,"点"用深橙 `#C2410C`。** + +### 3.2 品牌色板(Brand Palette) + +```dart +// lib/app/theme.dart — 品牌原色(直接取自 logo,不参与 ColorScheme 角色分工) +class AppBrand { + // —— logo 原色(仅用于大色块 / 渐变 / 插画 / 图表,禁止作文字色 —— + static const Color cloud = Color(0xFFFF914D); // logo 主色(图形中心像素) + static const Color cloudGold = Color(0xFFFFBE58); // logo 金黄 + static const Color cloudLight = Color(0xFFFFB487); // logo 渐变带 + static const Color cloudMid = Color(0xFFFFA36A); // logo 渐变带 + static const Color cloudWarm = Color(0xFFFFC56C); // logo 渐变带 + static const Color cloudCream = Color(0xFFFCE2CB); // logo 渐变带(最浅) + static const Color cloudPaper = Color(0xFFFEFEFE); // logo 近白底 + + // —— 亮橙上的唯一合法文字色 —— + static const Color onCloud = Color(0xFF3D1A00); // vs #FF914D = 6.99 ✅ + + // —— 品牌渐变(搬运 logo 的橙→金渐变)—— + static const LinearGradient cloudGradient = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFFF914D), Color(0xFFFFBE58)], + ); + + // —— 暖调阴影基色(关键:阴影不用纯黑)—— + static const Color shadowTint = Color(0xFF5C2E12); +} +``` + +### 3.3 浅色 ColorScheme(Light)—— 完整角色映射 + +```dart +static const ColorScheme _lightScheme = ColorScheme( + brightness: Brightness.light, + + // 主色(行动色) + primary: Color(0xFFC2410C), + onPrimary: Color(0xFFFFFFFF), // 5.18 ✅ + primaryContainer: Color(0xFFFFD3B3), + onPrimaryContainer: Color(0xFF4A1D00), // 10.38 ✅ + + // 辅助色(暖赭,来自 logo #DBBB88 的深调) + secondary: Color(0xFF8A5A3A), + onSecondary: Color(0xFFFFFFFF), // 5.83 ✅ + secondaryContainer: Color(0xFFF5DFCB), + onSecondaryContainer:Color(0xFF5A3A22), // 7.90 ✅ + + // 第三色(琥珀金,来自 logo #FFBE58 的深调) + tertiary: Color(0xFF9A6000), + onTertiary: Color(0xFFFFFFFF), // 5.19 ✅ + tertiaryContainer: Color(0xFFFFE0A3), + onTertiaryContainer: Color(0xFF5C3D00), // 7.75 ✅ + + // 错误 + error: Color(0xFFB02A20), + onError: Color(0xFFFFFFFF), // 6.57 ✅ + errorContainer: Color(0xFFFFDDD8), + onErrorContainer: Color(0xFF7A2A22), // 7.60 ✅ + + // 表面(暖中性,全部 R>G>B) + surface: Color(0xFFFCFAF8), + onSurface: Color(0xFF1A1614), // 17.26 ✅ + onSurfaceVariant: Color(0xFF6E655C), // 5.48 ✅ + surfaceContainerLowest: Color(0xFFFFFFFF), + surfaceContainerLow: Color(0xFFF7F3EF), + surfaceContainer: Color(0xFFF2EDE8), + surfaceContainerHigh: Color(0xFFECE5DF), + surfaceContainerHighest: Color(0xFFE5DED7), + + outline: Color(0xFF8A8178), // 3.67 ✅(非文本元素需 ≥3.0) + outlineVariant: Color(0xFFDCCFC2), // 装饰性分隔线,无强制对比要求 + surfaceTint: Color(0xFFC2410C), // M3 高度染色 → 卡片自带暖调 + shadow: Color(0xFF5C2E12), + scrim: Color(0xFF1A1614), + inverseSurface: Color(0xFF2E2823), + onInverseSurface: Color(0xFFF7EFE9), + inversePrimary: Color(0xFFFFB487), +); +``` + +### 3.4 深色 ColorScheme(Dark)—— 完整角色映射 + +深色模式下 M3 规则是**主色提亮到 tone-80、文字改深色**,`#FFB487` 恰好就是 logo 渐变带的原色,直接复用,品牌一致性最高。 + +```dart +static const ColorScheme _darkScheme = ColorScheme( + brightness: Brightness.dark, + + primary: Color(0xFFFFB487), // ← logo 渐变带原色 + onPrimary: Color(0xFF542000), // 7.66 ✅(白字仅 1.73 ❌,必须用深棕) + primaryContainer: Color(0xFF63300D), + onPrimaryContainer: Color(0xFFFFDCC2), // 8.30 ✅ + + secondary: Color(0xFFE8C39B), + onSecondary: Color(0xFF542000), // 8.03 ✅ + secondaryContainer: Color(0xFF5A3A22), + onSecondaryContainer:Color(0xFFFFDCC2), // 7.89 ✅ + + tertiary: Color(0xFFF2BE62), + onTertiary: Color(0xFF542000), // 7.78 ✅ + tertiaryContainer: Color(0xFF5C3D00), + onTertiaryContainer: Color(0xFFFFDCC2), // 7.66 ✅ + + error: Color(0xFFFF8474), + onError: Color(0xFF5A1610), // 5.65 ✅ + errorContainer: Color(0xFF7A2A22), + onErrorContainer: Color(0xFFFFC9C2), // 6.59 ✅ + + surface: Color(0xFF16130F), // 暖黑,非 #121212 + onSurface: Color(0xFFF3E9E2), // 15.49 ✅ + onSurfaceVariant: Color(0xFFC4B8AE), // 9.54 ✅ + surfaceContainerLowest: Color(0xFF0F0D0A), + surfaceContainerLow: Color(0xFF1E1A16), + surfaceContainer: Color(0xFF23201B), + surfaceContainerHigh: Color(0xFF2D2924), + surfaceContainerHighest: Color(0xFF38332D), + + outline: Color(0xFF7A7068), // 3.83 ✅ + outlineVariant: Color(0xFF453F39), + surfaceTint: Color(0xFFFFB487), + shadow: Color(0xFF000000), // 深色下改用纯黑,暖色阴影不可见 + scrim: Color(0xFF0F0D0A), + inverseSurface: Color(0xFFF3E9E2), + onInverseSurface: Color(0xFF2E2823), + inversePrimary: Color(0xFFC2410C), +); +``` + +### 3.5 语义色(成功 / 警告 / 错误 / 信息) + +**设计决策:警告色不用橙。** 因为品牌主色本身就是橙,若警告也用橙会与"可点击/品牌"语义混淆。警告改用**琥珀黄**(`#8A5C00` / `#F0BE4E`),与橙拉开色相差(约 20°),仍在同一暖色家族内。 + +| 语义 | Light 主色 | Light 容器 / 文字 | Dark 主色 | Dark 容器 / 文字 | +| --- | --- | --- | --- | --- | +| 成功 | `#1E7A4E` (5.11 ✅) | `#D3EDDD` / `#1E5138` (7.39 ✅) | `#5FD398` (9.93 ✅) | `#1E5138` / `#A8E6C4` (6.46 ✅) | +| 警告 | `#8A5C00` (5.58 ✅) | `#FFE9B8` / `#5C4100` (7.96 ✅) | `#F0BE4E` (10.74 ✅) | `#5C4100` / `#FFDCA8` (7.26 ✅) | +| 错误 | `#B02A20` (6.31 ✅) | `#FFDDD8` / `#7A2A22` (7.60 ✅) | `#FF8474` (7.76 ✅) | `#7A2A22` / `#FFC9C2` (6.59 ✅) | +| 信息 | `#0B6397` (6.21 ✅) | `#D7E9FA` / `#1A4763` (7.96 ✅) | `#7FC4F0` (9.75 ✅) | `#1A4763` / `#B8DCF5` (6.87 ✅) | + +> 括号中为对比度实测值(主色 vs surface;容器内文字 vs 容器)。 + +通过 `ThemeExtension` 暴露,避免散落硬编码: + +```dart +class AppSemanticColors extends ThemeExtension { + const AppSemanticColors({ + required this.success, required this.onSuccessContainer, + required this.successContainer, + required this.warning, required this.warningContainer, + required this.onWarningContainer, + required this.info, required this.infoContainer, + required this.onInfoContainer, + }); + final Color success, successContainer, onSuccessContainer; + final Color warning, warningContainer, onWarningContainer; + final Color info, infoContainer, onInfoContainer; + + static const light = AppSemanticColors( + success: Color(0xFF1E7A4E), successContainer: Color(0xFFD3EDDD), + onSuccessContainer: Color(0xFF1E5138), + warning: Color(0xFF8A5C00), warningContainer: Color(0xFFFFE9B8), + onWarningContainer: Color(0xFF5C4100), + info: Color(0xFF0B6397), infoContainer: Color(0xFFD7E9FA), + onInfoContainer: Color(0xFF1A4763), + ); + static const dark = AppSemanticColors( + success: Color(0xFF5FD398), successContainer: Color(0xFF1E5138), + onSuccessContainer: Color(0xFFA8E6C4), + warning: Color(0xFFF0BE4E), warningContainer: Color(0xFF5C4100), + onWarningContainer: Color(0xFFFFDCA8), + info: Color(0xFF7FC4F0), infoContainer: Color(0xFF1A4763), + onInfoContainer: Color(0xFFB8DCF5), + ); + + @override AppSemanticColors copyWith({/* … */}) => this; + @override AppSemanticColors lerp(covariant AppSemanticColors? o, double t) => + o ?? this; +} + +/// 品牌色扩展:把「只能看不能点」的亮橙单独隔离,防止被误用为文字色。 +class AppBrandExtension extends ThemeExtension { + const AppBrandExtension({ + this.cloud = AppBrand.cloud, + this.cloudGold = AppBrand.cloudGold, + this.onCloud = AppBrand.onCloud, + this.gradient = AppBrand.cloudGradient, + }); + final Color cloud, cloudGold, onCloud; + final LinearGradient gradient; + + @override ThemeExtension copyWith() => this; + @override ThemeExtension lerp( + covariant ThemeExtension? other, double t) => this; +} + +/// 使用方式: +/// final brand = Theme.of(context).extension()!; +/// final semantic = Theme.of(context).extension()!; +``` + +### 3.6 暖中性色阶(Neutral Ramp) + +全部带暖偏移(R>G>B),与 logo 的 `#DBBB88` 同源。实测亮度严格单调: + +| Token | Light | 相对亮度 | 用途 | +| --- | --- | --- | --- | +| n0 | `#FFFFFF` | 1.000 | 弹窗/输入填充 | +| n10 | `#FCFAF8` | 0.958 | **surface** | +| n20 | `#F7F3EF` | 0.901 | surfaceContainerLow | +| n30 | `#F2EDE8` | 0.853 | surfaceContainer | +| n40 | `#ECE5DF` | 0.792 | surfaceContainerHigh | +| n50 | `#E5DED7` | 0.738 | surfaceContainerHighest | +| n60 | `#DCCFC2` | 0.637 | outlineVariant / 分隔线 | +| n70 | `#C8BFB4` | 0.528 | 禁用态填充 | +| n80 | `#8A8178` | 0.225 | **outline** | +| n90 | `#6E655C` | 0.134 | **onSurfaceVariant** | +| n100 | `#1A1614` | 0.008 | **onSurface** | +| n110 | `#0F0D0A` | 0.004 | surfaceContainerLowest(dark) | + +| Token | Dark | 相对亮度 | 用途 | +| --- | --- | --- | --- | +| d10 | `#F3E9E2` | 0.828 | **onSurface** | +| d20 | `#C4B8AE` | 0.491 | **onSurfaceVariant** | +| d30 | `#7A7068` | 0.167 | **outline** | +| d40 | `#453F39` | 0.051 | outlineVariant | +| d50 | `#38332D` | 0.034 | surfaceContainerHighest | +| d60 | `#2D2924` | 0.023 | surfaceContainerHigh | +| d70 | `#23201B` | 0.015 | surfaceContainer | +| d80 | `#1E1A16` | 0.011 | surfaceContainerLow | +| d90 | `#16130F` | 0.007 | **surface** | +| d100 | `#0F0D0A` | 0.004 | surfaceContainerLowest | + +### 3.7 如何替换 `#2E7DFF`,及对可读性的影响 + +**替换路径(3 步)** + +1. `lib/app/theme.dart:8` `primaryBlue` → 改名 `brandPrimary`,值 `#2E7DFF` → `#C2410C`; + 同时**废弃 `ColorScheme.fromSeed`** 改为**显式 `ColorScheme(...)` 全量声明**。 + > 原因:`fromSeed` 会按 HCT 算法生成容器色与辅助色,得到的是**带紫/蓝偏移**的派生色(蓝色种子尤其明显),无法精确控制品牌色;显式声明才能保证 `#FFD3B3` 这类 logo 同源的容器色落地。 +2. 全局 `primaryBlue` 引用点逐一替换(清单见 §8.1); +3. 亮橙 `#FF914D` 通过 `ThemeExtension` 暴露,**不进入 ColorScheme**,避免被组件误用为文字色。 + +**替换后对可读性的影响(实测,非估算)** + +| 场景 | 旧(蓝 `#2E7DFF`) | 新(深橙 `#C2410C`) | 影响 | +| --- | --- | --- | --- | +| 主按钮白字 | 3.68(**未达 AA**) | **5.18 ✅** | **可读性提升** | +| 主色作文字/图标 | 3.68 | **4.97 ✅** | **可读性提升** | +| 选中态图标 on 容器 | — | **7.90 ✅** | 达标 | +| 亮橙作图标(禁止) | — | 1.66 ❌ | **新增约束,必须遵守** | + +> 值得强调:换成深橙后,主按钮的对比度**反而从 3.68 提升到 5.18**,旧的科技蓝 `#2E7DFF` 配白字其实一直**未达 AA**——这是一次换色顺带修复的无障碍问题。 + +### 3.8 对比度实测总表(全部通过) + +``` +[LIGHT] + ✅ 5.18 #FFFFFF / #C2410C onPrimary / primary + ✅ 10.38 #4A1D00 / #FFD3B3 onPrimaryContainer / primaryContainer + ✅ 5.83 #FFFFFF / #8A5A3A onSecondary / secondary + ✅ 7.90 #5A3A22 / #F5DFCB onSecondaryContainer / secondaryContainer + ✅ 5.19 #FFFFFF / #9A6000 onTertiary / tertiary + ✅ 7.75 #5C3D00 / #FFE0A3 onTertiaryContainer / tertiaryContainer + ✅ 6.57 #FFFFFF / #B02A20 onError / error + ✅ 7.60 #7A2A22 / #FFDDD8 onErrorContainer / errorContainer + ✅ 17.26 #1A1614 / #FCFAF8 onSurface / surface + ✅ 5.48 #6E655C / #FCFAF8 onSurfaceVariant / surface + ✅ 3.67 #8A8178 / #FCFAF8 outline / surface (需 ≥3.0) + ✅ 4.97 #C2410C / #FCFAF8 primary 作文本/图标 + ✅ 6.99 #3D1A00 / #FF914D 深棕字 on 品牌橙(Banner/大色块) + ✅ 7.90 #5A3A22 / #F5DFCB 底部导航选中图标 on indicator + +[DARK] + ✅ 7.66 #542000 / #FFB487 onPrimary / primary + ✅ 8.30 #FFDCC2 / #63300D onPrimaryContainer / primaryContainer + ✅ 8.03 #542000 / #E8C39B onSecondary / secondary + ✅ 7.89 #FFDCC2 / #5A3A22 onSecondaryContainer / secondaryContainer + ✅ 7.78 #542000 / #F2BE62 onTertiary / tertiary + ✅ 7.66 #FFDCC2 / #5C3D00 onTertiaryContainer / tertiaryContainer + ✅ 5.65 #5A1610 / #FF8474 onError / error + ✅ 6.59 #FFC9C2 / #7A2A22 onErrorContainer / errorContainer + ✅ 15.49 #F3E9E2 / #16130F onSurface / surface + ✅ 9.54 #C4B8AE / #16130F onSurfaceVariant / surface + ✅ 3.83 #7A7068 / #16130F outline / surface (需 ≥3.0) + ✅ 10.70 #FFB487 / #16130F primary 作文本/图标 + ✅ 1.73 #63300D / #16130F primaryContainer 与 surface 可区分(需 ≥1.3) +``` + +--- + +## 4. 圆角与阴影(呼应"云"意象) + +### 4.1 与 logo 小圆角(3%)的取舍:为什么不全盘照搬 + +logo 圆角 6px / 192px = 3%。若**等比**映射到 UI 控件: + +| 控件 | 尺寸 | 3% 等比结果 | 评价 | +| --- | --- | --- | --- | +| 主按钮 | 320×48 | 1.4dp | ❌ 生硬,几乎直角 | +| 标准卡片 | 360×140 | 4.2dp | ❌ 尖锐,与"云"完全无关 | +| 底部弹窗 | 360×600 | 10.8dp | ⚠️ 偏小 | + +**结论:3% 不能直接等比继承。** 从 logo 中真正可继承的是三条**定性**特征: + +1. **非胶囊** —— logo 是圆角矩形,不是圆形/胶囊,所以 UI 主体**不采用 999 全圆**; +2. **四角一致** —— 不做"左上大、右下小"这类不规则圆角(弹窗除外,见 4.2); +3. **克制感** —— 圆角明显但不夸张,落在移动端舒适区间的**中下段**。 + +定量上把落点定为**"容器短边的 5%~10%"**,正好落在 12~28dp 区间。这样既保住 logo 的克制基因,又满足移动端拇指触控的视觉引导需求。 + +### 4.2 圆角梯度(`AppRadius`) + +```dart +class AppRadius { + static const double xs = 4; // 进度条、内嵌小色块、图片缩略图角 + static const double sm = 8; // Chip、SnackBar、Tooltip、小标签 + static const double md = 12; // 输入框、表单控件(沿用现值,保持手感连续) + static const double lg = 16; // ★ 标准卡片、按钮(FilledButton/OutlinedButton) + static const double xl = 20; // 大卡片、列表分组容器、AppBar 底部两角 + static const double xxl = 28; // ★ 底部弹窗顶部两角(云朵弧线的核心表达) + static const double full = 999; // 仅用于状态 Chip / 头像 / pill 标签 + + /// 云朵轮廓:上缘多圆弧、下缘平缓。 + static const BorderRadius sheetTop = BorderRadius.vertical( + top: Radius.circular(xxl), // 28 + ); + /// 卡片式弹窗:上 28 / 下 12,形成"云朵被托起"的轮廓。 + static const BorderRadius cloudCard = BorderRadius.only( + topLeft: Radius.circular(xxl), // 28 + topRight: Radius.circular(xxl), // 28 + bottomLeft: Radius.circular(md), // 12 + bottomRight: Radius.circular(md), // 12 + ); +} +``` + +**适用场景速查** + +| 圆角 | 组件 | +| --- | --- | +| `xs` 4 | `LinearProgressIndicator`、`ClipRRect` 缩略图、内嵌色块 | +| `sm` 8 | `Chip`、`SnackBar`、`ActionChip`、小徽标 | +| `md` 12 | `InputDecoration`(现值保留)、`InputDecorator`、`TextFormField` | +| `lg` 16 | `Card`(列表项、统计卡、指标卡)、`FilledButton`、`OutlinedButton`、`TextButton` | +| `xl` 20 | 分组容器、`ExpansionTile`、大尺寸 Banner 卡 | +| `xxl` 28 | **底部弹窗顶部两角**、全屏 `Dialog` | +| `full` 999 | `StatusChip`、`CircleAvatar`、状态 pill | + +> **关键改动**:M3 的 `FilledButton` 默认是全圆角(20dp,实际渲染接近胶囊)。必须显式覆盖为 `lg`(16),否则按钮与卡片的圆角会脱节。 + +### 4.3 云感阴影(替代 `elevation: 1`) + +**现状问题**(`lib/app/theme.dart:41`):`CardTheme(elevation: 1)`。 +M3 中 elevation 1 只对应 5% 的 surfaceTint 染色 + 一层极淡引擎阴影,**几乎看不出层次**;且 Flutter 的 `Material` elevation 阴影由引擎按固定曲线绘制,**颜色可改(`shadowColor`)、模糊/偏移曲线不可控**。 + +**方案:A/B 双轨** + +#### 方案 A(全局生效,低风险)—— 改 `ThemeData` / `CardThemeData` + +```dart +// 暖调阴影色替代默认纯黑 +ThemeData( + shadowColor: AppBrand.shadowTint, // #5C2E12 +) +CardThemeData( + elevation: 0, // 交给自定义 BoxShadow + surfaceTintColor: scheme.surfaceTint, // 保留暖调染色 + shadowColor: AppBrand.shadowTint, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.lg)), +) +``` + +#### 方案 B(推荐用于核心卡片/弹窗)—— `CloudCard` 自定义组件 + +新建 `lib/widgets/cloud_card.dart`,用 `BoxShadow` 精确控制,**这是"云朵漂浮"的真正实现**: + +```dart +class AppShadows { + static const Color _tint = Color(0xFF5C2E12); // 暖棕,非纯黑 + + /// 极轻:列表项默认态(替代 elevation:1) + static const List xs = [ + BoxShadow(color: Color(0x0F5C2E12), blurRadius: 8, offset: Offset(0, 2), spreadRadius: -1), // α=6% + ]; + /// 轻:卡片 hover/按压 + static const List sm = [ + BoxShadow(color: Color(0x145C2E12), blurRadius: 12, offset: Offset(0, 4), spreadRadius: -2), // α=8% + ]; + /// 中:★ 标准卡片、指标卡 + static const List md = [ + BoxShadow(color: Color(0x1A5C2E12), blurRadius: 20, offset: Offset(0, 8), spreadRadius: -4), // α=10% + ]; + /// 强:悬浮层、下拉菜单、SnackBar + static const List lg = [ + BoxShadow(color: Color(0x245C2E12), blurRadius: 32, offset: Offset(0, 12), spreadRadius: -6), // α=14% + ]; + /// 最强:★ 底部弹窗、FAB、Dialog + static const List xl = [ + BoxShadow(color: Color(0x245C2E12), blurRadius: 48, offset: Offset(0, 20), spreadRadius: -8), // α=14% + ]; + + /// 深色模式:暖色阴影在暗底上不可见(实测 6% 暖棕在 #16130F 上对比仅 1.05), + /// 改用纯黑 + 1px 暖色描边来表达边界。 + static const List dark = [ + BoxShadow(color: Color(0x66000000), blurRadius: 24, offset: Offset(0, 8), spreadRadius: -4), + ]; +} + +class CloudCard extends StatelessWidget { + const CloudCard({ + super.key, + required this.child, + this.padding = const EdgeInsets.all(16), + this.margin, + this.level = CloudShadowLevel.md, + this.onTap, + }); + final Widget child; + final EdgeInsetsGeometry padding; + final EdgeInsetsGeometry? margin; + final CloudShadowLevel level; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final isDark = scheme.brightness == Brightness.dark; + return Container( + margin: margin, + decoration: BoxDecoration( + color: scheme.surface, + borderRadius: BorderRadius.circular(AppRadius.lg), + // 深色下加暖色描边替代阴影 + border: isDark ? Border.all(color: scheme.outlineVariant, width: 1) : null, + boxShadow: isDark ? AppShadows.dark : level.shadows, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(AppRadius.lg), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppRadius.lg), + child: Padding(padding: padding, child: child), + ), + ), + ), + ); + } +} +``` + +**参数设计依据** + +| 参数 | 取值逻辑 | +| --- | --- | +| `blurRadius` 8→48 | 远大于 `offset`(2→20),**模糊 : 偏移 ≈ 4:1** → 光是"漫射"的,像云影而非投影;M2/M3 默认约 2:1,偏硬 | +| `offset` 只有 Y 轴 | 光源正上方,符合"云在上方漂浮" | +| `spreadRadius` 负值 | 收缩阴影,避免卡片四周出现脏边 | +| `α` 6%~14% | 实测:α=6% 在 `#FCFAF8` 上混色 `#F2EEEA`(对比 1.11,可辨但不刺眼);α=14% 混色 `#E6DDD8`(对比 1.29,用于弹窗层级) | +| 颜色 `#5C2E12` 而非黑 | **这是"暖"的关键**:暖棕阴影在暖白底上是"暖灰",纯黑阴影会发灰发脏 | +| 深色模式换纯黑 + 描边 | 实测暖棕 α=6% 在 `#16130F` 上对比仅 1.05,**完全不可见**;必须换纯黑 α=40%(对比 1.06)+ `outlineVariant` 描边 | + +**M3 elevation 染色(surfaceTint)配合** + +M3 的 elevation→染色透明度曲线(Flutter 源码 `elevation_overlay.dart` 实测):`elevation 1 → 5%`、`3 → 8%`、`6 → 11%`、`12 → 12%`、`16 → 14%`。 + +建议:**保留 `surfaceTintColor = primary`**,让卡片在低 elevation 下获得 5%~8% 的暖橙染色 —— 这是一条**零成本把品牌色渗进全站卡片**的路径,且极其克制(5% 几乎只是"一点点暖意")。若嫌脏,设为 `Colors.transparent` 即可。 + +--- + +## 5. 图标风格 + +### 5.1 现状问题 + +- `lib/app/theme.dart` **未定义 `iconTheme`**,图标尺寸/颜色随组件默认值漂移; +- 全项目混用填充态与线性态:`Icons.devices`(填充)、`Icons.dns_outlined`(线性)、`Icons.dns`(填充)共存; +- 尺寸无栅格:14、16、20、28、32、40、48、56 随意出现(实测 `store_list_page.dart:365` 用 14、`device_list_tile.dart:71` 用 32、`home_page.dart:161` 用 28); +- 无统一的端点/描边规范。 + +### 5.2 统一图标语言 + +| 维度 | 规范 | 说明 | +| --- | --- | --- | +| **风格** | **Outlined(线性)为默认**,填充态仅用于"选中" | 线性更轻,呼应"云"的轻盈;底部导航现状已符合(`home_outlined` / `home`),予以保留并推广 | +| **描边粗细** | 1.8 ~ 2.0 dp(在 24dp 栅格下) | Material Symbols Outlined 默认 ≈ 2.0 | +| **端点/连接** | **圆角端点(round cap)+ 圆角连接(round join)** | **这是呼应云朵弧线最直接的手法**;比直角端点更柔和 | +| **栅格** | 24dp 网格 + 2dp 内边距(有效绘制区 20dp) | 与 Material 规范一致 | +| **光学尺寸** | `opticalSize = 20`(小尺寸场景) | 让小图标自动加粗描边,保持视觉重量一致 | +| **禁用** | 直角矩形类图标(`Icons.square`、`Icons.stop`、`Icons.check_box` 填充态) | 与圆润弧线冲突 | + +### 5.3 尺寸栅格(`AppIconSize`) + +```dart +class AppIconSize { + static const double inline = 16; // 正文行内图标 + static const double dense = 20; // 密集信息行(门店信息行 _infoLine) + static const double regular = 24; // ★ 默认:列表 leading、按钮内、AppBar actions + static const double medium = 28; // 统计卡强调图标 + static const double large = 32; // 列表主 leading(设备列表) + static const double display = 48; // 空状态 / 结果页 + static const double hero = 64; // 关于页 logo、引导页 +} +``` + +### 5.4 来源与落地(分两步,P0 零依赖) + +**P0(零依赖,立即可做)**:Flutter 自带的 `MaterialIcons` 字体中,`Icons.xxx_outlined` 变体本身就是线性风格,**无需新增任何依赖**。 + +```dart +// 全局强制线性 + 统一尺寸与颜色 +ThemeData( + iconTheme: IconThemeData( + size: AppIconSize.regular, // 24 + color: scheme.onSurfaceVariant, + // ↓ 可变字体轴:仅对支持 Variable Font 的字体生效 + fill: 0.0, // 0 = 线性,1 = 填充 + weight: 400, + grade: 0, + opticalSize: 24, + ), + primaryIconTheme: IconThemeData(size: 24, color: scheme.primary), +) +``` + +> **⚠️ 重要说明**:`IconThemeData.fill / weight / grade / opticalSize` 需要图标字体支持 OpenType 可变轴。Flutter 内置的 **`MaterialIcons` 不支持**,因此 `fill: 0` 目前是 **no-op**。 +> 真正强制线性风格,P0 靠**统一改用 `Icons.xxx_outlined` 变体**(见 §7 清单);P2 再考虑引入 Material Symbols 可变字体让这四个轴生效。 + +**P2(可选增强)**:引入 `Material Symbols Outlined` 可变字体(`google_fonts` 或自托管 woff2,约 300KB),在 `pubspec.yaml` 声明为 `fontFamily`,之后 `fill: 0` 等轴才能真正全局生效,并可做"线性→填充"的平滑过渡动画。 + +### 5.5 语义色图标映射(替换散落的 `Colors.green/orange/red`) + +| 语义 | 图标 | 颜色来源 | +| --- | --- | --- | +| 设备在线 | `Icons.cloud_done_outlined`(**呼应云**) | `AppSemanticColors.success` | +| 设备离线 | `Icons.cloud_off_outlined` | `onSurfaceVariant` | +| 设备告警 | `Icons.warning_amber_outlined` | `AppSemanticColors.warning` | +| 门店正常 | `Icons.storefront_outlined` | success | +| 门店停用 | `Icons.block_outlined` | `onSurfaceVariant` | +| 删除 | `Icons.delete_outline` | `error` | +| 编辑 | `Icons.edit_outlined` | `primary` | +| 发送指令 | `Icons.send_outlined` | `primary` | + +> 把 `Icons.dns` / `Icons.dns_outlined`(机柜,与"云"无关)换成 `Icons.cloud_*` 系列 —— 这是**最低成本、最高回报**的图标层品牌化动作。 + +--- + +## 6. 移动端布局与触控适配 + +### 6.1 触控目标规范 + +| 项 | 规范 | 依据 | +| --- | --- | --- | +| **最小触控区** | **48×48 dp** | Material 3 / Android 无障碍建议(Apple 为 44) | +| **相邻可点元素间距** | **≥ 8 dp** | 防误触 | +| **主操作按钮高度** | **≥ 52 dp** | 单手拇指舒适区 | +| **输入框高度** | **≥ 56 dp**(含 label 则为 60) | 保证点击 + 文字可读 | +| **列表项高度** | 单行 **≥ 56 dp**,多行 **≥ 72 dp** | `ListTile` 默认 56,符合 | + +**落地方式(全局一次性生效)** + +```dart +ThemeData( + // 让 IconButton 的点击区撑到 48(其默认 minimumSize 仅 40×40) + iconButtonTheme: IconButtonThemeData( + style: IconButton.styleFrom( + minimumSize: const Size(48, 48), + tapTargetSize: MaterialTapTargetSize.padded, + padding: EdgeInsets.zero, + ), + ), + materialTapTargetSize: MaterialTapTargetSize.padded, + // 手小/戴手套场景:把默认 compact 改为 standard,控件间距 +4dp + visualDensity: VisualDensity.standard, + // 按钮统一高度与圆角 + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + minimumSize: const Size(64, 52), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.lg)), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + minimumSize: const Size(64, 52), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.lg)), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + minimumSize: const Size(64, 48), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.lg)), + ), + ), +) +``` + +**重点修复**:`lib/widgets/device_list_tile.dart:81` 的 `IconButton` 与 `StatusChip` 挤在同一 `trailing` 的 `Row` 内,两者间距为 0,且 `StatusChip` 高约 24dp —— **这是全项目误触风险最高的一处**。建议: +- `StatusChip` 外层包 `Padding(padding: EdgeInsets.only(right: 8))`; +- 或把"发送指令"下沉到左滑(Slidable)/ 长按菜单,释放 `trailing` 空间。 + +### 6.2 单手操作友好 + +| 策略 | 具体做法 | +| --- | --- | +| **关键操作下沉** | 列表页 FAB 保留,但改为 `FloatingActionButton.extended` 置于 `endFloat`,并包 `SafeArea`;破坏性操作(删除)始终放在**底部弹窗的吸底栏**,不放 AppBar | +| **主操作吸底** | 长表单的"保存/创建"改为**吸底按钮**(见 6.4),不再埋在滚动区末尾 | +| **AppBar 精简** | `actions` 最多 2 个图标;超过 2 个收进 `PopupMenuButton`(右上角是单手最难触及区) | +| **底部导航** | 5 个 tab 保持(现状 `AppBottomNav` 已符合 M3),但**横屏切换为 `NavigationRail`**(见 6.6) | +| **删除二次确认** | 保留现状 `AlertDialog`,但主按钮改 `FilledButton(error)`,`isDefaultAction` 给"取消" | + +### 6.3 列表 / 卡片 / 表单在窄屏下的排版规则 + +**竖屏(< 600dp,手机主力场景)** + +| 元素 | 规则 | +| --- | --- | +| 列表项 | 卡片式,`margin: EdgeInsets.symmetric(horizontal: 12, vertical: 6)`,圆角 `lg`(16),阴影 `xs` | +| 卡片内边距 | `16`(现状 `store_list_page` 为 20,横屏才用 20) | +| 标题 | `titleSmall` + `w600`,**最多 1 行** `TextOverflow.ellipsis` | +| 副信息行 | 图标 `dense`(20) + 文字 `bodySmall`(12/13),**最多 4 行**,超出折叠 | +| 状态 Chip | 右上角,`radius.full`,与标题同行;**不与图标按钮相邻**(见 6.1) | +| 统计卡 Grid | `crossAxisCount: 2`,`childAspectRatio: 1.3`(现状 1.4 在竖屏偏扁) | +| 表单 | **单列**,`labelText` 常驻显示(不用 `hintText` 占位),字段间距 `12` | + +**横屏(1600×900,实测主力场景)—— 见 6.6** + +### 6.4 输入框与底部弹窗适配(项目大量使用 bottom sheet) + +#### 输入框 + +```dart +inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: scheme.surfaceContainerHighest, // 替换 surfaceContainerHighest(蓝灰) → 暖灰 + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18), // 12 → 18,高度 ≥56 + prefixIconConstraints: const BoxConstraints(minWidth: 48, minHeight: 48), + suffixIconConstraints: const BoxConstraints(minWidth: 48, minHeight: 48), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.md), // 12 + borderSide: BorderSide(color: scheme.outlineVariant), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + borderSide: BorderSide(color: scheme.outline), // 常驻描边,弱视力用户可辨 + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + borderSide: BorderSide(color: scheme.primary, width: 2),// 聚焦加粗,不再依赖颜色单独表意 + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + borderSide: BorderSide(color: scheme.error), + ), + floatingLabelStyle: MaterialStateTextStyle.resolveWith((s) => + TextStyle(color: s.contains(MaterialState.error) + ? scheme.error + : s.contains(MaterialState.focused) ? scheme.primary : scheme.onSurfaceVariant)), + labelStyle: TextStyle(color: scheme.onSurfaceVariant), + hintStyle: TextStyle(color: scheme.onSurfaceVariant), + errorStyle: TextStyle(color: scheme.error, fontSize: 12), + errorMaxLines: 2, +) +``` + +其他要点: +- 每个 `TextFormField` 显式声明 `textInputAction`(`next` / `done`)并用 `onFieldSubmitted` 串联焦点 —— 现状门店表单 7 个字段均未设置,键盘上全是"换行"; +- 数字字段用 `keyboardType: TextInputType.number` + `inputFormatters: [FilteringTextInputFormatter.digitsOnly]`(现状门店终端数字段已设 keyboardType,但缺格式化器); +- 手机号字段加 `maxLength: 11`。 + +#### 底部弹窗(**本项目最高优先级的结构性问题**) + +现状(`lib/screens/store/store_list_page.dart:140-154` + `1237-1421`): + +```dart +showModalBottomSheet( + context: context, + isScrollControlled: true, // ← 全屏高度 + builder: (ctx) => _StoreFormSheet(...), // ← 内部 SingleChildScrollView 无高度上限 +); +// 表单内部:保存按钮在 SingleChildScrollView 的 children 末尾(第 1404 行) +``` + +**四个问题**: +1. `isScrollControlled: true` 但**未限制最大高度** → 内容多时弹窗顶到状态栏,用户失去"这是弹窗"的感知,且键盘弹起后内容被挤没; +2. **`useSafeArea` 默认 `false`**(Flutter 源码 `bottom_sheet.dart:874`)→ 全项目 **30 处** `showModalBottomSheet` 全部存在内容被系统导航栏/手势条遮挡的风险; +3. **无 `showDragHandle`** → 没有可拖拽的视觉暗示,用户不知道能下滑关闭; +4. **保存按钮埋在滚动区末尾** → 门店新增表单有 7 个字段 + 主题模板网格 + 激活码区块,实际高度远超一屏,**必须滚到底才能点"创建"**。 + +**改造方案** + +```dart +// ① 全局兜底:在 ThemeData 里一次性解决形状、宽度、把手、安全区 +bottomSheetTheme: BottomSheetThemeData( + showDragHandle: true, + useSafeArea: true, // ★ 全局开启,30 处弹窗一次性受益 + backgroundColor: scheme.surface, + surfaceTintColor: scheme.surfaceTint, + elevation: 0, // 交给自定义阴影 + modalBackgroundColor: scheme.surface, + clipBehavior: Clip.antiAlias, + shape: const RoundedRectangleBorder( + borderRadius: AppRadius.sheetTop, // 顶部两角 28(云朵弧线) + ), + // ★ 横屏不再铺满:最大宽度 640 并居中 + constraints: const BoxConstraints(maxWidth: 640), +), + +// ② 页面级:限制高度 + 吸底操作栏 +Future showAppBottomSheet({ + required BuildContext context, + required WidgetBuilder bodyBuilder, + required Widget footer, // 吸底操作栏 + String? title, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + useSafeArea: true, + constraints: BoxConstraints( + maxWidth: 640, + maxHeight: MediaQuery.sizeOf(context).height * 0.92, + ), + builder: (ctx) => _AppSheetScaffold( + title: title, + body: bodyBuilder(ctx), + footer: footer, + ), + ); +} + +class _AppSheetScaffold extends StatelessWidget { + const _AppSheetScaffold({this.title, required this.body, required this.footer}); + final String? title; + final Widget body; + final Widget footer; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (title != null) + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), + child: Align( + alignment: Alignment.centerLeft, + child: Text(title!, + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.bold)), + ), + ), + // 可滚动内容区 + Flexible( + child: SingleChildScrollView( + padding: EdgeInsets.only( + left: 20, right: 20, bottom: 20, + // 键盘弹起时抬起内容 + top: 0, + ), + child: body, + ), + ), + // ★ 吸底操作栏:始终可见,不受滚动影响 + Container( + decoration: BoxDecoration( + color: scheme.surface, + border: Border(top: BorderSide(color: scheme.outlineVariant)), + boxShadow: AppShadows.lg, + ), + padding: EdgeInsets.fromLTRB( + 20, 12, 20, 12 + MediaQuery.viewInsetsOf(context).bottom * 0, + ), + child: SafeArea(top: false, child: footer), + ), + ], + ); + } +} +``` + +> 键盘适配:`SingleChildScrollView(padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom))` 仍需在 `body` 内保留,吸底栏用 `SafeArea(top: false)` 处理底部手势条。 + +### 6.5 横屏 1600×900 与竖屏适配策略(**重点**) + +#### 现状诊断(源码实证) + +| 检查项 | 结果 | +| --- | --- | +| `OrientationBuilder` | **0 处** | +| 响应式断点 / `LayoutBuilder`(业务布局用) | **0 处**(仅 `marquee_management_page.dart:1301/1344` 的两处与布局无关) | +| `GridView` 写死 `crossAxisCount: 2` | `home_page.dart:67`、`device_detail_page.dart:312` | +| 内容最大宽度约束 | **仅登录页有**(`login_page.dart:158` `maxWidth: 420`),其余页面全无 | +| 底部导航 | 恒为 `NavigationBar`(高度 80),横屏下吃掉 8.9% 的垂直空间 | + +**后果**:1600px 宽下,首页统计卡每张被拉到约 780px 宽、`GridView` 仍是 2 列 → 单屏只有 4 张巨卡;列表项文字行长超过 200 字符,眼动距离极长;底部弹窗铺满 1600px 宽,表单输入框宽到失真。而 900px 高度下 `NavigationBar`(80) + `AppBar`(56) 已占去 15%。 + +#### 适配方案 + +**① 建立断点工具 `lib/utils/responsive.dart`** + +```dart +enum AppWindowSize { compact, medium, expanded } + +class AppResponsive { + AppResponsive(this.size, this.orientation); + final Size size; + final Orientation orientation; + + factory AppResponsive.of(BuildContext context) => AppResponsive( + MediaQuery.sizeOf(context), + MediaQuery.orientationOf(context), + ); + + /// ★ 用 shortestSide 而非 width:横屏 1600×900 的 shortSide = 900 + double get shortSide => size.shortestSide; + bool get isLandscape => orientation == Orientation.landscape; + + AppWindowSize get windowSize { + final s = shortSide; + if (s < 600) return AppWindowSize.compact; // 手机竖屏 360-430 + if (s < 840) return AppWindowSize.medium; // ★ 手机横屏 900 落这里 + return AppWindowSize.expanded; // 平板 / 大屏 + } + + /// 列表页是否启用「左列表 + 右详情」双栏 + bool get useMasterDetail => size.width >= 840 && isLandscape; + + /// Grid 列数:按可用宽度自适应(1600px → 4 列;竖屏 400px → 2 列) + int gridCount({double minTileWidth = 320}) => + (size.width / minTileWidth).floor().clamp(2, 6); + + /// 内容最大宽度(表单/详情居中,避免行长失控) + double get contentMaxWidth => switch (windowSize) { + AppWindowSize.compact => double.infinity, + AppWindowSize.medium => 560, + AppWindowSize.expanded => 720, + }; + + /// 页面水平外边距 + double get gutter => switch (windowSize) { + AppWindowSize.compact => 12, + AppWindowSize.medium => 24, + AppWindowSize.expanded => 32, + }; + + /// 卡片内边距 + double get cardPadding => switch (windowSize) { + AppWindowSize.compact => 16, + _ => 20, + }; +} + +extension AppResponsiveX on BuildContext { + AppResponsive get responsive => AppResponsive.of(this); +} +``` + +**② 底部导航:横屏切换为 `NavigationRail`** + +```dart +// lib/widgets/app_bottom_nav.dart +@override +Widget build(BuildContext context) { + final r = context.responsive; + if (r.isLandscape && r.size.width >= 840) { + return Scaffold( + body: Row( + children: [ + NavigationRail( + selectedIndex: shell.currentIndex, + onDestinationSelected: (i) => + shell.goBranch(i, initialLocation: i == shell.currentIndex), + labelType: NavigationRailLabelType.all, + // 品牌色指示:indicatorColor 走 secondaryContainer + destinations: _destinations(context), + ), + const VerticalDivider(width: 1), + Expanded(child: ClipRect(child: shell)), + ], + ), + ); + } + return Scaffold( + body: shell, + bottomNavigationBar: NavigationBar( + selectedIndex: shell.currentIndex, + onDestinationSelected: (i) => + shell.goBranch(i, initialLocation: i == shell.currentIndex), + destinations: _destinations(context), + ), + ); +} +``` + +> 收益:横屏下不再有 80dp 的底部导航条,垂直空间 +80dp(900 高度的 8.9%),且 5 个 tab 移到左侧后**拇指横向可达,比底部更易点**。 + +**③ 列表页双栏主从布局(门店 / 设备)** + +横屏 ≥840px 时,`StoreListPage` / `DeviceListPage` 改为: + +``` +┌──────────────┬───────────────────────────────┐ +│ 搜索框 │ 门店详情(原 bottom sheet) │ +│ 门店列表 340dp│ ← 点击列表项直接在右侧展开 │ +│ │ │ +│ [FAB 新增] │ [编辑] [指令] [删除] │ +└──────────────┴───────────────────────────────┘ +``` + +- 左栏固定 `width: 340`,右栏 `Expanded`; +- 点击列表项**不再弹 bottom sheet**,直接更新右栏内容(大幅减少横屏下的弹窗使用); +- 竖屏保持现状:点击 → 底部弹窗。 + +**④ GridView 列数自适应** + +```dart +// home_page.dart:66 与 device_detail_page.dart:311 +GridView.count( + crossAxisCount: context.responsive.gridCount(minTileWidth: 300), // 竖屏2 / 横屏4 + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: context.responsive.isLandscape ? 1.8 : 1.3, + children: [...], +) +``` + +**⑤ 内容宽度约束** + +表单、详情页正文统一包一层: + +```dart +Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: context.responsive.contentMaxWidth), + child: content, + ), +) +``` + +**⑥ 底部弹窗横屏改版** + +`BottomSheetThemeData.constraints: BoxConstraints(maxWidth: 640)` 已在主题层全局生效(见 6.4),横屏下弹窗自动**居中、宽 640**、两侧留白,表单不再横向失真。 +此外横屏下把 `maxHeight` 收紧到 `0.85`(横屏 900 高度下 92% = 828 太高)。 + +**⑦ AppBar 横屏压缩** + +```dart +appBarTheme: AppBarTheme( + centerTitle: true, + backgroundColor: scheme.surface, + foregroundColor: scheme.onSurface, + surfaceTintColor: scheme.surfaceTint, + elevation: 0, + scrolledUnderElevation: 3, // ★ 新增:滚动后浮起,解决"AppBar 与内容无分隔" + toolbarHeight: 52, // 横屏时收紧(用 responsive 覆盖为 48) + titleSpacing: 16, + actionsIconTheme: IconThemeData(size: 24, color: scheme.onSurfaceVariant), +), +``` + +**⑧ 横屏专属检查清单** + +| 页面 | 横屏处理 | +| --- | --- | +| `login_page.dart` | 已有 `maxWidth: 420` ✅;补充:改为**左右分栏**(左品牌渐变区 40% + 右表单 60%),充分利用 1600px | +| `home_page.dart` | Grid 4 列;快捷入口 3 个 → 6 个(横排) | +| `device_list_page.dart` | 双栏主从 | +| `store_list_page.dart` | 双栏主从;FAB 移入左栏右下 | +| `device_detail_page.dart` | 指标 Grid 4 列;基本信息改为两列 `Wrap` | +| `device_scan_page.dart` | 扫码取景框需按 `shortSide` 而非 `width` 计算尺寸,否则横屏下被压扁 | +| `store_config_page.dart` / `branding_editor.dart` | 预览区左、编辑区右(分栏),现状是上下堆叠,横屏需滚很远 | + +--- + +## 7. 落地清单 + +### 7.1 P0 —— 品牌一致性 + 阻塞性问题(建议 1 个迭代内完成) + +| # | 文件 | 改动点 | +| --- | --- | --- | +| P0-1 | `lib/app/theme.dart` | 全量重写:`primaryBlue` → `brandPrimary = #C2410C`;`ColorScheme.fromSeed` → 显式 `ColorScheme(...)`(浅/深两套);新增 `AppRadius`、`AppShadows`、`AppIconSize`、`AppBrand`、`AppSemanticColors` | +| P0-2 | `lib/app/theme.dart` | 新增 `iconTheme` / `primaryIconTheme`;`cardTheme` elevation 1→0 + `CloudCard` 阴影;`bottomSheetTheme`(`showDragHandle: true` + `useSafeArea: true` + `maxWidth: 640` + 顶部 28 圆角);`appBarTheme` 补 `scrolledUnderElevation: 3` / `surfaceTintColor`;`visualDensity` / `iconButtonTheme` / 三个 ButtonTheme(48/52 触控 + 16 圆角);`extensions: [AppBrand.light, AppSemanticColors.light]` | +| P0-3 | `lib/screens/login/login_page.dart` | **整页硬编码深色改为跟随主题**:L152 `backgroundColor: #121212` → `scheme.surface`;L149 `cardColor: #0D2137`(**偏蓝**)→ `scheme.surfaceContainerLow`;L145-148 四个输入色 → 主题 token;L205/233/280 `#2E7DFF` → `scheme.primary`;L350 Checkbox → 主题;L370/372 按钮 `#2E7DFF`/`#1A4480` → `scheme.primary` / `onSurface.withOpacity(.12)` | +| P0-4 | `lib/screens/login/login_page.dart` | 品牌化:Logo 从 80×80 → **112×112**,下方加 `AppBrand.cloudGradient` 文字标题 / 或顶部加一条橙→金渐变的"云朵"装饰带;L158 `maxWidth: 420` 保留并推广 | +| P0-5 | `lib/main.dart` | L60-61 `statusBarIconBrightness: Brightness.light` **硬编码 → 跟随 `themeMode`**(当前浅色模式下状态栏图标不可见,是真 bug);方案:把 `setSystemUIOverlayStyle` 移到 `OpsConsoleApp.build` 内按 `theme.brightness` 动态设置 | +| P0-6 | `lib/widgets/store_theme_grid.dart` | L77 选中边框 `Color(0xFF2E7DFF)` → `Theme.of(context).colorScheme.primary` | +| P0-7 | `lib/providers/theme_provider.dart` | L7 默认 `ThemeMode.dark` → 建议改 `ThemeMode.light`(logo 明度均值 245/255,"明亮轻盈"才是品牌主场);或保留 dark 但确保深色方案已按 §3.4 落地 | +| P0-8 | `lib/screens/store/store_list_page.dart` | 三处 `showModalBottomSheet`(L142/158/188)改用 `showAppBottomSheet`(限高 92% + 吸底栏 + 安全区 + 把手);L1404-1416 保存按钮**移出滚动区**改为吸底 | +| P0-9 | `lib/utils/responsive.dart` | **新建**:`AppResponsive` 断点工具 + `BuildContext.responsive` 扩展 | + +### 7.2 P1 —— 布局与触控(第 2 个迭代) + +| # | 文件 | 改动点 | +| --- | --- | --- | +| P1-1 | `lib/widgets/app_bottom_nav.dart` | 横屏且宽 ≥840 时切换 `NavigationRail`;统一 `destinations` 提取为方法 | +| P1-2 | `lib/widgets/cloud_card.dart` | **新建** `CloudCard` 组件(§4.3 方案 B) | +| P1-3 | `lib/widgets/app_bottom_sheet.dart` | **新建** `showAppBottomSheet` + 吸底栏脚手架(§6.4) | +| P1-4 | `lib/screens/home/home_page.dart` | L67 `crossAxisCount: 2` → `responsive.gridCount()`;L78-96 `Colors.blue/purple/green/red` → `AppSemanticColors` / 主题色;`_StatCard` 换 `CloudCard`;`_QuickAction` 圆角 + 48 触控 | +| P1-5 | `lib/screens/home/device_detail_page.dart` | L312 `crossAxisCount: 2` → `responsive.gridCount()`;L325/334/343 `Colors.red/orange` → 语义色;L535 `Colors.redAccent` → `scheme.error` | +| P1-6 | `lib/screens/store/store_list_page.dart` | L298/474/479/487/528/542/1221 `Colors.grey/green/teal/red` → 主题语义色;`_StoreListTile` 换 `CloudCard`(圆角 16 + 阴影 xs);L365 图标 14→20;列表项 `margin` 用 `responsive.gutter` | +| P1-7 | `lib/widgets/device_list_tile.dart` | L71 `Icons.dns/dns_outlined` → `Icons.cloud_done_outlined` / `Icons.cloud_off_outlined`;L81 `IconButton` 与 `StatusChip` 间距补 8dp;换 `CloudCard` | +| P1-8 | `lib/widgets/status_chip.dart` | L16-20 `Colors.green/grey/orange` → `AppSemanticColors`(成功/中性/警告) | +| P1-9 | `lib/screens/store/org_tree_page.dart` | L280-290 `_TypeInfo` 六色(`purple/blue/orange/green/grey`)→ 主题色板内取色(避免与品牌橙混淆的 `Colors.orange`);L63/351 `Colors.red` → `scheme.error` | +| P1-10 | `lib/screens/device/device_activation_page.dart` | L119/293 `Colors.orange` → `scheme.primary`(**注意:这里用橙色表示"可点击",换色后必须改成 primary,否则与新品牌橙冲突**);L158 语义色 | +| P1-11 | `lib/widgets/region_picker.dart` | L189 `Colors.blue` → `scheme.primary`;L154 `size.height * 0.6` 改按断点(横屏改 0.8) | +| P1-12 | `lib/screens/alerts/alerts_page.dart` | L15/17/19 `Colors.red/orange/blue` → 语义色 | +| P1-13 | `lib/screens/admin/admin_hub_page.dart` | L19/25/31/37/43 五色 → 语义色 / 主题色板 | +| P1-14 | 其余 20+ 处 `showModalBottomSheet` | 统一加 `useSafeArea: true` + `constraints`(主题层已全局兜底 `showDragHandle`/`shape`/`maxWidth`,页面层补 `maxHeight`) | +| P1-15 | 全项目 `Icons.xxx` | 非选中态统一改 `_outlined` 变体;尺寸收敛到 `AppIconSize` 栅格 | + +### 7.3 P2 —— 增强与品牌渗透 + +| # | 文件 | 改动点 | +| --- | --- | --- | +| P2-1 | `lib/screens/store/store_list_page.dart`
`lib/screens/home/device_list_page.dart` | 横屏双栏主从布局(§6.5-③) | +| P2-2 | `lib/screens/login/login_page.dart` | 横屏左右分栏(左品牌渐变 + 右表单) | +| P2-3 | `pubspec.yaml` | 引入 Material Symbols Outlined 可变字体,让 `IconThemeData.fill/weight/grade/opticalSize` 真正生效 | +| P2-4 | AppBar / 首页 | 增加"云朵"品牌元素:首页顶部加一条 `AppBrand.cloudGradient` 的渐隐云带;AppBar 标题旁加 20dp 小 logo(品牌渗透,现状 logo 仅出现于登录页与设置页) | +| P2-5 | `lib/screens/device/device_scan_page.dart` | 取景框按 `shortestSide` 计算,修复横屏压扁 | +| P2-6 | `lib/screens/device/store_config_page.dart`
`lib/widgets/branding_editor.dart` | 横屏分栏(左预览 / 右编辑) | +| P2-7 | `lib/main.dart` | L193/255 错误页 `Colors.red.shade50` 硬编码 → 跟随主题(深色模式下刺眼) | +| P2-8 | 全局 | 空状态 / 加载 / 错误三态组件统一(现状各页自行拼装,风格不一) | + +### 7.4 `lib/app/theme.dart` 改造后关键代码片段 + +```dart +import 'package:flutter/material.dart'; + +/// 品牌原色(直接取自 assets/images/logo.png 实测像素,勿随意更改)。 +class AppBrand { + const AppBrand._(); + static const Color cloud = Color(0xFFFF914D); // logo 主色 + static const Color cloudGold = Color(0xFFFFBE58); // logo 金黄 + static const Color cloudLight = Color(0xFFFFB487); + static const Color onCloud = Color(0xFF3D1A00); // 亮橙上唯一合法文字色 + static const LinearGradient cloudGradient = LinearGradient( + begin: Alignment.topLeft, end: Alignment.bottomRight, + colors: [Color(0xFFFF914D), Color(0xFFFFBE58)], + ); + static const Color shadowTint = Color(0xFF5C2E12); // 暖调阴影 +} + +/// 圆角梯度(4dp 栅格;源自 logo 圆角矩形 6/192≈3% 的"克制圆角"语义)。 +class AppRadius { + const AppRadius._(); + static const double xs = 4, sm = 8, md = 12, lg = 16, xl = 20, xxl = 28; + static const double full = 999; + static const BorderRadius sheetTop = + BorderRadius.vertical(top: Radius.circular(xxl)); +} + +/// 云感阴影:大模糊、小偏移、低透明、暖色 —— 替代扁平的 elevation:1。 +class AppShadows { + const AppShadows._(); + static const List xs = [ + BoxShadow(color: Color(0x0F5C2E12), blurRadius: 8, offset: Offset(0, 2), spreadRadius: -1), + ]; + static const List sm = [ + BoxShadow(color: Color(0x145C2E12), blurRadius: 12, offset: Offset(0, 4), spreadRadius: -2), + ]; + static const List md = [ + BoxShadow(color: Color(0x1A5C2E12), blurRadius: 20, offset: Offset(0, 8), spreadRadius: -4), + ]; + static const List lg = [ + BoxShadow(color: Color(0x245C2E12), blurRadius: 32, offset: Offset(0, 12), spreadRadius: -6), + ]; + static const List xl = [ + BoxShadow(color: Color(0x245C2E12), blurRadius: 48, offset: Offset(0, 20), spreadRadius: -8), + ]; + /// 深色模式:暖色阴影在暗底不可见(实测对比 1.05),改用纯黑 + 描边。 + static const List dark = [ + BoxShadow(color: Color(0x66000000), blurRadius: 24, offset: Offset(0, 8), spreadRadius: -4), + ]; +} + +/// 图标尺寸栅格。 +class AppIconSize { + const AppIconSize._(); + static const double inline = 16, dense = 20, regular = 24; + static const double medium = 28, large = 32, display = 48, hero = 64; +} + +/// 应用主题:暖橙云感(取自 logo),浅色 / 深色两套。 +class AppTheme { + AppTheme._(); + + /// 行动主色 = logo #FF914D 沿同色相压暗至可承载白字(vs 白 = 5.18,达 AA)。 + static const Color brandPrimary = Color(0xFFC2410C); + + static const ColorScheme _light = ColorScheme( + brightness: Brightness.light, + primary: Color(0xFFC2410C), onPrimary: Color(0xFFFFFFFF), + primaryContainer: Color(0xFFFFD3B3), onPrimaryContainer: Color(0xFF4A1D00), + secondary: Color(0xFF8A5A3A), onSecondary: Color(0xFFFFFFFF), + secondaryContainer: Color(0xFFF5DFCB), onSecondaryContainer: Color(0xFF5A3A22), + tertiary: Color(0xFF9A6000), onTertiary: Color(0xFFFFFFFF), + tertiaryContainer: Color(0xFFFFE0A3), onTertiaryContainer: Color(0xFF5C3D00), + error: Color(0xFFB02A20), onError: Color(0xFFFFFFFF), + errorContainer: Color(0xFFFFDDD8), onErrorContainer: Color(0xFF7A2A22), + surface: Color(0xFFFCFAF8), onSurface: Color(0xFF1A1614), + onSurfaceVariant: Color(0xFF6E655C), + surfaceContainerLowest: Color(0xFFFFFFFF), + surfaceContainerLow: Color(0xFFF7F3EF), + surfaceContainer: Color(0xFFF2EDE8), + surfaceContainerHigh: Color(0xFFECE5DF), + surfaceContainerHighest: Color(0xFFE5DED7), + outline: Color(0xFF8A8178), outlineVariant: Color(0xFFDCCFC2), + surfaceTint: Color(0xFFC2410C), shadow: AppBrand.shadowTint, + scrim: Color(0xFF1A1614), + inverseSurface: Color(0xFF2E2823), onInverseSurface: Color(0xFFF7EFE9), + inversePrimary: Color(0xFFFFB487), + ); + + static const ColorScheme _dark = ColorScheme( + brightness: Brightness.dark, + primary: Color(0xFFFFB487), onPrimary: Color(0xFF542000), + primaryContainer: Color(0xFF63300D), onPrimaryContainer: Color(0xFFFFDCC2), + secondary: Color(0xFFE8C39B), onSecondary: Color(0xFF542000), + secondaryContainer: Color(0xFF5A3A22), onSecondaryContainer: Color(0xFFFFDCC2), + tertiary: Color(0xFFF2BE62), onTertiary: Color(0xFF542000), + tertiaryContainer: Color(0xFF5C3D00), onTertiaryContainer: Color(0xFFFFDCC2), + error: Color(0xFFFF8474), onError: Color(0xFF5A1610), + errorContainer: Color(0xFF7A2A22), onErrorContainer: Color(0xFFFFC9C2), + surface: Color(0xFF16130F), onSurface: Color(0xFFF3E9E2), + onSurfaceVariant: Color(0xFFC4B8AE), + surfaceContainerLowest: Color(0xFF0F0D0A), + surfaceContainerLow: Color(0xFF1E1A16), + surfaceContainer: Color(0xFF23201B), + surfaceContainerHigh: Color(0xFF2D2924), + surfaceContainerHighest: Color(0xFF38332D), + outline: Color(0xFF7A7068), outlineVariant: Color(0xFF453F39), + surfaceTint: Color(0xFFFFB487), shadow: Color(0xFF000000), + scrim: Color(0xFF0F0D0A), + inverseSurface: Color(0xFFF3E9E2), onInverseSurface: Color(0xFF2E2823), + inversePrimary: Color(0xFFC2410C), + ); + + static ThemeData light() => _base(_light); + static ThemeData dark() => _base(_dark); + + static ThemeData _base(ColorScheme scheme) { + final isDark = scheme.brightness == Brightness.dark; + return ThemeData( + useMaterial3: true, + colorScheme: scheme, + + // —— 暖调阴影(替代系统默认纯黑)—— + shadowColor: isDark ? Colors.black : AppBrand.shadowTint, + + // —— 触控:最小 48,主按钮 52 —— + materialTapTargetSize: MaterialTapTargetSize.padded, + visualDensity: VisualDensity.standard, + + // —— 图标:统一线性、24dp 栅格 —— + iconTheme: IconThemeData( + size: AppIconSize.regular, + color: scheme.onSurfaceVariant, + fill: 0.0, // 需可变字体支持;当前靠统一使用 _outlined 变体落地 + weight: 400, + grade: 0, + opticalSize: 24, + ), + primaryIconTheme: IconThemeData(size: AppIconSize.regular, color: scheme.primary), + + // —— AppBar:滚动后浮起,解决与内容无分隔 —— + appBarTheme: AppBarTheme( + centerTitle: true, + backgroundColor: scheme.surface, + foregroundColor: scheme.onSurface, + surfaceTintColor: scheme.surfaceTint, + elevation: 0, + scrolledUnderElevation: 3, + toolbarHeight: 52, + actionsIconTheme: + IconThemeData(size: AppIconSize.regular, color: scheme.onSurfaceVariant), + ), + + // —— 卡片:elevation 0 + CloudCard 自定义阴影;圆角 16 —— + cardTheme: CardThemeData( + elevation: 0, + clipBehavior: Clip.antiAlias, + surfaceTintColor: scheme.surfaceTint, + shadowColor: isDark ? Colors.black : AppBrand.shadowTint, + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.lg)), + ), + + // —— 底部弹窗:全局兜底 30 处调用 —— + bottomSheetTheme: BottomSheetThemeData( + showDragHandle: true, // 可下滑关闭的视觉暗示 + useSafeArea: true, // ★ 默认 false,必须显式开启 + backgroundColor: scheme.surface, + surfaceTintColor: scheme.surfaceTint, + modalBackgroundColor: scheme.surface, + elevation: 0, + clipBehavior: Clip.antiAlias, + shape: const RoundedRectangleBorder(borderRadius: AppRadius.sheetTop), + constraints: const BoxConstraints(maxWidth: 640), // 横屏不铺满 + ), + + // —— 输入框:高度 ≥56,常驻描边 —— + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: scheme.surfaceContainerHighest, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18), + prefixIconConstraints: const BoxConstraints(minWidth: 48, minHeight: 48), + suffixIconConstraints: const BoxConstraints(minWidth: 48, minHeight: 48), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + borderSide: BorderSide(color: scheme.outlineVariant)), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + borderSide: BorderSide(color: scheme.outline)), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + borderSide: BorderSide(color: scheme.primary, width: 2)), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + borderSide: BorderSide(color: scheme.error)), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + borderSide: BorderSide(color: scheme.error, width: 2)), + labelStyle: TextStyle(color: scheme.onSurfaceVariant), + hintStyle: TextStyle(color: scheme.onSurfaceVariant), + errorStyle: TextStyle(color: scheme.error, fontSize: 12), + errorMaxLines: 2, + ), + + // —— 按钮:统一 52 高 / 48 触控 / 16 圆角 —— + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + minimumSize: const Size(64, 52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.lg)), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + minimumSize: const Size(64, 52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.lg)), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + minimumSize: const Size(64, 48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.lg)), + ), + ), + iconButtonTheme: IconButtonThemeData( + style: IconButton.styleFrom( + minimumSize: const Size(48, 48), + tapTargetSize: MaterialTapTargetSize.padded, + padding: EdgeInsets.zero, + ), + ), + + // —— Chip / SnackBar / Dialog —— + chipTheme: ChipThemeData( + backgroundColor: scheme.surfaceContainerHighest, + labelStyle: TextStyle(color: scheme.onSurface), + selectedColor: scheme.secondaryContainer, + checkmarkColor: scheme.onSecondaryContainer, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.sm)), + ), + snackBarTheme: SnackBarThemeData( + behavior: SnackBarBehavior.floating, + backgroundColor: scheme.inverseSurface, + contentTextStyle: TextStyle(color: scheme.onInverseSurface), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.sm)), + ), + dialogTheme: DialogThemeData( + backgroundColor: scheme.surface, + surfaceTintColor: scheme.surfaceTint, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.xxl)), + ), + + // —— 品牌与语义色扩展(ThemeExtension,零依赖)—— + extensions: >[ + const AppBrandExtension(), + isDark ? AppSemanticColors.dark : AppSemanticColors.light, + ], + ); + } +} +``` + +--- + +## 8. 风险与权衡 + +### 8.1 换色影响面:所有硬编码蓝色(已全量搜索源码) + +**必须改(`App` 主题色误用)** + +| 文件:行 | 现值 | 改法 | +| --- | --- | --- | +| `lib/app/theme.dart:8` | `Color(0xFF2E7DFF)` | → `#C2410C` | +| `lib/screens/login/login_page.dart:205, 233, 280` | `Color(0xFF2E7DFF)`(聚焦边框) | → `scheme.primary` | +| `lib/screens/login/login_page.dart:350` | `Color(0xFF2E7DFF)`(Checkbox) | → `scheme.primary` | +| `lib/screens/login/login_page.dart:370` | `Color(0xFF2E7DFF)`(登录按钮) | → `scheme.primary` | +| `lib/screens/login/login_page.dart:372` | `Color(0xFF1A4480)`(禁用态) | → `onSurface.withOpacity(0.12)` | +| `lib/screens/login/login_page.dart:149` | `Color(0xFF0D2137)`(**卡片深蓝**) | → `scheme.surfaceContainerLow`(暖黑) | +| `lib/screens/login/login_page.dart:152` | `Color(0xFF121212)` | → `scheme.surface` | +| `lib/widgets/store_theme_grid.dart:77` | `Color(0xFF2E7DFF)`(选中边框) | → `scheme.primary` | +| `lib/widgets/region_picker.dart:189` | `Colors.blue` | → `scheme.primary` | +| `lib/screens/home/home_page.dart:78` | `Colors.blue` | → `scheme.primary` | +| `lib/screens/admin/admin_hub_page.dart:19` | `Colors.blue` | → 语义色 | +| `lib/screens/store/org_tree_page.dart:282` | `Colors.blue` | → 语义色 | +| `lib/screens/alerts/alerts_page.dart:19` | `Colors.blue` | → `AppSemanticColors.info` | + +**⚠️ 绝对不能动(业务数据色,不是 App 主题色)** + +| 文件:行 | 值 | 原因 | +| --- | --- | --- | +| `lib/widgets/store_theme_preview.dart:110-115` | `#3B82F6` `#8B5CF6` `#EC4899` `#F59E0B` `#10B981` `#6366F1` | **门店主题模板的预览色板**,是下发给 TV 端的业务配置,改了会导致门店界面配置错乱 | +| `lib/widgets/store_theme_grid.dart:129-134` | 同上 6 色 | 同上 | +| `lib/models/store_theme.dart:46-54` | `#1565C0` `#00838F` `#2E7D32` `#E65100` `#455A64` | **门店主题色枚举**,业务数据 | + +> **这是本次换色最大的误伤风险**:`#3B82F6` 与 `#2E7DFF` 都是蓝色,批量替换 `Color(0xFF2E7DFF)` 时若用正则误伤 `store_theme_grid.dart:129` 的 `#3B82F6`,会破坏门店主题功能。**建议只做精确字符串 `Color(0xFF2E7DFF)` 的整词替换,并逐处人工确认。** + +### 8.2 深色模式下橙色的处理(3 条硬约束) + +1. **主色必须提亮,文字必须改深色** + 深色下 `primary = #FFB487`(logo 渐变带原色),`onPrimary = #542000`(对比 7.66 ✅)。 + **绝对不能用白字**:白字 on `#FFB487` 仅 1.73 ❌。 + 若沿用浅色的 `#C2410C` 作深色主色,`onPrimary` 白字虽可达标,但深橙在深底上会"沉下去",按钮看起来像禁用态 —— **必须提亮**。 + +2. **橙色只做小面积强调,容器级用深橙底** + 深色模式下大面积亮橙会产生**光晕(halation)**,在 OLED 屏幕上尤其刺眼、且长时间注视疲劳。 + 规则:亮橙 `#FFB487` 仅用于**图标、进度条填充、选中指示、文字链接**(面积 < 8%);容器级一律用 `primaryContainer = #63300D`(深橙底)+ `onPrimaryContainer = #FFDCC2`。 + +3. **surface 必须暖化,不能是 `#121212`** + 现状 `login_page.dart:152` 用 `#121212`、`:149` 用 `#0D2137`(**深蓝**)—— 后者与暖橙主题直接冲突,登录是用户看到的第一屏,观感会分裂。 + 统一改为暖黑 `#16130F`(R>G>B 的暖偏移),与 `#FFB487` 同源。 + 另外,深色下**不要**用纯黑 `#000000` 作 surface:OLED 上纯黑与滚动内容之间缺乏层次,且暖色阴影完全不可见(实测对比 1.05)→ 必须配合 `outlineVariant` 描边。 + +### 8.3 其他风险与权衡 + +| 风险 | 影响 | 缓解 | +| --- | --- | --- | +| **`ColorScheme.fromSeed` → 显式声明** | 失去 M3 动态取色(Android 12+ 壁纸取色)能力 | 本项目是**强品牌识别**诉求,品牌一致性优先级 > 动态取色;且现状已用 `.copyWith(primary:)` 覆盖了 primary,动态取色本就未生效。**可接受** | +| **亮橙不能作文字/图标色** | 设计上少了一个高亮手段,部分位置需改用 `primary` 深橙 | 用 `primaryContainer`(浅橙底)+ `onPrimaryContainer`(深棕字)组合替代;Banner/插画用大色块亮橙 + 深棕字(6.99 ✅) | +| **App 图标 / 启动图 未同步** | 主题改了但桌面图标仍是旧色,品牌一致性打折 | 需同步更新 `android/app/src/main/res/mipmap-*/ic_launcher.png` 与 `drawable*/launch_background.xml`(本方案未覆盖,建议单独立项) | +| **M3 `FilledButton` 默认胶囊圆角** | 与卡片 16 圆角脱节 | 已在 `filledButtonTheme` 显式覆盖为 `AppRadius.lg`(16),**必须检查所有未走主题的 `style: FilledButton.styleFrom(...)` 局部覆盖** | +| **横屏双栏改造工作量** | 涉及 `store_list_page.dart`(1423 行)、`device_list_page.dart` | 分两步:P1 先做断点工具 + Grid + NavigationRail(低风险);P2 再做双栏主从(需重构选中态管理,用 Riverpod 持有 `selectedStoreId`) | +| **`visualDensity: standard`** | 控件间距 +4dp,单屏信息量略降 | 运维现场场景,误触代价值得;若反馈过松,可回退 `VisualDensity.comfortable` | +| **登录页去硬编码** | 该页此前是为规避"M3 动态取色导致文字/背景融为一体"而硬编码的(见 L144 注释) | 改成显式 `ColorScheme` 后色彩完全可控,**该规避理由已不成立**;但仍需保留完整的 try/catch 兜底逻辑,不要顺手删 | +| **深色→浅色默认切换**(P0-7) | 老用户升级后界面突变 | 建议:`StoreLocalCache` 中无显式偏好时,跟随 `ThemeMode.system`,而非硬切 light | + +--- + +## 9. 验收标准(可度量) + +| 项 | 验收方式 | 目标 | +| --- | --- | --- | +| 品牌一致性 | 取 5 个主页面截图,取样主色相 | 主色 H ∈ [20°, 26°](logo 为 22.9°) | +| 对比度 | 用 §3.8 清单逐项复核 | **全部达到 WCAG AA**(正文 4.5 / 大字 3.0 / 非文本 3.0) | +| 触控目标 | Android 无障碍扫描 / `flutter test` 断言 | 所有可点元素 ≥48×48,间距 ≥8 | +| 横屏适配 | PGBM10 实机横屏 1600×900 走查 | 无横向拉伸失真;列表页双栏;底部导航为 NavigationRail | +| 弹窗 | 实机打开全部 30 处弹窗 | 全部有拖拽把手、限高 ≤92%、内容不被手势条遮挡、长表单有吸底操作栏 | +| 视觉回归 | 浅色 / 深色两套各 5 个页面截图对比 | 无文字与背景融为一体的情况;无硬编码蓝色残留(`grep -rn "0xFF2E7DFF\|Colors.blue" lib/` 除业务色外为 0) | + +--- + +**附录:本文所有对比度数值均由 WCAG 2.1 相对亮度公式脚本计算得出(源码见 §3.8 输出),非目测或估算。** diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/Generated.xcconfig b/ios/Flutter/Generated.xcconfig new file mode 100644 index 0000000..386673d --- /dev/null +++ b/ios/Flutter/Generated.xcconfig @@ -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 diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/flutter_export_environment.sh b/ios/Flutter/flutter_export_environment.sh new file mode 100644 index 0000000..6a4676c --- /dev/null +++ b/ios/Flutter/flutter_export_environment.sh @@ -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" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..25686dc --- /dev/null +++ b/ios/Podfile @@ -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 diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6266644 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -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) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..a19a549 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,20 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..5feee5a --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..a60c132 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/GeneratedPluginRegistrant.h b/ios/Runner/GeneratedPluginRegistrant.h new file mode 100644 index 0000000..7a89092 --- /dev/null +++ b/ios/Runner/GeneratedPluginRegistrant.h @@ -0,0 +1,19 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GeneratedPluginRegistrant_h +#define GeneratedPluginRegistrant_h + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface GeneratedPluginRegistrant : NSObject ++ (void)registerWithRegistry:(NSObject*)registry; +@end + +NS_ASSUME_NONNULL_END +#endif /* GeneratedPluginRegistrant_h */ diff --git a/ios/Runner/GeneratedPluginRegistrant.m b/ios/Runner/GeneratedPluginRegistrant.m new file mode 100644 index 0000000..8945f92 --- /dev/null +++ b/ios/Runner/GeneratedPluginRegistrant.m @@ -0,0 +1,63 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#import "GeneratedPluginRegistrant.h" + +#if __has_include() +#import +#else +@import file_picker; +#endif + +#if __has_include() +#import +#else +@import image_picker_ios; +#endif + +#if __has_include() +#import +#else +@import mobile_scanner; +#endif + +#if __has_include() +#import +#else +@import package_info_plus; +#endif + +#if __has_include() +#import +#else +@import permission_handler_apple; +#endif + +#if __has_include() +#import +#else +@import shared_preferences_foundation; +#endif + +#if __has_include() +#import +#else +@import url_launcher_ios; +#endif + +@implementation GeneratedPluginRegistrant + ++ (void)registerWithRegistry:(NSObject*)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 diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..ba946cf --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,67 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + 云主页运营助手 + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + cloudhome_ops + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + NSAppTransportSecurity + + + NSExceptionDomains + + 58.220.83.58 + + NSExceptionAllowsInsecureHTTPLoads + + + + + NSBonjourServices + + _dartVmService._tcp + + NSLocalNetworkUsageDescription + 调试模式下用于发现 Dart VM 服务(热重载) + + diff --git a/lib/app/router.dart b/lib/app/router.dart new file mode 100644 index 0000000..eb7757f --- /dev/null +++ b/lib/app/router.dart @@ -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((ref) { + // 登录态变化时触发路由重定向刷新,而不重建整个 GoRouter。 + final refreshNotifier = ValueNotifier(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(), + ), + ], + ), + ], + ), + ], + ), + ], + ); +}); diff --git a/lib/app/theme.dart b/lib/app/theme.dart new file mode 100644 index 0000000..06e8309 --- /dev/null +++ b/lib/app/theme.dart @@ -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 >[AppSemanticColors.light], + ); + + /// 深色主题(暖化 surface,主色提亮为 #FFB487)。 + static ThemeData dark() => _base(_dark).copyWith( + extensions: const >[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 >[], + 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 xs = [ + BoxShadow( + color: Color(0x0F5C2E12), + blurRadius: 8, + offset: Offset(0, 2), + spreadRadius: -1, + ), + ]; + static const List sm = [ + BoxShadow( + color: Color(0x145C2E12), + blurRadius: 12, + offset: Offset(0, 4), + spreadRadius: -2, + ), + ]; + static const List md = [ + BoxShadow( + color: Color(0x1A5C2E12), + blurRadius: 20, + offset: Offset(0, 8), + spreadRadius: -4, + ), + ]; + static const List lg = [ + BoxShadow( + color: Color(0x245C2E12), + blurRadius: 32, + offset: Offset(0, 12), + spreadRadius: -6, + ), + ]; + static const List xl = [ + BoxShadow( + color: Color(0x245C2E12), + blurRadius: 48, + offset: Offset(0, 20), + spreadRadius: -8, + ), + ]; + + /// 深色:暖色阴影在暗底不可见,改用纯黑 + 描边。 + static const List 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 { + 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 copyWith() => this; + + @override + ThemeExtension lerp( + covariant ThemeExtension? other, double t) => + other ?? this; +} diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart new file mode 100644 index 0000000..50fce7a --- /dev/null +++ b/lib/config/app_config.dart @@ -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; + } + } +} diff --git a/lib/config/environment.dart b/lib/config/environment.dart new file mode 100644 index 0000000..031daa8 --- /dev/null +++ b/lib/config/environment.dart @@ -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, +} diff --git a/lib/config/prod_config.dart b/lib/config/prod_config.dart new file mode 100644 index 0000000..6a0317f --- /dev/null +++ b/lib/config/prod_config.dart @@ -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; +} diff --git a/lib/config/prod_guard.dart b/lib/config/prod_guard.dart new file mode 100644 index 0000000..e466f4f --- /dev/null +++ b/lib/config/prod_guard.dart @@ -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 _productionHostDenyList = [ + '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); + } +} diff --git a/lib/config/staging_config.dart b/lib/config/staging_config.dart new file mode 100644 index 0000000..8faa701 --- /dev/null +++ b/lib/config/staging_config.dart @@ -0,0 +1,16 @@ +/// 预发环境专属配置。 +/// +/// 与 [TestConfig] 完全隔离,**禁止交叉引用生产环境**的任何常量或文件。 +/// 预发环境的服务地址、密钥、数据库连接等敏感配置统一在此独立管理。 +class StagingConfig { + StagingConfig._(); + + /// 预发环境后端 origin(按实际预发域名填写,**严禁填写生产地址**)。 + static const String apiBaseUrl = 'https://ips-staging.ainetv.com'; + + /// 预发环境是否启用登录验证码。 + static const bool enableCaptcha = true; + + // 预发环境密钥 / 数据库连接等敏感配置在此独立维护, + // 不得出现任何生产环境标识或引用。 +} diff --git a/lib/config/test_config.dart b/lib/config/test_config.dart new file mode 100644 index 0000000..f63ff15 --- /dev/null +++ b/lib/config/test_config.dart @@ -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; + + // 测试环境密钥 / 数据库连接等敏感配置在此独立维护, + // 不得出现任何生产环境标识或引用。 +} diff --git a/lib/data/region_data.dart b/lib/data/region_data.dart new file mode 100644 index 0000000..b5317c6 --- /dev/null +++ b/lib/data/region_data.dart @@ -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 sub; + + factory RegionNode.fromJson(Map json) { + final subRaw = json['sub']; + final List children = subRaw is List + ? subRaw + .whereType>() + .map(RegionNode.fromJson) + .toList() + : const []; + return RegionNode( + code: json['code']?.toString() ?? '', + name: json['name']?.toString() ?? '', + sub: children, + ); + } +} + +/// 省市区数据源。 +/// +/// 统一由 [RegionData.load] 懒加载 `province.json` 并缓存为 +/// `List` 树;组件禁止直接 `rootBundle` 读取,避免重复解析 +/// (文件约 284KB)。 +class RegionData { + RegionData._(); + + /// 全局单例。 + static final RegionData instance = RegionData._(); + + /// 是否已加载。 + bool get isLoaded => _loaded; + bool _loaded = false; + + /// 省级节点树(已缓存)。 + List get provinces => List.unmodifiable(_tree); + final List _tree = []; + + /// 省份名称缓存(code → 名称),用于反查。 + final Map _nameByCode = {}; + + /// 懒加载省市区数据。重复调用安全(仅首次解析)。 + Future 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(RegionNode.fromJson) + : const []; + _tree + ..clear() + ..addAll(list); + _buildNameIndex(_tree); + _loaded = true; + } + + /// 构建 code → 名称 反查索引(省/市/区全部登记)。 + void _buildNameIndex(List nodes) { + for (final node in nodes) { + if (node.code.isNotEmpty) { + _nameByCode[node.code] = node.name; + } + if (node.sub.isNotEmpty) { + _buildNameIndex(node.sub); + } + } + } + + /// 获取某省下的市级节点;[provinceCode] 为空或不存在时返回空列表。 + List citiesOf(String provinceCode) { + for (final province in _tree) { + if (province.code == provinceCode) return province.sub; + } + return const []; + } + + /// 获取某市下的区级节点; + /// [provinceCode]/[cityCode] 任一为空或不存在时返回空列表。 + List 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 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]; + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..d1a8f61 --- /dev/null +++ b/lib/main.dart @@ -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( + () 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 { + const _AppLocaleDelegate(); + + @override + bool isSupported(Locale locale) => + locale.languageCode == 'zh' || locale.languageCode == 'en'; + + @override + Future load(Locale locale) async { + return locale.languageCode == 'en' ? AppLocale.en : AppLocale.zh; + } + + @override + bool shouldReload(covariant LocalizationsDelegate 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, + ), + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/lib/models/alert.dart b/lib/models/alert.dart new file mode 100644 index 0000000..dc2b130 --- /dev/null +++ b/lib/models/alert.dart @@ -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 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 toJson() { + return { + 'id': id, + 'deviceId': deviceId, + 'deviceName': deviceName, + 'title': title, + 'message': message, + 'level': level.name, + 'occurredAt': occurredAt.toIso8601String(), + 'acknowledged': acknowledged, + }; + } +} diff --git a/lib/models/app_version.dart b/lib/models/app_version.dart new file mode 100644 index 0000000..ba8f219 --- /dev/null +++ b/lib/models/app_version.dart @@ -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 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 toJson() { + return { + 'id': id, + 'orgId': orgId, + 'groupId': groupId, + 'appName': appName, + 'packageName': packageName, + 'versionCode': versionCode, + 'versionName': versionName, + 'apkUrl': apkUrl, + 'status': status, + 'createTime': createTime?.toIso8601String(), + }; + } +} diff --git a/lib/models/device.dart b/lib/models/device.dart new file mode 100644 index 0000000..a977cd0 --- /dev/null +++ b/lib/models/device.dart @@ -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 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 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 json) { + // 兼容详情接口可能再包一层 {device|info|data:{...}} 的形态,先兜底解包。 + final Map data = _unwrapDeviceJson(json); + final mac = data['mac']?.toString() ?? ''; + final metricsJson = data['metrics']; + // 合并嵌套 metrics 与顶层扁平遥测字段(详情接口可能直接返回 cpuUsage / + // cpu_usage / cpu 等)。任一使用率字段存在即视为有指标数据,便于详情页展示。 + final Map mergedMetrics = {}; + if (metricsJson is Map) { + 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 _unwrapDeviceJson(Map 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 && + (nested.containsKey('id') || nested.containsKey('mac'))) { + return nested; + } + } + return json; + } + + /// 从 [json] 中按 [keys] 顺序取第一个非空数值。 + /// + /// 兼容后端返回字符串或数字两种形态;任意匹配键取到有效数值即返回, + /// 否则返回 null(交由调用处按默认 0 处理,避免 UI 崩溃)。 + static num? _firstNum( + Map json, + List 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 json, + List 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 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) { + 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 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, + }; + } +} diff --git a/lib/models/intro_category.dart b/lib/models/intro_category.dart new file mode 100644 index 0000000..75ee240 --- /dev/null +++ b/lib/models/intro_category.dart @@ -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? subCategories; + + factory IntroCategory.fromJson(Map 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.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 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? contents; + + factory IntroSubcategory.fromJson(Map 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.from(json['contents'] as List) + : null, + ); + } + + Map 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 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 toJson() { + return { + 'id': id, + 'subcategoryId': subcategoryId, + 'title': title, + 'description': description, + 'mediaType': mediaType, + 'mediaUrl': mediaUrl, + 'sortOrder': sort, + 'status': status, + }; + } +} diff --git a/lib/models/marquee_config.dart b/lib/models/marquee_config.dart new file mode 100644 index 0000000..973cbc9 --- /dev/null +++ b/lib/models/marquee_config.dart @@ -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.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.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; +} diff --git a/lib/models/marquee_message.dart b/lib/models/marquee_message.dart new file mode 100644 index 0000000..fca7fa0 --- /dev/null +++ b/lib/models/marquee_message.dart @@ -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 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 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 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; + } +} diff --git a/lib/models/media_asset.dart b/lib/models/media_asset.dart new file mode 100644 index 0000000..699f128 --- /dev/null +++ b/lib/models/media_asset.dart @@ -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 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 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 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 toJson() { + return { + 'id': id, + 'code': code, + 'mediaName': mediaName, + 'sortOrder': sortOrder, + 'coverUrl': coverUrl, + }; + } +} diff --git a/lib/models/org_node.dart b/lib/models/org_node.dart new file mode 100644 index 0000000..50a7e3c --- /dev/null +++ b/lib/models/org_node.dart @@ -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 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 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(OrgNode.fromJson) + .toList() + : const [], + 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 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? 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; +} diff --git a/lib/models/schedule.dart b/lib/models/schedule.dart new file mode 100644 index 0000000..4a714f4 --- /dev/null +++ b/lib/models/schedule.dart @@ -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 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 toJson() { + return { + 'jobId': jobId, + 'beanName': beanName, + 'methodName': methodName, + 'params': params, + 'cronExpression': cronExpression, + 'status': status, + 'remark': remark, + 'createTime': createTime?.toIso8601String(), + }; + } +} diff --git a/lib/models/store_config.dart b/lib/models/store_config.dart new file mode 100644 index 0000000..94b1e07 --- /dev/null +++ b/lib/models/store_config.dart @@ -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 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); + } +} diff --git a/lib/models/store_info.dart b/lib/models/store_info.dart new file mode 100644 index 0000000..a48898e --- /dev/null +++ b/lib/models/store_info.dart @@ -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 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 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 = []; + 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(''); + } +} diff --git a/lib/models/store_theme.dart b/lib/models/store_theme.dart new file mode 100644 index 0000000..e823ef5 --- /dev/null +++ b/lib/models/store_theme.dart @@ -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 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; + } +} diff --git a/lib/models/sys_config.dart b/lib/models/sys_config.dart new file mode 100644 index 0000000..6c4a917 --- /dev/null +++ b/lib/models/sys_config.dart @@ -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 json) { + return SysConfig( + id: json['id']?.toString() ?? '', + paramKey: json['paramKey']?.toString() ?? '', + paramValue: json['paramValue']?.toString() ?? '', + remark: json['remark']?.toString() ?? '', + ); + } + + Map toJson() { + return { + 'id': id, + 'paramKey': paramKey, + 'paramValue': paramValue, + 'remark': remark, + }; + } +} diff --git a/lib/models/sys_log.dart b/lib/models/sys_log.dart new file mode 100644 index 0000000..be6102f --- /dev/null +++ b/lib/models/sys_log.dart @@ -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 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 toJson() { + return { + 'id': id, + 'username': username, + 'operation': operation, + 'method': method, + 'params': params, + 'time': time, + 'ip': ip, + 'createDate': createDate?.toIso8601String(), + }; + } +} diff --git a/lib/models/sys_menu.dart b/lib/models/sys_menu.dart new file mode 100644 index 0000000..5696316 --- /dev/null +++ b/lib/models/sys_menu.dart @@ -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 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 toJson() { + return { + 'menuId': menuId, + 'name': name, + 'url': url, + 'type': type, + 'icon': icon, + 'parentId': parentId, + 'orderNum': orderNum, + 'perms': perms, + 'show': show, + }; + } +} diff --git a/lib/models/sys_role.dart b/lib/models/sys_role.dart new file mode 100644 index 0000000..25ad4e1 --- /dev/null +++ b/lib/models/sys_role.dart @@ -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 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 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 json) { + return RoleMenu( + id: json['id']?.toString() ?? '', + roleId: json['roleId']?.toString() ?? '', + menuId: json['menuId']?.toString() ?? '', + ); + } + + Map toJson() { + return { + 'id': id, + 'roleId': roleId, + 'menuId': menuId, + }; + } +} diff --git a/lib/models/sys_user.dart b/lib/models/sys_user.dart new file mode 100644 index 0000000..02ac824 --- /dev/null +++ b/lib/models/sys_user.dart @@ -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 roleNames; + + /// 所属组织 ID。 + final String orgId; + + /// 所属组织名称(门店 / 渠道商 / 代理商名称)。 + final String orgName; + + /// 创建时间。 + final DateTime? createTime; + + factory SysUser.fromJson(Map 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 keys) { + if (value is Map) { + final map = value as Map; + 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 _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 []; + } + + /// 兼容 `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 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? 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, + ); + } +} diff --git a/lib/models/theme_group.dart b/lib/models/theme_group.dart new file mode 100644 index 0000000..e36c306 --- /dev/null +++ b/lib/models/theme_group.dart @@ -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 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 themes; + + factory ThemeGroup.fromJson(Map json) { + final themesRaw = json['themes']; + final themes = themesRaw is List + ? themesRaw + .whereType>() + .map(ThemePage.fromJson) + .where((t) => t.themeId > 0) + .toList() + : []; + 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; + } +} diff --git a/lib/models/theme_template.dart b/lib/models/theme_template.dart new file mode 100644 index 0000000..3d90713 --- /dev/null +++ b/lib/models/theme_template.dart @@ -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 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 toJson() { + return { + 'id': id, + 'name': name, + if (description != null) 'description': description, + if (coverUrl != null) 'coverUrl': coverUrl, + 'status': status, + }; + } + + bool get isEnabled => status.toUpperCase() == 'ENABLED'; +} diff --git a/lib/models/tv_app.dart b/lib/models/tv_app.dart new file mode 100644 index 0000000..d37c5ff --- /dev/null +++ b/lib/models/tv_app.dart @@ -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 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 toJson() { + return { + 'id': id, + 'appName': appName, + 'packageName': packageName, + 'appCategory': appCategory, + 'iconUrl': iconUrl, + 'orgId': orgId, + 'groupId': groupId, + 'status': status, + 'createTime': createTime?.toIso8601String(), + }; + } +} diff --git a/lib/models/user.dart b/lib/models/user.dart new file mode 100644 index 0000000..0fcf93f --- /dev/null +++ b/lib/models/user.dart @@ -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 toJson() => { + 'id': id, + 'account': account, + 'displayName': displayName, + 'role': role, + 'token': token, + 'orgId': orgId, + 'roleId': roleId, + 'orgType': orgType, + 'superAdmin': superAdmin, + }; + + /// 从 JSON 反序列化。 + factory User.fromJson(Map 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, + ); +} diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart new file mode 100644 index 0000000..aa1fc80 --- /dev/null +++ b/lib/providers/auth_provider.dart @@ -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((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 { + @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 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 logout() async { + await ref.read(authServiceProvider).logout(); + SessionOrgScope.instance.clear(); + await _clearPersistedSession(); + state = const AuthState(); + } + + /// 清除本地持久化的登录态。 + Future _clearPersistedSession() async { + await CredentialStorage.instance.clearSession(); + } +} + +/// 登录状态提供者。 +final authProvider = NotifierProvider( + AuthNotifier.new, +); diff --git a/lib/providers/dashboard_provider.dart b/lib/providers/dashboard_provider.dart new file mode 100644 index 0000000..dbd3887 --- /dev/null +++ b/lib/providers/dashboard_provider.dart @@ -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 { + DashboardStatsNotifier(this._deviceService, this._orgService) + : super(const DashboardStats()); + + final DeviceService _deviceService; + final OrgService _orgService; + + Future 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([ + _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((ref) => OrgService()); + +final dashboardStatsProvider = + StateNotifierProvider( + (ref) => DashboardStatsNotifier( + ref.watch(deviceServiceProvider), + ref.watch(orgServiceProvider), + ), +); diff --git a/lib/providers/devices_provider.dart b/lib/providers/devices_provider.dart new file mode 100644 index 0000000..de0656c --- /dev/null +++ b/lib/providers/devices_provider.dart @@ -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((ref) => DeviceService()); + +/// 设备列表提供者(下拉刷新时调用 [ProviderRef.refresh] 即可)。 +final devicesProvider = FutureProvider>((ref) { + return ref.watch(deviceServiceProvider).fetchDevices(); +}); + +/// 按门店筛选的设备列表提供者(参数为门店 orgId,null 表示全部门店)。 +final devicesByStoreProvider = + FutureProvider.family, String?>((ref, storeOrgId) { + return ref + .watch(deviceServiceProvider) + .fetchDevices(storeOrgId: storeOrgId); +}); + +/// 设备详情提供者(按设备 ID 参数化)。 +final deviceDetailProvider = + FutureProvider.family((ref, deviceId) { + return ref.watch(deviceServiceProvider).fetchDeviceById(deviceId); +}); + +/// 告警列表提供者。 +final alertsProvider = FutureProvider>((ref) { + return ref.watch(deviceServiceProvider).fetchAlerts(); +}); diff --git a/lib/providers/display_provider.dart b/lib/providers/display_provider.dart new file mode 100644 index 0000000..8b1381c --- /dev/null +++ b/lib/providers/display_provider.dart @@ -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 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 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 { + @override + DisplayOrientation build() { + // 首选项在 main 启动期已应用;这里异步回读以保证 UI 选中态与持久值一致。 + _load(); + return DisplayOrientation.system; + } + + Future _load() async { + final saved = await loadSavedOrientation(); + if (saved != state) { + state = saved; + applyDisplayOrientation(saved); + } + } + + /// 切换显示方向:更新状态、即时生效并持久化。 + Future 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.new, +); diff --git a/lib/providers/locale_provider.dart b/lib/providers/locale_provider.dart new file mode 100644 index 0000000..983f1e2 --- /dev/null +++ b/lib/providers/locale_provider.dart @@ -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 { + 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 setLocale(AppLocale locale) async { + if (state == locale) return; + state = locale; + await StoreLocalCache.instance.setString(_storageKey, locale.storageKey); + } +} + +/// 语言提供者实例。 +final localeProvider = NotifierProvider( + LocaleNotifier.new, +); diff --git a/lib/providers/metrics_visibility_provider.dart b/lib/providers/metrics_visibility_provider.dart new file mode 100644 index 0000000..d3d7ed5 --- /dev/null +++ b/lib/providers/metrics_visibility_provider.dart @@ -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 loadSavedShowRuntimeMetrics() async { + try { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_kShowRuntimeMetrics) ?? false; + } catch (_) { + return false; + } +} + +/// 设备详情页运行指标显示偏好管理。 +/// +/// - 默认关闭,首次进入设备详情时不展示 CPU/内存/磁盘/网络卡片。 +/// - 用户可在「我的 → 设置」中手动开启。 +/// - 状态持久化到 [SharedPreferences],切换账号/重启 App 后仍保持。 +class ShowRuntimeMetricsNotifier extends Notifier { + @override + bool build() { + _load(); + return false; + } + + Future _load() async { + final saved = await loadSavedShowRuntimeMetrics(); + if (saved != state) { + state = saved; + } + } + + /// 切换显示状态并持久化。 + Future setValue(bool value) async { + state = value; + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kShowRuntimeMetrics, value); + } catch (_) { + // 忽略持久化失败,运行时状态仍生效。 + } + } +} + +/// 运行指标显示偏好提供者。 +final showRuntimeMetricsProvider = NotifierProvider( + ShowRuntimeMetricsNotifier.new, +); diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart new file mode 100644 index 0000000..19a9e32 --- /dev/null +++ b/lib/providers/theme_provider.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 主题模式管理,默认跟随系统(logo 明度均值 245/255,「明亮轻盈」才是品牌主场)。 +class ThemeModeNotifier extends Notifier { + @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.new, +); diff --git a/lib/screens/admin/admin_hub_page.dart b/lib/screens/admin/admin_hub_page.dart new file mode 100644 index 0000000..8f3d013 --- /dev/null +++ b/lib/screens/admin/admin_hub_page.dart @@ -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; +} diff --git a/lib/screens/admin/app/app_version_page.dart b/lib/screens/admin/app/app_version_page.dart new file mode 100644 index 0000000..97b13ab --- /dev/null +++ b/lib/screens/admin/app/app_version_page.dart @@ -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 createState() => _AppVersionPageState(); +} + +class _AppVersionPageState extends ConsumerState { + final _searchController = TextEditingController(); + final _scrollController = ScrollController(); + + List _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 _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 _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 _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( + 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 = { + 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 _delete(String id) async { + final confirmed = await showDialog( + 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( + 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), + ), + ); + } +} diff --git a/lib/screens/admin/app/tv_app_page.dart b/lib/screens/admin/app/tv_app_page.dart new file mode 100644 index 0000000..923510c --- /dev/null +++ b/lib/screens/admin/app/tv_app_page.dart @@ -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 createState() => _TvAppPageState(); +} + +class _TvAppPageState extends ConsumerState { + final _searchController = TextEditingController(); + final _scrollController = ScrollController(); + + List _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 _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 _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 _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( + 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 = { + 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 _delete(String id) async { + final confirmed = await showDialog( + 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( + 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), + ), + ); + } +} diff --git a/lib/screens/admin/channel/channel_management_page.dart b/lib/screens/admin/channel/channel_management_page.dart new file mode 100644 index 0000000..99c4f24 --- /dev/null +++ b/lib/screens/admin/channel/channel_management_page.dart @@ -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 createState() => + _ChannelManagementPageState(); +} + +class _ChannelManagementPageState + extends ConsumerState { + List _tree = []; + bool _loading = false; + String? _error; + final _searchController = TextEditingController(); + String _keyword = ''; + int? _typeFilter; + String? _statusFilter; + final Set _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 _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 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 _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( + 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( + 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( + 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( + 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( + 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 = { + '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 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( + 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), + ), + ); + } +} diff --git a/lib/screens/admin/channel/channel_version_manage_dialog.dart b/lib/screens/admin/channel/channel_version_manage_dialog.dart new file mode 100644 index 0000000..12026e1 --- /dev/null +++ b/lib/screens/admin/channel/channel_version_manage_dialog.dart @@ -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 showChannelVersionManageDialog( + BuildContext context, { + required OrgNode node, +}) { + return showModalBottomSheet( + 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 _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 _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 = { + '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 _delete() async { + final id = _versionId; + if (id == null) return; + final confirmed = await showDialog( + 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 _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 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( + segments: [ + ButtonSegment(value: false, label: Text(t.forceUpdateNo)), + ButtonSegment(value: true, label: Text(t.forceUpdateYes)), + ], + selected: {value}, + onSelectionChanged: (set) => onChanged(set.first), + ), + ], + ); + } +} diff --git a/lib/screens/admin/intro/intro_category_page.dart b/lib/screens/admin/intro/intro_category_page.dart new file mode 100644 index 0000000..91b1d81 --- /dev/null +++ b/lib/screens/admin/intro/intro_category_page.dart @@ -0,0 +1,1358 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:image_picker/image_picker.dart'; + +import '../../../models/intro_category.dart'; +import '../../../models/org_node.dart'; +import '../../../services/api_client.dart'; +import '../../../widgets/org_picker_sheet.dart'; +import '../../../services/intro_service.dart'; +import '../../../services/org_service.dart'; +import '../../../services/upload_service.dart'; +import '../../../utils/i10n.dart'; + +/// 组织选择项:携带层级深度,用于按组织树自上而下排序并缩进展示。 +class _OrgOption { + const _OrgOption({required this.node, required this.depth}); + + final OrgNode node; + final int depth; +} + +/// 公司介绍页:三级层级导航(大类 -> 小类 -> 内容)。 +/// +/// 支持: +/// - 大类表单所属门店选择(树形组织选择器) +/// - 小类/详情表单排序号、图片上传、状态与 Web 后端对齐(1=启用,0=禁用) +class IntroCategoryPage extends ConsumerStatefulWidget { + const IntroCategoryPage({super.key}); + + @override + ConsumerState createState() => _IntroCategoryPageState(); +} + +class _IntroCategoryPageState extends ConsumerState { + final _service = IntroService(); + final _orgService = OrgService(); + final _uploadService = UploadService(); + final _imagePicker = ImagePicker(); + + // 0=大类, 1=小类, 2=详情 + int _currentLevel = 0; + String? _selectedCategoryId; + String? _selectedCategoryName; + String? _selectedCategoryType; + String? _selectedSubcategoryId; + String? _selectedSubcategoryName; + + List _allCategories = []; + List _items = []; + bool _loading = false; + String? _error; + + // 组织树(用于大类表单的组织选择器,也用于列表门店名称回显)。 + List _orgNodes = []; + + @override + void initState() { + super.initState(); + _loadOrgNodes(); + _loadData(); + } + + /// 加载组织树,用于大类表单的树形组织选择器。 + /// 优先取 /sys/org/tree;失败时回退到 flatList / select。 + Future _loadOrgNodes() async { + Future> fetch() async { + try { + final tree = await _orgService.getOrgTree(); + if (tree.isNotEmpty) return tree; + } catch (_) {} + try { + final flat = await _orgService.getOrgFlatList(); + if (flat.isNotEmpty) return flat; + } catch (_) {} + try { + final select = await _orgService.getOrgSelect(); + if (select.isNotEmpty) return select; + } catch (_) {} + return const []; + } + + _orgNodes = await fetch(); + if (!mounted) return; + setState(() {}); + } + + /// 按组织树(或扁平列表)构建带深度的选择器选项。 + /// 展示渠道(type=1)与代理商(type=2)节点但禁用选择; + /// 门店(type=3)可选;平台(type=0)与设备分组(type=4)不展示。 + List _buildOrgPickerOptions(List nodes) { + final hasTree = nodes.any((n) => n.children.isNotEmpty); + final rawOptions = hasTree + ? _buildHierarchicalOptions(nodes) + : _buildHierarchyFromFlatList(nodes); + return rawOptions + .where((o) => _isOrgVisible(o.node.type)) + .map((o) => OrgPickerOption( + node: o.node, + depth: o.depth, + selectable: _isOrgSelectable(o.node.type), + )) + .toList(); + } + + /// 组织类型是否可见于选择器(仅渠道、代理商、门店)。 + bool _isOrgVisible(int type) => type == 1 || type == 2 || type == 3; + + /// 组织类型是否可选(仅门店可选)。 + bool _isOrgSelectable(int type) => type == 3; + + List<_OrgOption> _buildHierarchicalOptions(List nodes, + {int depth = 0}) { + final result = <_OrgOption>[]; + for (final node in nodes) { + result.add(_OrgOption(node: node, depth: depth)); + if (node.children.isNotEmpty) { + result.addAll( + _buildHierarchicalOptions(node.children, depth: depth + 1)); + } + } + return result; + } + + List<_OrgOption> _buildHierarchyFromFlatList(List nodes) { + final map = {}; + final roots = []; + for (final node in nodes) { + map[node.id] = node; + } + for (final node in nodes) { + if (node.parentId.isEmpty || !map.containsKey(node.parentId)) { + roots.add(node); + } else { + final parent = map[node.parentId]!; + map[node.parentId] = OrgNode( + id: parent.id, + name: parent.name, + type: parent.type, + parentId: parent.parentId, + children: [...parent.children, node], + address: parent.address, + contactPhone: parent.contactPhone, + activationCode: parent.activationCode, + province: parent.province, + city: parent.city, + themeId: parent.themeId, + ); + } + } + return _buildHierarchicalOptions(roots); + } + + /// 在组织树/列表中按名称查找节点(递归)。 + OrgNode? _findOrgByName(List nodes, String name) { + final target = name.trim(); + if (target.isEmpty) return null; + final targetLower = target.toLowerCase(); + for (final node in nodes) { + if (node.name.trim().toLowerCase() == targetLower) return node; + final found = _findOrgByName(node.children, name); + if (found != null) return found; + } + return null; + } + + Future _loadData() async { + setState(() { + _loading = true; + _error = null; + }); + try { + switch (_currentLevel) { + case 0: + final cats = await _service.getIntroCategoryList(page: 1, limit: 200); + _allCategories = cats; + _items = List.of(_allCategories); + break; + case 1: + if (_selectedCategoryId != null) { + _items = await _service + .getIntroSubcategoryListByCategory(_selectedCategoryId!); + } + break; + case 2: + if (_selectedSubcategoryId != null) { + _items = await _service + .getIntroDetailListBySubcategory(_selectedSubcategoryId!); + } + break; + } + } catch (e) { + _error = e is ApiException ? e.message : e.toString(); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _refresh() async => _loadData(); + + String get _appBarTitle { + final t = AppLocalizations.of(context); + switch (_currentLevel) { + case 0: + return t.introCategoryTitle; + case 1: + return '${t.introCategoryTitle} > ${_selectedCategoryName ?? ''}'; + case 2: + return '${t.introCategoryTitle} > ${_selectedCategoryName ?? ''} > ${_selectedSubcategoryName ?? ''}'; + default: + return t.introCategoryTitle; + } + } + + void _enterSubcategory(IntroCategory cat) { + setState(() { + _currentLevel = 1; + _selectedCategoryId = cat.id; + _selectedCategoryName = cat.name; + _selectedCategoryType = cat.categoryType; + }); + _loadData(); + } + + void _enterDetail(IntroSubcategory sub) { + setState(() { + _currentLevel = 2; + _selectedSubcategoryId = sub.id; + _selectedSubcategoryName = sub.title; + }); + _loadData(); + } + + bool _onWillPop() { + if (_currentLevel > 0) { + setState(() { + _currentLevel--; + if (_currentLevel == 0) { + _selectedCategoryId = null; + _selectedCategoryName = null; + _selectedCategoryType = null; + _selectedSubcategoryId = null; + _selectedSubcategoryName = null; + } else if (_currentLevel == 1) { + _selectedSubcategoryId = null; + _selectedSubcategoryName = null; + } + }); + _loadData(); + return false; + } + return true; + } + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + return PopScope( + canPop: _currentLevel == 0, + onPopInvoked: (didPop) { + if (!didPop) _onWillPop(); + }, + child: Scaffold( + appBar: AppBar( + automaticallyImplyLeading: false, + title: Text(_appBarTitle), + ), + body: Column( + children: [ + Expanded( + child: _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: Text(t.retry), + ), + ], + ), + ) + : RefreshIndicator( + onRefresh: _loadData, + child: _items.isEmpty + ? Center(child: Text(t.noData)) + : ListView.builder( + physics: + const AlwaysScrollableScrollPhysics(), + padding: + const EdgeInsets.symmetric(vertical: 4), + itemCount: _items.length, + itemBuilder: (context, index) { + switch (_currentLevel) { + case 0: + return _buildCategoryTile( + _items[index] as IntroCategory); + case 1: + return _buildSubcategoryTile( + _items[index] as IntroSubcategory); + case 2: + return _buildDetailTile( + _items[index] as IntroDetail); + default: + return const SizedBox.shrink(); + } + }, + ), + ), + ), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: _showAddForm, + child: const Icon(Icons.add), + ), + ), + ); + } + + // ========== Level 0: 大类 ========== + + Widget _buildCategoryTile(IntroCategory item) { + final t = AppLocalizations.of(context); + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: ListTile( + title: Text(item.name, + style: const TextStyle(fontWeight: FontWeight.w600)), + subtitle: Text( + '${t.introTypeLabel}:${item.categoryType.isEmpty ? '-' : item.categoryType}' + '${_storeName(item.orgId, item.orgName).isNotEmpty ? ' | ${t.introStoreLabel}:${_storeName(item.orgId, item.orgName)}' : ''}', + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + PopupMenuButton( + onSelected: (v) { + if (v == 'edit') _showCategoryForm(editing: item); + if (v == 'delete') _deleteCategory(item); + }, + itemBuilder: (_) => [ + PopupMenuItem(value: 'edit', child: Text(t.edit)), + PopupMenuItem(value: 'delete', child: Text(t.delete)), + ], + ), + ], + ), + onTap: () => _enterSubcategory(item), + ), + ); + } + + // ========== Level 1: 小类 ========== + + Widget _buildSubcategoryTile(IntroSubcategory item) { + final t = AppLocalizations.of(context); + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: ListTile( + leading: item.mediaUrl.isNotEmpty && item.mediaType == 'image' + ? ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.network( + item.mediaUrl, + width: 56, + height: 56, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const Icon(Icons.image, size: 40), + ), + ) + : const Icon(Icons.folder, size: 40), + title: Text(item.title, + style: const TextStyle(fontWeight: FontWeight.w600)), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (item.description.isNotEmpty) + Text( + item.description, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + Wrap( + spacing: 6, + children: [ + Chip( + label: Text(_statusText(item.status)), + visualDensity: VisualDensity.compact, + ), + if (item.sort > 0) + Chip( + label: Text('${t.introSortOrder}: ${item.sort}'), + visualDensity: VisualDensity.compact, + ), + ], + ), + ], + ), + trailing: PopupMenuButton( + onSelected: (v) { + if (v == 'edit') _showSubcategoryForm(editing: item); + if (v == 'delete') _deleteSubcategory(item); + }, + itemBuilder: (_) => [ + PopupMenuItem(value: 'edit', child: Text(t.edit)), + PopupMenuItem(value: 'delete', child: Text(t.delete)), + ], + ), + onTap: () => _enterDetail(item), + ), + ); + } + + // ========== Level 2: 详情 ========== + + Widget _buildDetailTile(IntroDetail item) { + final t = AppLocalizations.of(context); + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: ListTile( + leading: item.mediaUrl.isNotEmpty && item.mediaType == 'image' + ? ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.network( + item.mediaUrl, + width: 56, + height: 56, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const Icon(Icons.image, size: 40), + ), + ) + : const Icon(Icons.article, size: 40), + title: Text(item.title, + style: const TextStyle(fontWeight: FontWeight.w600)), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + if (item.description.isNotEmpty) + Text( + item.description, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Wrap( + spacing: 6, + children: [ + Chip( + label: Text(_statusText(item.status)), + visualDensity: VisualDensity.compact, + ), + if (item.sort > 0) + Chip( + label: Text('${t.introSortOrder}: ${item.sort}'), + visualDensity: VisualDensity.compact, + ), + ], + ), + ], + ), + trailing: PopupMenuButton( + onSelected: (v) { + if (v == 'edit') _showDetailForm(editing: item); + if (v == 'delete') _deleteDetail(item); + }, + itemBuilder: (_) => [ + PopupMenuItem(value: 'edit', child: Text(t.edit)), + PopupMenuItem(value: 'delete', child: Text(t.delete)), + ], + ), + ), + ); + } + + String _statusText(int status) { + final t = AppLocalizations.of(context); + return status == 1 ? t.introEnabled : t.introDisabled; + } + + /// 解析门店展示名:优先用接口返回的 orgName,缺失时按 orgId 从组织树回查。 + String _storeName(String orgId, String fallback) { + if (fallback.isNotEmpty) return fallback; + final orgMatched = _findOrgById(_orgNodes, orgId); + if (orgMatched != null) return orgMatched.name; + return ''; + } + + /// 在组织树/列表中按 ID 查找节点(递归)。 + OrgNode? _findOrgById(List nodes, String id) { + if (id.isEmpty) return null; + for (final node in nodes) { + if (node.id == id) return node; + final found = _findOrgById(node.children, id); + if (found != null) return found; + } + return null; + } + + // 与 Web 端 company-intro 上传校验保持一致。 + static const List _imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp']; + static const List _videoExts = ['mp4', 'webm', 'mov', 'm4v']; + static const int _subImageMaxBytes = 300 * 1024; // 300KB + static const int _detailImageMaxBytes = 3 * 1024 * 1024; // 3MB + static const int _detailVideoMaxBytes = 30 * 1024 * 1024; // 30MB + + /// 校验图片/视频扩展名,返回错误文案或 null。 + String? _mediaTypeError(XFile file, bool isVideo) { + final t = AppLocalizations.of(context); + final ext = file.path.split('.').last.toLowerCase(); + final allowed = isVideo ? _videoExts : _imageExts; + if (!allowed.contains(ext)) { + return isVideo ? t.introVideoTypeInvalid : t.introImageTypeInvalid; + } + return null; + } + + // ========== Add form dispatch ========== + + void _showAddForm() { + switch (_currentLevel) { + case 0: + _showCategoryForm(); + break; + case 1: + _showSubcategoryForm(); + break; + case 2: + _showDetailForm(); + break; + } + } + + // ========== Category form ========== + + void _showCategoryForm({IntroCategory? editing}) async { + final t = AppLocalizations.of(context); + // 每次打开表单都重新加载组织树,确保当前用户可见的组织都能被选择。 + await _loadOrgNodes(); + if (!mounted) return; + + final isEdit = editing != null; + final nameCtrl = TextEditingController(text: editing?.name ?? ''); + final typeCtrl = + TextEditingController(text: editing?.categoryType ?? ''); + String orgId = editing?.orgId ?? ''; + String orgName = editing?.orgName ?? ''; + + // 若后端未返回 orgId,仅返回了 orgName,尝试在组织树中按名称匹配组织 ID。 + if (orgId.isEmpty && orgName.isNotEmpty) { + final matched = _findOrgByName(_orgNodes, orgName); + if (matched != null) orgId = matched.id; + } + + final orgOptions = _buildOrgPickerOptions(_orgNodes); + if (orgOptions.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.introStoreLoadFailed)), + ); + return; + } + + // 编辑时若当前 category 所属组织不在可见树中,补入选项,保证能选中并高亮。 + if (isEdit && orgId.isNotEmpty && + !orgOptions.any((o) => o.node.id == orgId)) { + orgOptions.add(OrgPickerOption( + node: OrgNode( + id: orgId, + name: orgName.isNotEmpty ? orgName : orgId, + type: 3, + parentId: '', + address: '', + contactPhone: '', + activationCode: '', + ), + depth: 0, + selectable: true, + )); + } + + 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.stretch, + children: [ + Text(isEdit ? t.introCategoryEdit : t.introCategoryAdd, + style: Theme.of(ctx).textTheme.titleMedium), + const SizedBox(height: 16), + // 所属门店组织选择器:树形层级、可搜索、编辑态自动展开并高亮当前节点。 + OrgPickerFormField( + value: orgId.isEmpty ? null : orgId, + labelText: t.introCategoryStore, + options: orgOptions, + searchHint: t.searchOrgHint, + emptyHint: t.noMatchingOrg, + autoOpen: isEdit, + onChanged: (v) { + setSheet(() { + orgId = v ?? ''; + orgName = orgOptions + .firstWhere( + (o) => o.node.id == orgId, + orElse: () => OrgPickerOption( + node: OrgNode( + id: orgId, + name: orgId, + type: 3, + parentId: '', + address: '', + contactPhone: '', + activationCode: '', + ), + ), + ) + .node + .name; + }); + }, + ), + const SizedBox(height: 12), + TextField( + controller: nameCtrl, + decoration: InputDecoration( + labelText: t.introCategoryName, + border: const OutlineInputBorder()), + ), + const SizedBox(height: 12), + TextField( + controller: typeCtrl, + decoration: InputDecoration( + labelText: t.introCategoryTypeCode, + border: const OutlineInputBorder()), + ), + const SizedBox(height: 16), + FilledButton( + onPressed: () async { + final name = nameCtrl.text.trim(); + final categoryType = typeCtrl.text.trim(); + if (orgId.isEmpty) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar(content: Text(t.introStoreRequired)), + ); + return; + } + if (name.isEmpty) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar(content: Text(t.introCategoryNameRequired)), + ); + return; + } + if (categoryType.isEmpty) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar(content: Text(t.introCategoryTypeRequired)), + ); + return; + } + if (RegExp(r'[\u4e00-\u9fff]').hasMatch(categoryType)) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar(content: Text(t.introCategoryTypeNoChinese)), + ); + return; + } + final data = { + if (isEdit) 'id': editing!.id, + 'orgId': int.tryParse(orgId) ?? orgId, + 'name': name, + 'categoryType': categoryType, + }; + try { + await _service.saveIntroCategory(data); + if (!ctx.mounted) return; + Navigator.pop(ctx); + _loadData(); + 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(formatApiError(e))), + ); + } + }, + child: Text(t.save), + ), + ], + ), + ), + ), + ), + ), + ); + } + + // ========== Subcategory form ========== + + void _showSubcategoryForm({IntroSubcategory? editing}) { + final t = AppLocalizations.of(context); + final isEdit = editing != null; + final titleCtrl = TextEditingController(text: editing?.title ?? ''); + final descCtrl = TextEditingController(text: editing?.description ?? ''); + final mediaUrlCtrl = + TextEditingController(text: editing?.mediaUrl ?? ''); + final sortCtrl = TextEditingController( + text: (editing?.sort ?? 0).toString()); + final categoryTypeCtrl = TextEditingController( + text: editing?.categoryType ?? _selectedCategoryType ?? ''); + int statusValue = editing?.status ?? 1; + XFile? pickedFile; + bool uploading = false; + + 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.stretch, + children: [ + Text(isEdit ? t.introSubcategoryEdit : t.introSubcategoryAdd, + style: Theme.of(ctx).textTheme.titleMedium), + const SizedBox(height: 16), + TextField( + controller: titleCtrl, + decoration: InputDecoration( + labelText: t.introTitle, + border: const OutlineInputBorder()), + ), + const SizedBox(height: 12), + TextField( + controller: descCtrl, + maxLines: 2, + decoration: InputDecoration( + labelText: t.introDescription, + border: const OutlineInputBorder()), + ), + const SizedBox(height: 12), + TextField( + controller: categoryTypeCtrl, + decoration: InputDecoration( + labelText: t.introCategoryTypeCode, + border: const OutlineInputBorder()), + ), + const SizedBox(height: 12), + TextField( + controller: sortCtrl, + decoration: InputDecoration( + labelText: t.introSortOrder, + border: const OutlineInputBorder()), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + const SizedBox(height: 12), + // 小类素材固定为图片,与 Web 端一致(Web 端无媒体类型选择,始终 image)。 + Text( + t.introSubImageHint, + style: Theme.of(ctx) + .textTheme + .bodySmall + ?.copyWith( + color: Theme.of(ctx).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + if (mediaUrlCtrl.text.isNotEmpty || pickedFile != null) + _buildImagePreview( + ctx: ctx, + url: mediaUrlCtrl.text, + pickedFile: pickedFile, + uploading: uploading, + onClear: () => setSheet(() { + mediaUrlCtrl.clear(); + pickedFile = null; + }), + ), + const SizedBox(height: 12), + TextField( + controller: mediaUrlCtrl, + decoration: InputDecoration( + labelText: t.introMediaUrl, + border: const OutlineInputBorder()), + ), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: uploading + ? null + : () async { + final file = await _imagePicker.pickImage( + source: ImageSource.gallery); + if (file == null) return; + final typeErr = _mediaTypeError(file, false); + if (typeErr != null) { + if (ctx.mounted) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar(content: Text(typeErr)), + ); + } + return; + } + final size = await file.length(); + if (size > _subImageMaxBytes) { + if (ctx.mounted) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar(content: Text(t.introFileTooLarge)), + ); + } + return; + } + setSheet(() { + pickedFile = file; + uploading = true; + }); + try { + final url = await _uploadService.uploadImage(file); + setSheet(() { + mediaUrlCtrl.text = url; + uploading = false; + }); + } catch (e) { + setSheet(() => uploading = false); + if (ctx.mounted) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar( + content: Text(e is ApiException + ? e.message + : t.introUploadFailed)), + ); + } + } + }, + icon: uploading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.upload), + label: Text(uploading ? t.introUploading : t.introUploadImage), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + value: statusValue, + decoration: InputDecoration( + labelText: t.status, border: const OutlineInputBorder()), + items: [ + DropdownMenuItem(value: 1, child: Text(t.introEnabled)), + DropdownMenuItem(value: 0, child: Text(t.introDisabled)), + ], + onChanged: (v) { + if (v != null) setSheet(() => statusValue = v); + }, + ), + const SizedBox(height: 16), + FilledButton( + onPressed: () async { + final title = titleCtrl.text.trim(); + final categoryType = categoryTypeCtrl.text.trim(); + final mediaUrl = mediaUrlCtrl.text.trim(); + if (title.isEmpty) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar(content: Text(t.introTitleRequired)), + ); + return; + } + if (categoryType.isEmpty) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar(content: Text(t.introCategoryTypeRequired)), + ); + return; + } + if (RegExp(r'[\u4e00-\u9fff]').hasMatch(categoryType)) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar(content: Text(t.introCategoryTypeNoChinese)), + ); + return; + } + if (mediaUrl.isEmpty) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar(content: Text(t.introMediaUrlRequired)), + ); + return; + } + final data = { + if (isEdit) 'id': editing!.id, + 'categoryId': + int.tryParse(_selectedCategoryId ?? '') ?? 0, + 'categoryType': categoryType, + 'title': title, + 'description': descCtrl.text.trim(), + 'mediaType': 'image', + 'mediaUrl': mediaUrl, + 'sortOrder': int.tryParse(sortCtrl.text) ?? 0, + 'status': statusValue, + }; + try { + await _service.saveIntroSubcategory(data); + if (!ctx.mounted) return; + Navigator.pop(ctx); + _loadData(); + 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(formatApiError(e))), + ); + } + }, + child: Text(t.save), + ), + ], + ), + ), + ), + ), + ), + ); + } + + // ========== Detail form ========== + + void _showDetailForm({IntroDetail? editing}) { + final t = AppLocalizations.of(context); + final isEdit = editing != null; + final titleCtrl = TextEditingController(text: editing?.title ?? ''); + final descCtrl = + TextEditingController(text: editing?.description ?? ''); + final mediaUrlCtrl = + TextEditingController(text: editing?.mediaUrl ?? ''); + final sortCtrl = TextEditingController( + text: (editing?.sort ?? 0).toString()); + String mediaType = editing?.mediaType ?? 'image'; + int statusValue = editing?.status ?? 1; + XFile? pickedFile; + bool uploading = false; + + 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.stretch, + children: [ + Text(isEdit ? t.introDetailEdit : t.introDetailAdd, + style: Theme.of(ctx).textTheme.titleMedium), + const SizedBox(height: 16), + TextField( + controller: titleCtrl, + decoration: InputDecoration( + labelText: t.introTitle, + border: const OutlineInputBorder()), + ), + const SizedBox(height: 12), + TextField( + controller: descCtrl, + maxLines: 3, + decoration: InputDecoration( + labelText: t.introDescription, + border: const OutlineInputBorder()), + ), + const SizedBox(height: 12), + TextField( + controller: sortCtrl, + decoration: InputDecoration( + labelText: t.introSortOrder, + border: const OutlineInputBorder()), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + const SizedBox(height: 12), + DropdownButtonFormField( + value: mediaType, + decoration: InputDecoration( + labelText: t.introMediaType, + border: const OutlineInputBorder()), + items: [ + DropdownMenuItem(value: 'image', child: Text(t.introImage)), + DropdownMenuItem(value: 'video', child: Text(t.introVideo)), + ], + onChanged: (v) { + if (v != null) setSheet(() => mediaType = v); + }, + ), + const SizedBox(height: 12), + Text( + '${t.introDetailImageHint},${t.introDetailVideoHint}', + style: Theme.of(ctx) + .textTheme + .bodySmall + ?.copyWith( + color: Theme.of(ctx).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + if (mediaUrlCtrl.text.isNotEmpty || pickedFile != null) + _buildImagePreview( + ctx: ctx, + url: mediaUrlCtrl.text, + pickedFile: pickedFile, + uploading: uploading, + onClear: () => setSheet(() { + mediaUrlCtrl.clear(); + pickedFile = null; + }), + ), + const SizedBox(height: 12), + TextField( + controller: mediaUrlCtrl, + decoration: InputDecoration( + labelText: t.introMediaUrl, + border: const OutlineInputBorder()), + ), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: uploading + ? null + : () async { + final isVideo = mediaType == 'video'; + final file = isVideo + ? await _imagePicker.pickVideo( + source: ImageSource.gallery) + : await _imagePicker.pickImage( + source: ImageSource.gallery); + if (file == null) return; + final typeErr = _mediaTypeError(file, isVideo); + if (typeErr != null) { + if (ctx.mounted) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar(content: Text(typeErr)), + ); + } + return; + } + final size = await file.length(); + final maxBytes = isVideo + ? _detailVideoMaxBytes + : _detailImageMaxBytes; + if (size > maxBytes) { + if (ctx.mounted) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar(content: Text(t.introFileTooLarge)), + ); + } + return; + } + setSheet(() { + pickedFile = file; + uploading = true; + }); + try { + final url = await _uploadService.uploadImage( + file, + mediaType: isVideo ? 'video' : 'image', + ); + setSheet(() { + mediaUrlCtrl.text = url; + uploading = false; + }); + } catch (e) { + setSheet(() => uploading = false); + if (ctx.mounted) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar( + content: Text(e is ApiException + ? e.message + : t.introUploadFailed)), + ); + } + } + }, + icon: uploading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.upload), + label: Text(uploading ? t.introUploading : t.introUploadImage), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + value: statusValue, + decoration: InputDecoration( + labelText: t.status, border: const OutlineInputBorder()), + items: [ + DropdownMenuItem(value: 1, child: Text(t.introEnabled)), + DropdownMenuItem(value: 0, child: Text(t.introDisabled)), + ], + onChanged: (v) { + if (v != null) setSheet(() => statusValue = v); + }, + ), + const SizedBox(height: 16), + FilledButton( + onPressed: () async { + final title = titleCtrl.text.trim(); + final mediaUrl = mediaUrlCtrl.text.trim(); + if (title.isEmpty) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar(content: Text(t.introTitleRequired)), + ); + return; + } + if (mediaUrl.isEmpty) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar(content: Text(t.introMediaUrlRequired)), + ); + return; + } + final data = { + if (isEdit) 'id': editing!.id, + 'subcategoryId': + int.tryParse(_selectedSubcategoryId ?? '') ?? 0, + 'title': title, + 'description': descCtrl.text.trim(), + 'mediaType': mediaType, + 'mediaUrl': mediaUrl, + 'sortOrder': int.tryParse(sortCtrl.text) ?? 0, + 'status': statusValue, + }; + try { + await _service.saveIntroDetail(data); + if (!ctx.mounted) return; + Navigator.pop(ctx); + _loadData(); + 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(formatApiError(e))), + ); + } + }, + child: Text(t.save), + ), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildImagePreview({ + required BuildContext ctx, + required String url, + required XFile? pickedFile, + required bool uploading, + required VoidCallback onClear, + }) { + final t = AppLocalizations.of(ctx); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t.introImagePreview, + style: Theme.of(ctx).textTheme.labelLarge), + const SizedBox(height: 6), + Stack( + alignment: Alignment.center, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: pickedFile != null + ? Image.file( + File(pickedFile.path), + width: double.infinity, + height: 160, + fit: BoxFit.cover, + ) + : url.isNotEmpty + ? Image.network( + url, + width: double.infinity, + height: 160, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container( + width: double.infinity, + height: 160, + color: Theme.of(ctx).colorScheme.surfaceContainerHighest, + child: const Icon(Icons.broken_image, size: 48), + ), + ) + : Container( + width: double.infinity, + height: 160, + color: Theme.of(ctx).colorScheme.surfaceContainerHighest, + child: const Icon(Icons.image, size: 48), + ), + ), + if (uploading) + Container( + width: double.infinity, + height: 160, + color: Colors.black54, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 8), + Text(t.introUploading, + style: const TextStyle(color: Colors.white)), + ], + ), + ), + Positioned( + top: 4, + right: 4, + child: IconButton.filledTonal( + onPressed: onClear, + icon: const Icon(Icons.clear), + style: IconButton.styleFrom( + minimumSize: const Size(32, 32), + ), + ), + ), + ], + ), + ], + ); + } + + // ========== Delete methods ========== + + Future _deleteCategory(IntroCategory item) async { + final t = AppLocalizations.of(context); + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(t.confirmDelete), + content: Text(t.deleteConfirmContent(item.name)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(t.cancel)), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(t.delete)), + ], + ), + ); + if (confirmed != true) return; + try { + await _service.deleteIntroCategory([int.tryParse(item.id) ?? 0]); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.deleted))); + _loadData(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(formatApiError(e))), + ); + } + } + + Future _deleteSubcategory(IntroSubcategory item) async { + final t = AppLocalizations.of(context); + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(t.confirmDelete), + content: Text(t.deleteConfirmContent(item.title)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(t.cancel)), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(t.delete)), + ], + ), + ); + if (confirmed != true) return; + try { + await _service.deleteIntroSubcategory([int.tryParse(item.id) ?? 0]); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.deleted))); + _loadData(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(formatApiError(e))), + ); + } + } + + Future _deleteDetail(IntroDetail item) async { + final t = AppLocalizations.of(context); + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(t.confirmDelete), + content: Text(t.deleteConfirmContent(item.title)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(t.cancel)), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(t.delete)), + ], + ), + ); + if (confirmed != true) return; + try { + await _service.deleteIntroDetail([int.tryParse(item.id) ?? 0]); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.deleted))); + _loadData(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(formatApiError(e))), + ); + } + } +} diff --git a/lib/screens/admin/intro/intro_store_content_page.dart b/lib/screens/admin/intro/intro_store_content_page.dart new file mode 100644 index 0000000..6b4521a --- /dev/null +++ b/lib/screens/admin/intro/intro_store_content_page.dart @@ -0,0 +1,1144 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:image_picker/image_picker.dart'; + +import '../../../models/intro_category.dart'; +import '../../../models/store_info.dart'; +import '../../../services/api_client.dart'; +import '../../../services/intro_service.dart'; +import '../../../services/upload_service.dart'; +import '../../../utils/i10n.dart'; + +/// 公司介绍内容页。 +/// +/// 展示单个门店下的公司介绍数据,按「内容类型(categoryType)」分类, +/// 每个类型下再按「小类 -> 详情」层级展开。 +class IntroStoreContentPage extends ConsumerStatefulWidget { + const IntroStoreContentPage({ + super.key, + required this.storeId, + this.store, + }); + + /// 门店组织 ID。 + final String storeId; + + /// 门店基本信息(可选,由列表页传入以快速展示)。 + final StoreInfo? store; + + @override + ConsumerState createState() => + _IntroStoreContentPageState(); +} + +class _IntroStoreContentPageState extends ConsumerState { + final _service = IntroService(); + final _uploadService = UploadService(); + final _imagePicker = ImagePicker(); + + StoreInfo? _store; + List _categories = []; + final Map> _subcategories = {}; + final Map> _details = {}; + + bool _loading = false; + String? _error; + + // 展开状态:分类 ID / 小类 ID。 + final Set _expandedCategories = {}; + final Set _expandedSubcategories = {}; + + @override + void initState() { + super.initState(); + _store = widget.store; + _loadData(); + } + + Future _loadData() async { + setState(() { + _loading = true; + _error = null; + }); + try { + // 1) 加载当前门店下的大类(与 Web 端一致走 /list 并按 orgId 服务端过滤)。 + final storeOrgId = int.tryParse(widget.storeId) ?? widget.storeId; + _categories = await _service.getIntroCategoryList( + page: 1, + limit: 500, + orgId: storeOrgId, + ); + _categories.sort((a, b) => a.categoryType.compareTo(b.categoryType)); + + // 2) 并行加载每个大类下的小类。 + _subcategories.clear(); + await Future.wait( + _categories.map((cat) => _loadSubcategories(cat.id)), + ); + + // 3) 并行加载每个小类下的详情。 + _details.clear(); + final allSubIds = _subcategories.values.expand((list) => list).toList(); + await Future.wait( + allSubIds.map((sub) => _loadDetails(sub.id)), + ); + + if (!mounted) return; + setState(() => _loading = false); + } catch (e) { + if (!mounted) return; + setState(() { + _error = formatApiError(e, fallback: '数据加载失败,请稍后重试'); + _loading = false; + }); + } + } + + Future _loadSubcategories(String categoryId) async { + try { + final list = await _service.getIntroSubcategoryListByCategory(categoryId); + _subcategories[categoryId] = list; + } catch (_) { + _subcategories[categoryId] = []; + } + } + + Future _loadDetails(String subcategoryId) async { + try { + final list = await _service.getIntroDetailListBySubcategory(subcategoryId); + _details[subcategoryId] = list; + } catch (_) { + _details[subcategoryId] = []; + } + } + + void _toggleCategory(String categoryId) { + setState(() { + if (_expandedCategories.contains(categoryId)) { + _expandedCategories.remove(categoryId); + } else { + _expandedCategories.add(categoryId); + } + }); + } + + void _toggleSubcategory(String subcategoryId) { + setState(() { + if (_expandedSubcategories.contains(subcategoryId)) { + _expandedSubcategories.remove(subcategoryId); + } else { + _expandedSubcategories.add(subcategoryId); + } + }); + } + + /// 按 categoryType 把大类分组,相同类型合并为一个可展开的分组。 + Map> _groupCategoriesByType() { + final groups = >{}; + for (final cat in _categories) { + groups.putIfAbsent(cat.categoryType, () => []).add(cat); + } + return groups; + } + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + final storeName = _store?.name ?? t.unknown; + return Scaffold( + appBar: AppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t.introCategoryTitle), + Text( + storeName, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + body: _buildBody(), + floatingActionButton: FloatingActionButton( + onPressed: _showAddCategoryForm, + child: const Icon(Icons.add), + ), + ); + } + + Widget _buildBody() { + final t = AppLocalizations.of(context); + if (_loading && _categories.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + if (_error != null && _categories.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 (_categories.isEmpty) { + return RefreshIndicator( + onRefresh: _loadData, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + const SizedBox(height: 200), + Center(child: Text(t.noData)), + ], + ), + ); + } + + final groups = _groupCategoriesByType(); + final typeKeys = groups.keys.toList()..sort(); + + return RefreshIndicator( + onRefresh: _loadData, + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: typeKeys.length, + itemBuilder: (context, index) { + final type = typeKeys[index]; + final cats = groups[type]!; + return _buildTypeSection(type, cats); + }, + ), + ); + } + + /// 内容类型分组卡片。 + Widget _buildTypeSection(String type, List categories) { + final theme = Theme.of(context); + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: ExpansionTile( + title: Text( + type.isEmpty ? '未分类' : type, + style: theme.textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.bold), + ), + subtitle: Text( + '${categories.length} 个分类', + style: theme.textTheme.bodySmall, + ), + initiallyExpanded: true, + children: categories.map((cat) => _buildCategoryTile(cat)).toList(), + ), + ); + } + + /// 单个大类:可展开查看其下小类。 + Widget _buildCategoryTile(IntroCategory category) { + final t = AppLocalizations.of(context); + final subs = _subcategories[category.id] ?? []; + final isExpanded = _expandedCategories.contains(category.id); + + return Column( + children: [ + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + leading: CircleAvatar( + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + child: Text( + category.name.isNotEmpty ? category.name.substring(0, 1) : '-', + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer, + fontWeight: FontWeight.bold, + ), + ), + ), + title: Text(category.name), + subtitle: Text('${t.introTypeLabel}:${category.categoryType}'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.add, size: 20), + tooltip: t.introSubcategoryAdd, + onPressed: () => _showSubcategoryForm(categoryId: category.id), + ), + IconButton( + icon: Icon( + isExpanded ? Icons.expand_less : Icons.expand_more, + size: 20, + ), + onPressed: () => _toggleCategory(category.id), + ), + ], + ), + onTap: () => _toggleCategory(category.id), + ), + if (isExpanded) + Padding( + padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8), + child: subs.isEmpty + ? ListTile( + dense: true, + title: Text( + t.noData, + style: TextStyle( + color: Theme.of(context).colorScheme.outline, + ), + ), + ) + : Column( + children: subs + .map((sub) => _buildSubcategoryTile(category, sub)) + .toList(), + ), + ), + ], + ); + } + + /// 单个小类:可展开查看其下详情。 + Widget _buildSubcategoryTile( + IntroCategory category, + IntroSubcategory sub, + ) { + final t = AppLocalizations.of(context); + final details = _details[sub.id] ?? []; + final isExpanded = _expandedSubcategories.contains(sub.id); + + return Card( + margin: const EdgeInsets.symmetric(vertical: 4), + child: Column( + children: [ + ListTile( + dense: true, + leading: sub.mediaUrl.isNotEmpty && sub.mediaType == 'image' + ? ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.network( + sub.mediaUrl, + width: 40, + height: 40, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => + const Icon(Icons.image, size: 32), + ), + ) + : const Icon(Icons.folder, size: 32), + title: Text( + sub.title, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + subtitle: Text( + sub.description.isNotEmpty + ? sub.description + : '${details.length} 个内容', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.add, size: 18), + tooltip: t.introDetailAdd, + onPressed: () => _showDetailForm( + categoryId: category.id, + subcategoryId: sub.id, + ), + ), + Icon( + isExpanded ? Icons.expand_less : Icons.expand_more, + size: 18, + ), + ], + ), + onTap: () => _toggleSubcategory(sub.id), + ), + if (isExpanded) + Padding( + padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8), + child: details.isEmpty + ? ListTile( + dense: true, + title: Text( + t.noData, + style: TextStyle( + color: Theme.of(context).colorScheme.outline, + ), + ), + ) + : Column( + children: details + .map((d) => _buildDetailTile(category, sub, d)) + .toList(), + ), + ), + ], + ), + ); + } + + /// 单个详情内容项。 + Widget _buildDetailTile( + IntroCategory category, + IntroSubcategory sub, + IntroDetail detail, + ) { + final t = AppLocalizations.of(context); + return ListTile( + dense: true, + leading: detail.mediaUrl.isNotEmpty && detail.mediaType == 'image' + ? ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.network( + detail.mediaUrl, + width: 40, + height: 40, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const Icon(Icons.image, size: 32), + ), + ) + : const Icon(Icons.article, size: 32), + title: Text(detail.title), + subtitle: detail.description.isNotEmpty + ? Text( + detail.description, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + : null, + trailing: PopupMenuButton( + onSelected: (v) { + if (v == 'edit') { + _showDetailForm( + categoryId: category.id, + subcategoryId: sub.id, + editing: detail, + ); + } else if (v == 'delete') { + _deleteDetail(detail); + } + }, + itemBuilder: (_) => [ + PopupMenuItem(value: 'edit', child: Text(t.edit)), + PopupMenuItem(value: 'delete', child: Text(t.delete)), + ], + ), + ); + } + + // ========== 表单入口 ========== + + void _showAddCategoryForm() { + _showCategoryForm(); + } + + void _showCategoryForm({IntroCategory? editing}) { + final t = AppLocalizations.of(context); + final isEdit = editing != null; + final nameCtrl = TextEditingController(text: editing?.name ?? ''); + final typeCtrl = + TextEditingController(text: editing?.categoryType ?? ''); + + 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.stretch, + children: [ + Text( + isEdit ? t.introCategoryEdit : t.introCategoryAdd, + style: Theme.of(ctx).textTheme.titleMedium, + ), + const SizedBox(height: 16), + TextField( + controller: nameCtrl, + decoration: InputDecoration( + labelText: t.introCategoryName, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: typeCtrl, + decoration: InputDecoration( + labelText: t.introCategoryTypeCode, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + FilledButton( + onPressed: () async { + final name = nameCtrl.text.trim(); + final categoryType = typeCtrl.text.trim(); + if (name.isEmpty) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar(content: Text(t.introCategoryNameRequired)), + ); + return; + } + if (categoryType.isEmpty) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar(content: Text(t.introCategoryTypeRequired)), + ); + return; + } + if (RegExp(r'[\u4e00-\u9fff]').hasMatch(categoryType)) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar(content: Text(t.introCategoryTypeNoChinese)), + ); + return; + } + final data = { + if (isEdit) 'id': editing.id, + 'orgId': int.tryParse(widget.storeId) ?? widget.storeId, + 'name': name, + 'categoryType': categoryType, + }; + try { + await _service.saveIntroCategory(data); + if (!ctx.mounted) return; + Navigator.pop(ctx); + _loadData(); + 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(formatApiError(e))), + ); + } + }, + child: Text(t.save), + ), + ], + ), + ), + ), + ), + ), + ); + } + + void _showSubcategoryForm({ + required String categoryId, + IntroSubcategory? editing, + }) { + final t = AppLocalizations.of(context); + final isEdit = editing != null; + final titleCtrl = TextEditingController(text: editing?.title ?? ''); + final descCtrl = TextEditingController(text: editing?.description ?? ''); + final mediaUrlCtrl = TextEditingController(text: editing?.mediaUrl ?? ''); + final sortCtrl = TextEditingController( + text: (editing?.sort ?? 0).toString()); + // 小类的 categoryType 预填父大类值,与 Web 端一致;为空时不下发,避免覆盖后端默认值。 + String parentCategoryType = ''; + for (final c in _categories) { + if (c.id == categoryId) { + parentCategoryType = c.categoryType; + break; + } + } + final categoryTypeCtrl = TextEditingController( + text: editing?.categoryType ?? parentCategoryType); + int statusValue = editing?.status ?? 1; + XFile? pickedFile; + bool uploading = false; + + 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.stretch, + children: [ + Text( + isEdit ? t.introSubcategoryEdit : t.introSubcategoryAdd, + style: Theme.of(ctx).textTheme.titleMedium, + ), + const SizedBox(height: 16), + TextField( + controller: titleCtrl, + decoration: InputDecoration( + labelText: t.introTitle, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: descCtrl, + maxLines: 2, + decoration: InputDecoration( + labelText: t.introDescription, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: categoryTypeCtrl, + decoration: InputDecoration( + labelText: t.introCategoryTypeCode, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: sortCtrl, + decoration: InputDecoration( + labelText: t.introSortOrder, + border: const OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + const SizedBox(height: 12), + if (mediaUrlCtrl.text.isNotEmpty || pickedFile != null) + _buildImagePreview( + ctx: ctx, + url: mediaUrlCtrl.text, + pickedFile: pickedFile, + uploading: uploading, + onClear: () => setSheet(() { + mediaUrlCtrl.clear(); + pickedFile = null; + }), + ), + const SizedBox(height: 12), + TextField( + controller: mediaUrlCtrl, + decoration: InputDecoration( + labelText: t.introMediaUrl, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: uploading + ? null + : () async { + final file = await _imagePicker.pickImage( + source: ImageSource.gallery); + if (file == null) return; + final typeErr = _mediaTypeError(file, false, ctx); + if (typeErr != null) { + if (ctx.mounted) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar(content: Text(typeErr)), + ); + } + return; + } + final size = await file.length(); + if (size > _subImageMaxBytes) { + if (ctx.mounted) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar(content: Text(t.introFileTooLarge)), + ); + } + return; + } + setSheet(() { + pickedFile = file; + uploading = true; + }); + try { + final url = await _uploadService.uploadImage(file); + setSheet(() { + mediaUrlCtrl.text = url; + uploading = false; + }); + } catch (e) { + setSheet(() => uploading = false); + if (ctx.mounted) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar( + content: Text(e is ApiException + ? e.message + : t.introUploadFailed)), + ); + } + } + }, + icon: uploading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.upload), + label: Text(uploading ? t.introUploading : t.introUploadImage), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + value: statusValue, + decoration: InputDecoration( + labelText: t.status, + border: const OutlineInputBorder(), + ), + items: [ + DropdownMenuItem(value: 1, child: Text(t.introEnabled)), + DropdownMenuItem(value: 0, child: Text(t.introDisabled)), + ], + onChanged: (v) { + if (v != null) setSheet(() => statusValue = v); + }, + ), + const SizedBox(height: 16), + FilledButton( + onPressed: () async { + final title = titleCtrl.text.trim(); + final categoryType = categoryTypeCtrl.text.trim(); + final mediaUrl = mediaUrlCtrl.text.trim(); + if (title.isEmpty) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar(content: Text(t.introTitleRequired)), + ); + return; + } + if (categoryType.isEmpty) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar(content: Text(t.introCategoryTypeRequired)), + ); + return; + } + if (RegExp(r'[\u4e00-\u9fff]').hasMatch(categoryType)) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar(content: Text(t.introCategoryTypeNoChinese)), + ); + return; + } + if (mediaUrl.isEmpty) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar(content: Text(t.introMediaUrlRequired)), + ); + return; + } + final data = { + if (isEdit) 'id': editing.id, + 'categoryId': int.tryParse(categoryId) ?? 0, + if (categoryType.isNotEmpty) + 'categoryType': categoryType, + 'title': title, + 'description': descCtrl.text.trim(), + 'mediaType': 'image', + 'mediaUrl': mediaUrl, + 'sortOrder': int.tryParse(sortCtrl.text) ?? 0, + 'status': statusValue, + }; + try { + await _service.saveIntroSubcategory(data); + if (!ctx.mounted) return; + Navigator.pop(ctx); + _loadData(); + 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(formatApiError(e))), + ); + } + }, + child: Text(t.save), + ), + ], + ), + ), + ), + ), + ), + ); + } + + void _showDetailForm({ + required String categoryId, + required String subcategoryId, + IntroDetail? editing, + }) { + final t = AppLocalizations.of(context); + final isEdit = editing != null; + final titleCtrl = TextEditingController(text: editing?.title ?? ''); + final descCtrl = TextEditingController(text: editing?.description ?? ''); + final mediaUrlCtrl = TextEditingController(text: editing?.mediaUrl ?? ''); + final sortCtrl = TextEditingController( + text: (editing?.sort ?? 0).toString()); + String mediaType = editing?.mediaType ?? 'image'; + int statusValue = editing?.status ?? 1; + XFile? pickedFile; + bool uploading = false; + + 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.stretch, + children: [ + Text( + isEdit ? t.introDetailEdit : t.introDetailAdd, + style: Theme.of(ctx).textTheme.titleMedium, + ), + const SizedBox(height: 16), + TextField( + controller: titleCtrl, + decoration: InputDecoration( + labelText: t.introTitle, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: descCtrl, + maxLines: 3, + decoration: InputDecoration( + labelText: t.introDescription, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: sortCtrl, + decoration: InputDecoration( + labelText: t.introSortOrder, + border: const OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + const SizedBox(height: 12), + DropdownButtonFormField( + value: mediaType, + decoration: InputDecoration( + labelText: t.introMediaType, + border: const OutlineInputBorder(), + ), + items: [ + DropdownMenuItem(value: 'image', child: Text(t.introImage)), + DropdownMenuItem(value: 'video', child: Text(t.introVideo)), + ], + onChanged: (v) { + if (v != null) setSheet(() => mediaType = v); + }, + ), + const SizedBox(height: 12), + if (mediaUrlCtrl.text.isNotEmpty || pickedFile != null) + _buildImagePreview( + ctx: ctx, + url: mediaUrlCtrl.text, + pickedFile: pickedFile, + uploading: uploading, + onClear: () => setSheet(() { + mediaUrlCtrl.clear(); + pickedFile = null; + }), + ), + const SizedBox(height: 12), + TextField( + controller: mediaUrlCtrl, + decoration: InputDecoration( + labelText: t.introMediaUrl, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: uploading + ? null + : () async { + final isVideo = mediaType == 'video'; + final file = isVideo + ? await _imagePicker.pickVideo( + source: ImageSource.gallery) + : await _imagePicker.pickImage( + source: ImageSource.gallery); + if (file == null) return; + final typeErr = _mediaTypeError(file, isVideo, ctx); + if (typeErr != null) { + if (ctx.mounted) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar(content: Text(typeErr)), + ); + } + return; + } + final size = await file.length(); + final maxBytes = isVideo + ? _detailVideoMaxBytes + : _detailImageMaxBytes; + if (size > maxBytes) { + if (ctx.mounted) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar(content: Text(t.introFileTooLarge)), + ); + } + return; + } + setSheet(() { + pickedFile = file; + uploading = true; + }); + try { + final url = await _uploadService.uploadImage( + file, + mediaType: isVideo ? 'video' : 'image', + ); + setSheet(() { + mediaUrlCtrl.text = url; + uploading = false; + }); + } catch (e) { + setSheet(() => uploading = false); + if (ctx.mounted) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar( + content: Text(e is ApiException + ? e.message + : t.introUploadFailed)), + ); + } + } + }, + icon: uploading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.upload), + label: Text(uploading ? t.introUploading : t.introUploadImage), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + value: statusValue, + decoration: InputDecoration( + labelText: t.status, + border: const OutlineInputBorder(), + ), + items: [ + DropdownMenuItem(value: 1, child: Text(t.introEnabled)), + DropdownMenuItem(value: 0, child: Text(t.introDisabled)), + ], + onChanged: (v) { + if (v != null) setSheet(() => statusValue = v); + }, + ), + const SizedBox(height: 16), + FilledButton( + onPressed: () async { + final title = titleCtrl.text.trim(); + final mediaUrl = mediaUrlCtrl.text.trim(); + if (title.isEmpty) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar(content: Text(t.introTitleRequired)), + ); + return; + } + if (mediaUrl.isEmpty) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar(content: Text(t.introMediaUrlRequired)), + ); + return; + } + final data = { + if (isEdit) 'id': editing.id, + 'subcategoryId': int.tryParse(subcategoryId) ?? 0, + 'title': title, + 'description': descCtrl.text.trim(), + 'mediaType': mediaType, + 'mediaUrl': mediaUrl, + 'sortOrder': int.tryParse(sortCtrl.text) ?? 0, + 'status': statusValue, + }; + try { + await _service.saveIntroDetail(data); + if (!ctx.mounted) return; + Navigator.pop(ctx); + _loadData(); + 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(formatApiError(e))), + ); + } + }, + child: Text(t.save), + ), + ], + ), + ), + ), + ), + ), + ); + } + + Future _deleteDetail(IntroDetail item) async { + final t = AppLocalizations.of(context); + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(t.confirmDelete), + content: Text(t.deleteConfirmContent(item.title)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(t.cancel)), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(t.delete)), + ], + ), + ); + if (confirmed != true) return; + try { + await _service.deleteIntroDetail([int.tryParse(item.id) ?? 0]); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.deleted))); + _loadData(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(formatApiError(e))), + ); + } + } + + // 与 Web 端 company-intro 上传校验保持一致。 + static const List _imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp']; + static const List _videoExts = ['mp4', 'webm', 'mov', 'm4v']; + static const int _subImageMaxBytes = 300 * 1024; // 300KB + static const int _detailImageMaxBytes = 3 * 1024 * 1024; // 3MB + static const int _detailVideoMaxBytes = 30 * 1024 * 1024; // 30MB + + String? _mediaTypeError(XFile file, bool isVideo, BuildContext ctx) { + final t = AppLocalizations.of(ctx); + final ext = file.path.split('.').last.toLowerCase(); + final allowed = isVideo ? _videoExts : _imageExts; + if (!allowed.contains(ext)) { + return isVideo ? t.introVideoTypeInvalid : t.introImageTypeInvalid; + } + return null; + } + + Widget _buildImagePreview({ + required BuildContext ctx, + required String url, + required XFile? pickedFile, + required bool uploading, + required VoidCallback onClear, + }) { + final t = AppLocalizations.of(ctx); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t.introImagePreview, style: Theme.of(ctx).textTheme.labelLarge), + const SizedBox(height: 6), + Stack( + alignment: Alignment.center, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: pickedFile != null + ? Image.file( + File(pickedFile.path), + width: double.infinity, + height: 160, + fit: BoxFit.cover, + ) + : url.isNotEmpty + ? Image.network( + url, + width: double.infinity, + height: 160, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container( + width: double.infinity, + height: 160, + color: Theme.of(ctx).colorScheme.surfaceContainerHighest, + child: const Icon(Icons.broken_image, size: 48), + ), + ) + : Container( + width: double.infinity, + height: 160, + color: Theme.of(ctx).colorScheme.surfaceContainerHighest, + child: const Icon(Icons.image, size: 48), + ), + ), + if (uploading) + Container( + width: double.infinity, + height: 160, + color: Colors.black54, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 8), + Text(t.introUploading, + style: const TextStyle(color: Colors.white)), + ], + ), + ), + Positioned( + top: 4, + right: 4, + child: IconButton.filledTonal( + onPressed: onClear, + icon: const Icon(Icons.clear), + style: IconButton.styleFrom( + minimumSize: const Size(32, 32), + ), + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/screens/admin/intro/intro_store_list_page.dart b/lib/screens/admin/intro/intro_store_list_page.dart new file mode 100644 index 0000000..f3d2a2b --- /dev/null +++ b/lib/screens/admin/intro/intro_store_list_page.dart @@ -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 createState() => _IntroStoreListPageState(); +} + +class _IntroStoreListPageState extends ConsumerState { + final _orgService = OrgService(); + final _searchController = TextEditingController(); + + List _allStores = []; + List _stores = []; + bool _loading = false; + String? _error; + String _keyword = ''; + + @override + void initState() { + super.initState(); + _loadData(); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future _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))), + ], + ), + ); + } +} diff --git a/lib/screens/admin/marquee/marquee_management_page.dart b/lib/screens/admin/marquee/marquee_management_page.dart new file mode 100644 index 0000000..a7df615 --- /dev/null +++ b/lib/screens/admin/marquee/marquee_management_page.dart @@ -0,0 +1,1489 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../models/marquee_config.dart'; +import '../../../models/marquee_message.dart'; +import '../../../models/org_node.dart'; +import '../../../models/store_info.dart'; +import '../../../services/api_client.dart'; +import '../../../services/marquee_service.dart'; +import '../../../utils/i10n.dart'; +import '../../../utils/session_org_scope.dart'; +import '../../../widgets/org_picker_sheet.dart'; + +/// 滚动字幕管理页。 +/// +/// 支持按门店查看、新增、编辑、删除、查询、下发/取消下发滚动字幕。 +class MarqueeManagementPage extends ConsumerStatefulWidget { + const MarqueeManagementPage({super.key}); + + @override + ConsumerState createState() => + _MarqueeManagementPageState(); +} + +class _MarqueeManagementPageState extends ConsumerState { + final MarqueeService _service = MarqueeService(); + final _searchController = TextEditingController(); + + List _items = []; + List _stores = []; + bool _loading = false; + String? _error; + String _keyword = ''; + String? _selectedStoreId; + + AppLocalizations get t => AppLocalizations.of(context); + + @override + void initState() { + super.initState(); + _init(); + } + + /// 初始化:先加载门店,受限账号默认选中第一家门店(避免「全部门店」视图 + /// 泄露跨组织字幕),再加载字幕列表。 + Future _init() async { + await _loadStores(); + if (SessionOrgScope.instance.enabled && + _selectedStoreId == null && + _stores.isNotEmpty) { + setState(() => _selectedStoreId = _stores.first.orgId); + } + await _loadData(); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future _loadStores() async { + try { + final stores = await _service.getStores(); + if (!mounted) return; + setState(() => _stores = stores); + } catch (_) { + // 门店列表加载失败不影响主列表展示。 + } + } + + Future _loadData() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final list = await _service.getMarqueeList( + storeId: _selectedStoreId, + keyword: _keyword.isEmpty ? null : _keyword, + ); + if (!mounted) return; + setState(() { + _items = list; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e is ApiException ? e.message : e.toString(); + _loading = false; + }); + } + } + + Future _refresh() async => _loadData(); + + void _onSearchChanged(String value) { + _keyword = value.trim(); + _loadData(); + } + + Future _showForm({MarqueeMessage? editing}) async { + final result = await showModalBottomSheet>( + context: context, + isScrollControlled: true, + builder: (ctx) => _MarqueeForm( + stores: _stores, + editing: editing, + ), + ); + if (result == null) return; + + try { + await _service.saveMarquee(result); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.saved)), + ); + _refresh(); + } on ApiException catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.message)), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${t.saveFailed}: $e')), + ); + } + } + + Future _deleteMarquee(MarqueeMessage item) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(t.confirmDelete), + content: Text(t.deleteConfirmContent(item.content)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(t.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(t.delete), + ), + ], + ), + ); + if (confirmed != true) return; + + try { + await _service.deleteMarquee(item.id); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.deleted)), + ); + _refresh(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${t.deleteFailed}: $e')), + ); + } + } + + Future _enableMarquee(MarqueeMessage item) async { + setState(() => _loading = true); + try { + await _service.setMarqueeStatus(item.id, status: 1); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.marqueeStatusEnabled)), + ); + _refresh(); + } on ApiException catch (e) { + if (!mounted) return; + setState(() => _loading = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.message)), + ); + } catch (e) { + if (!mounted) return; + setState(() => _loading = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${t.marqueeStatusEnabled} $e')), + ); + } + } + + Future _disableMarquee(MarqueeMessage item) async { + try { + await _service.setMarqueeStatus(item.id, status: 0); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.marqueeStatusDisabled)), + ); + _refresh(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${t.marqueeStatusDisabled} $e')), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + automaticallyImplyLeading: false, + title: Text(t.marqueeManagement), + ), + body: Column( + children: [ + _buildFilterBar(), + Expanded(child: _buildBody()), + ], + ), + floatingActionButton: FloatingActionButton( + heroTag: 'marquee_add_fab', + onPressed: () => _showForm(), + child: const Icon(Icons.add), + ), + ); + } + + Widget _buildFilterBar() { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: Column( + children: [ + TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: t.searchMarqueeHint, + prefixIcon: const Icon(Icons.search), + border: const OutlineInputBorder(), + contentPadding: const EdgeInsets.symmetric(vertical: 12), + ), + onChanged: _onSearchChanged, + ), + const SizedBox(height: 8), + DropdownButtonFormField( + value: _selectedStoreId, + decoration: InputDecoration( + labelText: t.filterByStore, + border: const OutlineInputBorder(), + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + ), + items: [ + if (!SessionOrgScope.instance.enabled) + DropdownMenuItem( + value: null, + child: Text(t.allStores), + ), + ..._stores.map((s) => DropdownMenuItem( + value: s.orgId, + child: Text(s.name), + )), + ], + onChanged: (v) { + setState(() => _selectedStoreId = v); + _loadData(); + }, + ), + ], + ), + ); + } + + Widget _buildBody() { + if (_loading && _items.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + if (_error != null && _items.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(_error!), + const SizedBox(height: 8), + FilledButton.tonal( + onPressed: _refresh, + child: Text(t.retry), + ), + ], + ), + ); + } + if (_items.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.subtitles_outlined, + size: 64, color: Theme.of(context).colorScheme.outline), + const SizedBox(height: 16), + Text(t.noMarqueeData), + ], + ), + ); + } + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _items.length, + itemBuilder: (context, index) { + final item = _items[index]; + return _MarqueeCard( + item: item, + onEdit: () => _showForm(editing: item), + onDelete: () => _deleteMarquee(item), + onEnable: () => _enableMarquee(item), + onDisable: () => _disableMarquee(item), + ); + }, + ), + ); + } +} + +class _MarqueeCard extends StatelessWidget { + const _MarqueeCard({ + required this.item, + required this.onEdit, + required this.onDelete, + required this.onEnable, + required this.onDisable, + }); + + final MarqueeMessage item; + final VoidCallback onEdit; + final VoidCallback onDelete; + final VoidCallback onEnable; + final VoidCallback onDisable; + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + item.storeName, + style: theme.textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + ), + _StatusChip(status: item.status), + ], + ), + const SizedBox(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _parseColor(item.backgroundColor) ?? + colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: _parseColor(item.fontColor)?.withAlpha(76) ?? + colorScheme.outlineVariant, + ), + ), + child: Text( + item.content, + style: theme.textTheme.bodyLarge?.copyWith( + color: _parseColor(item.fontColor), + fontSize: item.fontSize?.toDouble(), + ), + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Icon(Icons.speed, size: 16, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 4), + Text( + '${t.marqueeSpeed}: ${MarqueeConfig.speedFromValue(item.speed).label(t.isEnglish)}', + style: theme.textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + const SizedBox(width: 16), + if (item.fontSize != null) + Padding( + padding: const EdgeInsets.only(right: 16), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.format_size, + size: 16, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 4), + Text( + '${t.marqueeFontSize}: ${item.fontSize}', + style: theme.textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), + if (item.level != null) + Padding( + padding: const EdgeInsets.only(right: 16), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.layers, + size: 16, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 4), + Text( + '${t.marqueeLevel}: ${MarqueeConfig.gradeFromValue(item.level).label(t.isEnglish)}', + style: theme.textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Icon(Icons.access_time, + size: 16, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 4), + Text( + _formatTimeRange(item.startTime, item.endTime), + style: theme.textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + const Spacer(), + if (item.fontColor != null || item.backgroundColor != null) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (item.fontColor != null) + _ColorDot( + label: t.marqueeFontColor, + colorHex: item.fontColor!, + ), + const SizedBox(width: 8), + if (item.backgroundColor != null) + _ColorDot( + label: t.marqueeBackgroundColor, + colorHex: item.backgroundColor!, + ), + ], + ), + ], + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (item.isDisabled) + FilledButton.tonal( + onPressed: onEnable, + child: Text(t.marqueeStatusEnabled), + ) + else + OutlinedButton( + onPressed: onDisable, + child: Text(t.marqueeStatusDisabled), + ), + const SizedBox(width: 8), + TextButton.icon( + onPressed: onEdit, + icon: const Icon(Icons.edit, size: 18), + label: Text(t.edit), + ), + TextButton.icon( + onPressed: onDelete, + icon: const Icon(Icons.delete, size: 18), + label: Text(t.delete), + style: TextButton.styleFrom( + foregroundColor: colorScheme.error, + ), + ), + ], + ), + ], + ), + ), + ); + } + + String _formatTime(DateTime? time) { + if (time == null) return '--'; + return '${time.hour.toString().padLeft(2, '0')}:' + '${time.minute.toString().padLeft(2, '0')}:' + '${time.second.toString().padLeft(2, '0')}'; + } + + String _formatTimeRange(DateTime? start, DateTime? end) { + if (start == null && end == null) return '--'; + final s = start == null ? '--' : _formatTime(start); + final e = end == null ? '--' : _formatTime(end); + return '$s ~ $e'; + } + + Color? _parseColor(String? hex) { + if (hex == null || hex.isEmpty) return null; + final normalized = hex.trim(); + try { + if (normalized.length == 7 && normalized.startsWith('#')) { + return Color(int.parse('FF${normalized.substring(1)}', radix: 16)); + } + if (normalized.length == 9 && normalized.startsWith('#')) { + return Color(int.parse(normalized.substring(1), radix: 16)); + } + if (normalized.length == 6 && !normalized.startsWith('#')) { + return Color(int.parse('FF$normalized', radix: 16)); + } + } catch (_) { + return null; + } + return null; + } +} + +class _ColorPrefixIcon extends StatelessWidget { + const _ColorPrefixIcon({required this.colorHex}); + + final String colorHex; + + @override + Widget build(BuildContext context) { + final parsed = _parseColor(colorHex); + return Container( + width: 24, + height: 24, + margin: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: parsed ?? Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), + ), + ); + } + + Color? _parseColor(String hex) { + final normalized = hex.trim(); + try { + if (normalized.length == 7 && normalized.startsWith('#')) { + return Color(int.parse('FF${normalized.substring(1)}', radix: 16)); + } + if (normalized.length == 9 && normalized.startsWith('#')) { + return Color(int.parse(normalized.substring(1), radix: 16)); + } + if (normalized.length == 6 && !normalized.startsWith('#')) { + return Color(int.parse('FF$normalized', radix: 16)); + } + } catch (_) { + return null; + } + return null; + } +} + +class _DateTimeField extends StatelessWidget { + const _DateTimeField({ + required this.label, + this.value, + required this.onChanged, + }); + + final String label; + final DateTime? value; + final ValueChanged onChanged; + + String _formatTime(DateTime? dt) { + if (dt == null) return ''; + return '${dt.hour.toString().padLeft(2, '0')}:' + '${dt.minute.toString().padLeft(2, '0')}:' + '${dt.second.toString().padLeft(2, '0')}'; + } + + @override + Widget build(BuildContext context) { + final text = _formatTime(value); + + return InkWell( + onTap: () async { + final initial = value ?? DateTime(2000, 1, 1, 0, 0, 0); + final picked = await showModalBottomSheet( + useSafeArea: true, + context: context, + isScrollControlled: true, + builder: (ctx) => _TimePickerSheet(initialTime: initial), + ); + if (picked == null) return; + onChanged(picked); + }, + child: InputDecorator( + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + suffixIcon: const Icon(Icons.access_time), + ), + child: Text( + text.isEmpty ? '--' : text, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ); + } +} + +/// 时分秒时间选择器,对齐 Web 端滚动字幕开始/结束时间控件。 +/// +/// 三列滚轮(时 00-23 / 分 00-59 / 秒 00-59),底部取消/确定按钮。 +class _TimePickerSheet extends StatefulWidget { + const _TimePickerSheet({required this.initialTime}); + + final DateTime initialTime; + + @override + State<_TimePickerSheet> createState() => _TimePickerSheetState(); +} + +class _TimePickerSheetState extends State<_TimePickerSheet> { + late FixedExtentScrollController _hourCtrl; + late FixedExtentScrollController _minuteCtrl; + late FixedExtentScrollController _secondCtrl; + late int _hour; + late int _minute; + late int _second; + + @override + void initState() { + super.initState(); + _hour = widget.initialTime.hour; + _minute = widget.initialTime.minute; + _second = widget.initialTime.second; + _hourCtrl = FixedExtentScrollController(initialItem: _hour); + _minuteCtrl = FixedExtentScrollController(initialItem: _minute); + _secondCtrl = FixedExtentScrollController(initialItem: _second); + } + + @override + void dispose() { + _hourCtrl.dispose(); + _minuteCtrl.dispose(); + _secondCtrl.dispose(); + super.dispose(); + } + + Widget _buildWheel({ + required int count, + required FixedExtentScrollController controller, + required ValueChanged onChanged, + required String unit, + }) { + return Expanded( + child: Column( + children: [ + Text(unit, style: Theme.of(context).textTheme.labelSmall), + Expanded( + child: ListWheelScrollView.useDelegate( + controller: controller, + itemExtent: 44, + diameterRatio: 1.2, + physics: const FixedExtentScrollPhysics(), + onSelectedItemChanged: onChanged, + childDelegate: ListWheelChildBuilderDelegate( + builder: (ctx, index) { + if (index < 0 || index >= count) return null; + return Container( + alignment: Alignment.center, + child: Text( + index.toString().padLeft(2, '0'), + style: Theme.of(context).textTheme.titleMedium, + ), + ); + }, + childCount: count, + ), + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + t.pleaseSelect, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + ), + SizedBox( + height: 220, + child: Row( + children: [ + _buildWheel( + count: 24, + controller: _hourCtrl, + onChanged: (v) => setState(() => _hour = v), + unit: t.marqueeHour, + ), + const Text(':', style: TextStyle(fontSize: 20)), + _buildWheel( + count: 60, + controller: _minuteCtrl, + onChanged: (v) => setState(() => _minute = v), + unit: t.marqueeMinute, + ), + const Text(':', style: TextStyle(fontSize: 20)), + _buildWheel( + count: 60, + controller: _secondCtrl, + onChanged: (v) => setState(() => _second = v), + unit: t.marqueeSecond, + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.pop(context), + child: Text(t.cancel), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton( + onPressed: () { + Navigator.pop( + context, + DateTime(2000, 1, 1, _hour, _minute, _second), + ); + }, + child: Text(t.confirm), + ), + ), + ], + ), + ), + SizedBox(height: MediaQuery.of(context).padding.bottom), + ], + ), + ); + } +} + +class _ColorDot extends StatelessWidget { + const _ColorDot({required this.label, required this.colorHex}); + + final String label; + final String colorHex; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final parsed = _parseColor(colorHex); + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: theme.textTheme.bodySmall + ?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + const SizedBox(width: 4), + Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: parsed ?? theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + ), + ], + ); + } + + Color? _parseColor(String hex) { + final normalized = hex.trim(); + try { + if (normalized.length == 7 && normalized.startsWith('#')) { + return Color(int.parse('FF${normalized.substring(1)}', radix: 16)); + } + if (normalized.length == 9 && normalized.startsWith('#')) { + return Color(int.parse(normalized.substring(1), radix: 16)); + } + if (normalized.length == 6 && !normalized.startsWith('#')) { + return Color(int.parse('FF$normalized', radix: 16)); + } + } catch (_) { + return null; + } + return null; + } +} + +class _StatusChip extends StatelessWidget { + const _StatusChip({required this.status}); + + final int status; + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + final isEnabled = status == 1; + final colorScheme = Theme.of(context).colorScheme; + + return Chip( + avatar: Icon( + isEnabled ? Icons.check_circle : Icons.block, + size: 18, + color: isEnabled ? colorScheme.onPrimaryContainer : null, + ), + label: Text( + isEnabled ? t.marqueeStatusEnabled : t.marqueeStatusDisabled, + style: TextStyle( + color: isEnabled ? colorScheme.onPrimaryContainer : null, + fontSize: 12, + ), + ), + backgroundColor: + isEnabled ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest, + side: BorderSide.none, + padding: EdgeInsets.zero, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + } +} + +class _MarqueeForm extends StatefulWidget { + const _MarqueeForm({ + required this.stores, + this.editing, + }); + + final List stores; + final MarqueeMessage? editing; + + @override + State<_MarqueeForm> createState() => _MarqueeFormState(); +} + +class _MarqueeFormState extends State<_MarqueeForm> { + final _formKey = GlobalKey(); + String? _storeId; + late final _contentController = + TextEditingController(text: widget.editing?.content ?? ''); + late final _fontColorController = + TextEditingController(text: widget.editing?.fontColor ?? '#FFFFFF'); + late final _backgroundColorController = + TextEditingController(text: widget.editing?.backgroundColor ?? '#3B82F6'); + late final _fontSizeController = + TextEditingController(text: (widget.editing?.fontSize ?? 32).toString()); + late MarqueeSpeed _speed; + late MarqueeGrade _grade; + DateTime? _startTime; + DateTime? _endTime; + late int _status; + + @override + void initState() { + super.initState(); + _storeId = widget.editing?.storeId ?? + (widget.stores.isNotEmpty ? widget.stores.first.orgId : null); + _speed = MarqueeConfig.speedFromValue(widget.editing?.speed); + _grade = MarqueeConfig.gradeFromValue(widget.editing?.level); + _startTime = widget.editing?.startTime ?? DateTime(2000, 1, 1, 0, 0, 0); + _endTime = widget.editing?.endTime ?? DateTime(2000, 1, 1, 23, 59, 59); + _status = widget.editing?.status ?? 1; + } + + @override + void dispose() { + _contentController.dispose(); + _fontColorController.dispose(); + _backgroundColorController.dispose(); + _fontSizeController.dispose(); + super.dispose(); + } + + Future _pickColor(TextEditingController controller) async { + final result = await showModalBottomSheet( + useSafeArea: true, + context: context, + isScrollControlled: true, + builder: (ctx) => _ColorPickerSheet(initialColor: controller.text), + ); + if (result != null) { + setState(() => controller.text = result); + } + } + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + final isEdit = widget.editing != null; + + return SingleChildScrollView( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Container( + padding: const EdgeInsets.all(20), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + isEdit ? t.editMarquee : t.addMarquee, + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 20), + OrgPickerFormField( + value: _storeId, + labelText: t.targetStore, + options: widget.stores + .map((s) => OrgPickerOption( + node: OrgNode( + id: s.orgId, + name: s.name, + type: 3, + parentId: s.parentId ?? '', + address: s.address, + contactPhone: s.contactPhone, + activationCode: s.activationCode, + ), + )) + .toList(), + onChanged: (v) => setState(() => _storeId = v), + validator: (v) => + (v == null || v.isEmpty) ? t.pleaseSelect : null, + searchHint: t.searchStoreHint, + emptyHint: t.noStoreAvailable, + autoOpen: false, + ), + const SizedBox(height: 16), + TextFormField( + controller: _contentController, + decoration: InputDecoration( + labelText: t.marqueeContent, + border: const OutlineInputBorder(), + alignLabelWithHint: true, + ), + maxLines: 4, + validator: (v) => + (v == null || v.trim().isEmpty) ? t.marqueeContentRequired : null, + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _fontColorController, + readOnly: true, + onTap: () => _pickColor(_fontColorController), + decoration: InputDecoration( + labelText: t.marqueeFontColor, + border: const OutlineInputBorder(), + prefixIcon: _ColorPrefixIcon(colorHex: _fontColorController.text), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + controller: _backgroundColorController, + readOnly: true, + onTap: () => _pickColor(_backgroundColorController), + decoration: InputDecoration( + labelText: t.marqueeBackgroundColor, + border: const OutlineInputBorder(), + prefixIcon: _ColorPrefixIcon(colorHex: _backgroundColorController.text), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _fontSizeController, + decoration: InputDecoration( + labelText: t.marqueeFontSize, + border: const OutlineInputBorder(), + suffixText: 'px', + ), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + validator: (v) { + final size = int.tryParse(v ?? ''); + if (size == null || size <= 0) { + return t.marqueeFontSizeInvalid; + } + return null; + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: DropdownButtonFormField( + value: _grade, + decoration: InputDecoration( + labelText: t.marqueeLevel, + border: const OutlineInputBorder(), + ), + items: MarqueeGrade.values.map((g) { + return DropdownMenuItem( + value: g, + child: Text(g.label(t.isEnglish)), + ); + }).toList(), + onChanged: (v) { + if (v != null) setState(() => _grade = v); + }, + ), + ), + ], + ), + const SizedBox(height: 16), + DropdownButtonFormField( + value: _speed, + decoration: InputDecoration( + labelText: t.marqueeSpeed, + border: const OutlineInputBorder(), + ), + items: MarqueeSpeed.values.map((s) { + return DropdownMenuItem( + value: s, + child: Text(s.label(t.isEnglish)), + ); + }).toList(), + onChanged: (v) { + if (v != null) setState(() => _speed = v); + }, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + value: _status, + decoration: InputDecoration( + labelText: t.marqueeStatus, + border: const OutlineInputBorder(), + ), + items: [ + DropdownMenuItem( + value: 1, + child: Text(t.marqueeStatusEnabled), + ), + DropdownMenuItem( + value: 0, + child: Text(t.marqueeStatusDisabled), + ), + ], + onChanged: (v) { + if (v == null) return; + setState(() => _status = v); + }, + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _DateTimeField( + label: t.marqueeStartTime, + value: _startTime, + onChanged: (v) => setState(() => _startTime = v), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _DateTimeField( + label: t.marqueeEndTime, + value: _endTime, + onChanged: (v) => setState(() => _endTime = v), + ), + ), + ], + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _submit, + child: Text(t.save), + ), + ), + ], + ), + ), + ), + ); + } + + void _submit() { + if (!_formKey.currentState!.validate()) return; + final store = widget.stores.firstWhere( + (s) => s.orgId == _storeId, + orElse: () => const StoreInfo( + orgId: '', + name: '', + address: '', + contactPhone: '', + activationCode: '', + status: 0, + expireTime: null, + createTime: null, + ), + ); + Navigator.pop(context, { + 'id': widget.editing?.id, + 'storeId': _storeId, + 'storeName': store.name, + 'content': _contentController.text.trim(), + 'speed': _speed.value, + 'fontColor': _fontColorController.text.trim(), + 'backgroundColor': _backgroundColorController.text.trim(), + 'fontSize': int.tryParse(_fontSizeController.text.trim()) ?? 32, + 'level': _grade.value, + if (_startTime != null) 'startTime': _startTime!.toIso8601String(), + if (_endTime != null) 'endTime': _endTime!.toIso8601String(), + 'status': _status, + }); + } +} + +/// 颜色选择器弹窗,对齐 Web 端 `el-color-picker` 交互: +/// - 饱和度/明度 2D 选择区 +/// - 色相条 +/// - HEX 输入框 +/// - 当前颜色预览 +/// - 取消/确定按钮 +class _ColorPickerSheet extends StatefulWidget { + const _ColorPickerSheet({this.initialColor}); + + final String? initialColor; + + @override + State<_ColorPickerSheet> createState() => _ColorPickerSheetState(); +} + +class _ColorPickerSheetState extends State<_ColorPickerSheet> { + late HSVColor _hsv; + final _hexCtrl = TextEditingController(); + + @override + void initState() { + super.initState(); + final color = _parseColor(widget.initialColor) ?? const Color(0xFFFFFFFF); + _hsv = HSVColor.fromColor(color); + _hexCtrl.text = _colorToHex(_hsv.toColor()); + } + + @override + void dispose() { + _hexCtrl.dispose(); + super.dispose(); + } + + void _updateFromHsv(HSVColor hsv) { + setState(() { + _hsv = hsv; + _hexCtrl.text = _colorToHex(hsv.toColor()); + }); + } + + void _onHexSubmitted(String value) { + final color = _parseColor(value); + if (color == null) return; + setState(() => _hsv = HSVColor.fromColor(color)); + } + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + final color = _hsv.toColor(); + final bottom = MediaQuery.of(context).viewInsets.bottom; + + return Padding( + padding: EdgeInsets.only(bottom: bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + t.marqueeFontColor, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: _SaturationValueBox( + hsv: _hsv, + onChanged: (sat, val) => + _updateFromHsv(_hsv.withSaturation(sat).withValue(val)), + ), + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: _HueSlider( + hue: _hsv.hue, + onChanged: (h) => _updateFromHsv(_hsv.withHue(h)), + ), + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _hexCtrl, + decoration: const InputDecoration( + labelText: 'HEX', + border: OutlineInputBorder(), + prefixText: '#', + ), + textCapitalization: TextCapitalization.characters, + onSubmitted: _onHexSubmitted, + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), + child: Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.pop(context), + child: Text(t.cancel), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton( + onPressed: () => Navigator.pop(context, _colorToHex(color)), + child: Text(t.confirm), + ), + ), + ], + ), + ), + SizedBox(height: MediaQuery.of(context).padding.bottom), + ], + ), + ); + } +} + +class _SaturationValueBox extends StatelessWidget { + const _SaturationValueBox({required this.hsv, required this.onChanged}); + + final HSVColor hsv; + final void Function(double saturation, double value) onChanged; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final size = Size(constraints.maxWidth, constraints.maxWidth); + return GestureDetector( + onPanUpdate: (d) => _handle(d.localPosition, size), + onTapUp: (d) => _handle(d.localPosition, size), + child: SizedBox( + width: size.width, + height: size.height, + child: Stack( + children: [ + CustomPaint( + size: size, + painter: _SaturationValuePainter(hue: hsv.hue), + ), + Positioned( + left: hsv.saturation * size.width - 8, + top: (1 - hsv.value) * size.height - 8, + child: const _PickerThumb(), + ), + ], + ), + ), + ); + }, + ); + } + + void _handle(Offset local, Size size) { + final x = local.dx.clamp(0, size.width); + final y = local.dy.clamp(0, size.height); + onChanged(x / size.width, 1 - y / size.height); + } +} + +class _HueSlider extends StatelessWidget { + const _HueSlider({required this.hue, required this.onChanged}); + + final double hue; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final size = Size(constraints.maxWidth, 32); + return GestureDetector( + onPanUpdate: (d) => _handle(d.localPosition, size), + onTapUp: (d) => _handle(d.localPosition, size), + child: SizedBox( + width: size.width, + height: size.height, + child: Stack( + children: [ + CustomPaint( + size: size, + painter: const _HuePainter(), + ), + Positioned( + left: (hue / 360) * size.width - 6, + top: 2, + child: Container( + width: 12, + height: 28, + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.black38), + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ], + ), + ), + ); + }, + ); + } + + void _handle(Offset local, Size size) { + onChanged((local.dx / size.width).clamp(0, 1) * 360); + } +} + +class _SaturationValuePainter extends CustomPainter { + const _SaturationValuePainter({required this.hue}); + + final double hue; + + @override + void paint(Canvas canvas, Size size) { + final rect = Rect.fromLTWH(0, 0, size.width, size.height); + final base = HSVColor.fromAHSV(1, hue, 1, 1).toColor(); + final gradientH = LinearGradient( + colors: [Colors.white, base], + ).createShader(rect); + final gradientV = const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black], + ).createShader(rect); + + final paint = Paint()..style = PaintingStyle.fill; + canvas.drawRect(rect, paint..shader = gradientH); + canvas.drawRect(rect, paint..shader = gradientV); + } + + @override + bool shouldRepaint(covariant _SaturationValuePainter old) => old.hue != hue; +} + +class _HuePainter extends CustomPainter { + const _HuePainter(); + + @override + void paint(Canvas canvas, Size size) { + final rect = Rect.fromLTWH(0, 0, size.width, size.height); + final colors = [ + HSVColor.fromAHSV(1, 0, 1, 1).toColor(), + HSVColor.fromAHSV(1, 60, 1, 1).toColor(), + HSVColor.fromAHSV(1, 120, 1, 1).toColor(), + HSVColor.fromAHSV(1, 180, 1, 1).toColor(), + HSVColor.fromAHSV(1, 240, 1, 1).toColor(), + HSVColor.fromAHSV(1, 300, 1, 1).toColor(), + HSVColor.fromAHSV(1, 360, 1, 1).toColor(), + ]; + final gradient = LinearGradient(colors: colors).createShader(rect); + canvas.drawRect(rect, Paint()..shader = gradient..style = PaintingStyle.fill); + } + + @override + bool shouldRepaint(covariant CustomPainter old) => false; +} + +class _PickerThumb extends StatelessWidget { + const _PickerThumb(); + + @override + Widget build(BuildContext context) { + return Container( + width: 16, + height: 16, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 2)], + ), + ); + } +} + +Color? _parseColor(String? hex) { + if (hex == null || hex.isEmpty) return null; + final normalized = hex.trim(); + try { + if (normalized.length == 7 && normalized.startsWith('#')) { + return Color(int.parse('FF${normalized.substring(1)}', radix: 16)); + } + if (normalized.length == 9 && normalized.startsWith('#')) { + return Color(int.parse(normalized.substring(1), radix: 16)); + } + if (normalized.length == 6 && !normalized.startsWith('#')) { + return Color(int.parse('FF$normalized', radix: 16)); + } + } catch (_) { + return null; + } + return null; +} + +String _colorToHex(Color color) { + final hex = color.value.toRadixString(16).padLeft(8, '0').toUpperCase(); + return '#${hex.substring(2)}'; +} diff --git a/lib/screens/admin/media/media_list_page.dart b/lib/screens/admin/media/media_list_page.dart new file mode 100644 index 0000000..f5bc5fa --- /dev/null +++ b/lib/screens/admin/media/media_list_page.dart @@ -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 createState() => _MediaListPageState(); +} + +class _MediaListPageState extends ConsumerState { + final _scrollController = ScrollController(); + final _searchController = TextEditingController(); + + List _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 _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 _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 _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( + 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( + 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 = { + 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 _delete(String id) async { + final confirmed = await showDialog( + 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( + 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), + ), + ); + } +} diff --git a/lib/screens/admin/media/media_rank_page.dart b/lib/screens/admin/media/media_rank_page.dart new file mode 100644 index 0000000..73be1db --- /dev/null +++ b/lib/screens/admin/media/media_rank_page.dart @@ -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 createState() => _MediaRankPageState(); +} + +class _MediaRankPageState extends ConsumerState { + List _items = []; + bool _loading = false; + String? _error; + + @override + void initState() { + super.initState(); + _loadData(); + } + + Future _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 _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 _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 _remove(String code) async { + final confirmed = await showDialog( + 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 _clearAll() async { + final confirmed = await showDialog( + 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 allMedia = []; + bool searching = false; + + showModalBottomSheet( + useSafeArea: true, + context: context, + isScrollControlled: true, + builder: (sheetCtx) { + return StatefulBuilder( + builder: (ctx, setSheet) { + Future 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 _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), + ), + ); + } +} diff --git a/lib/screens/admin/sys/config_page.dart b/lib/screens/admin/sys/config_page.dart new file mode 100644 index 0000000..db6865a --- /dev/null +++ b/lib/screens/admin/sys/config_page.dart @@ -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 createState() => _ConfigPageState(); +} + +class _ConfigPageState extends ConsumerState { + final SystemService _service = SystemService(); + + final List _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 _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 _loadMore() async { + if (_loading || !_hasMore) return; + _page++; + await _loadData(); + } + + Future _refresh() async { + await _loadData(reset: true); + } + + void _onSearchChanged(String value) { + _searchKeyword = value; + // 本地过滤,因为 getConfigList 不支持搜索参数 + setState(() {}); + } + + List get _filteredItems { + if (_searchKeyword.isEmpty) return _items; + return _items + .where((c) => c.paramKey.toLowerCase().contains( + _searchKeyword.toLowerCase(), + )) + .toList(); + } + + Future _showForm({SysConfig? config}) async { + final result = await showModalBottomSheet>( + 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 _deleteConfig(SysConfig config) async { + final confirmed = await showDialog( + 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 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( + 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(); + 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 = { + '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('保存'), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/admin/sys/log_page.dart b/lib/screens/admin/sys/log_page.dart new file mode 100644 index 0000000..4440711 --- /dev/null +++ b/lib/screens/admin/sys/log_page.dart @@ -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 createState() => _LogPageState(); +} + +class _LogPageState extends ConsumerState + 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 _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> _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 _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 _loadMore() async { + if (_loading || !_hasMore) return; + _page++; + await _loadData(); + } + + Future _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, + ), + ], + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/screens/admin/sys/menu_page.dart b/lib/screens/admin/sys/menu_page.dart new file mode 100644 index 0000000..6feaef2 --- /dev/null +++ b/lib/screens/admin/sys/menu_page.dart @@ -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 createState() => _MenuPageState(); +} + +class _MenuPageState extends ConsumerState { + final SystemService _service = SystemService(); + + List _items = []; + bool _loading = false; + String? _error; + + @override + void initState() { + super.initState(); + _loadData(); + } + + Future _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 _refresh() async { + await _loadData(); + } + + /// 构建菜单树:parentId 为空或 "0" 的为根节点。 + List<_MenuNode> _buildTree(List menus) { + final nodeMap = {}; + 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 _showForm({SysMenu? menu}) async { + final result = await showModalBottomSheet>( + 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 _deleteMenu(SysMenu menu) async { + final confirmed = await showDialog( + 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( + 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( + 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 allMenus; + + @override + State<_MenuForm> createState() => _MenuFormState(); +} + +class _MenuFormState extends State<_MenuForm> { + final _formKey = GlobalKey(); + 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 = { + '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( + 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( + title: const Text('目录'), + value: 0, + groupValue: _type, + onChanged: (v) => setState(() => _type = v ?? 0), + contentPadding: EdgeInsets.zero, + ), + ), + Expanded( + child: RadioListTile( + title: const Text('菜单'), + value: 1, + groupValue: _type, + onChanged: (v) => setState(() => _type = v ?? 1), + contentPadding: EdgeInsets.zero, + ), + ), + Expanded( + child: RadioListTile( + 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('保存'), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/admin/sys/role_page.dart b/lib/screens/admin/sys/role_page.dart new file mode 100644 index 0000000..7eb7a1b --- /dev/null +++ b/lib/screens/admin/sys/role_page.dart @@ -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 createState() => _RolePageState(); +} + +class _RolePageState extends ConsumerState { + final SystemService _service = SystemService(); + + final List _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 _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 _loadMore() async { + if (_loading || !_hasMore) return; + _page++; + await _loadData(); + } + + Future _refresh() async { + await _loadData(reset: true); + } + + Future _showForm({SysRole? role}) async { + final result = await showModalBottomSheet>( + 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 _deleteRole(SysRole role) async { + final confirmed = await showDialog( + 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( + 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(); + 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 = { + '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('保存'), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/admin/sys/schedule_page.dart b/lib/screens/admin/sys/schedule_page.dart new file mode 100644 index 0000000..45391be --- /dev/null +++ b/lib/screens/admin/sys/schedule_page.dart @@ -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 createState() => _SchedulePageState(); +} + +class _SchedulePageState extends ConsumerState { + final SystemService _service = SystemService(); + + final List _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 _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 _loadMore() async { + if (_loading || !_hasMore) return; + _page++; + await _loadData(); + } + + Future _refresh() async { + await _loadData(reset: true); + } + + Future _showForm({Schedule? schedule}) async { + final result = await showModalBottomSheet>( + 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 _deleteSchedule(Schedule schedule) async { + final confirmed = await showDialog( + 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 _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 _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 _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( + 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 _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 _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 _loadMore() async { + if (_loading || !_hasMore) return; + _page++; + await _loadData(); + } + + Future _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(); + 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 = { + 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('保存'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/admin/sys/user_page.dart b/lib/screens/admin/sys/user_page.dart new file mode 100644 index 0000000..40f2c2c --- /dev/null +++ b/lib/screens/admin/sys/user_page.dart @@ -0,0 +1,1191 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../models/org_node.dart'; +import '../../../models/sys_role.dart'; +import '../../../models/sys_user.dart'; +import '../../../providers/auth_provider.dart'; +import '../../../services/api_client.dart'; +import '../../../services/org_service.dart'; +import '../../../services/sys_service.dart'; +import '../../../utils/credential_storage.dart'; +import '../../../utils/i10n.dart'; +import '../../../utils/session_org_scope.dart'; +import '../../../widgets/org_picker_sheet.dart'; + +/// 用户管理页。 +class UserPage extends ConsumerStatefulWidget { + const UserPage({super.key}); + + @override + ConsumerState createState() => _UserPageState(); +} + +class _UserPageState extends ConsumerState { + final SystemService _service = SystemService(); + + final List _items = []; + bool _loading = false; + int _page = 1; + bool _hasMore = true; + String? _error; + + final TextEditingController _searchController = TextEditingController(); + String _searchKeyword = ''; + + List _roles = []; + bool _rolesLoaded = false; + + List _orgNodes = []; + final Map _orgNameMap = {}; + /// orgId -> 组织类型(0=平台 1=渠道 2=代理商 3=门店 4=设备分组), + /// 用于「角色」字段在角色名缺失时回退展示组织类型,而非裸数字 roleId。 + final Map _orgTypeMap = {}; + + final ScrollController _scrollController = ScrollController(); + final int _limit = 20; + + /// 当前账号密码是否明文展示(仅在无下级账号时展示自身密码)。 + bool _showOwnPassword = false; + + /// 当前选中的组织(门店/渠道商)筛选值,null 表示全部。 + String? _selectedOrgId; + + /// 当前登录用户组织范围内的全部组织 ID(用于客户端二次收敛用户列表)。 + /// 仅在 [SessionOrgScope] 启用时填充;为 null 表示尚未加载。 + Set? _scopeOrgIds; + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + _loadRoles(); + _loadOrgs(); + _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 _loadRoles() async { + final t = AppLocalizations.of(context); + try { + // 优先用 /sys/role/select;不存在时回退到 /sys/role/list。 + _roles = await _service.getRoleSelect(); + if (_roles.isEmpty) { + _roles = await _service.getRoleList(limit: 1000); + } + } catch (e) { + // 下拉接口失败时尝试分页接口。 + try { + _roles = await _service.getRoleList(limit: 1000); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.rolesLoadFailed)), + ); + } + } + } finally { + _rolesLoaded = true; + if (mounted) setState(() {}); + } + } + + Future _loadOrgs() async { + // 优先取组织树,保留层级关系;失败时回退到扁平列表。 + Future> fetch() async { + try { + final tree = await OrgService().getOrgTree(); + if (tree.isNotEmpty) return tree; + } catch (_) {} + try { + final flat = await OrgService().getOrgFlatList(); + if (flat.isNotEmpty) return flat; + } catch (_) {} + try { + final select = await OrgService().getOrgSelect(); + if (select.isNotEmpty) return select; + } catch (_) {} + return const []; + } + + _orgNodes = await fetch(); + _buildOrgNameMap(_orgNodes); + if (mounted) setState(() {}); + } + + /// 递归建立 orgId -> name / type 映射,用于展示用户所属门店/渠道商及其组织类型。 + void _buildOrgNameMap(List nodes) { + for (final node in nodes) { + if (node.id.isNotEmpty) { + if (node.name.isNotEmpty) _orgNameMap[node.id] = node.name; + _orgTypeMap[node.id] = node.type; + } + if (node.children.isNotEmpty) { + _buildOrgNameMap(node.children); + } + } + } + + /// 展示用户归属(门店/渠道商/代理商)。 + String _displayOrgName(SysUser user) { + final t = AppLocalizations.of(context); + if (user.orgName.isNotEmpty) return user.orgName; + if (user.orgId.isNotEmpty) { + final name = _orgNameMap[user.orgId]; + if (name != null && name.isNotEmpty) return name; + } + return t.unassigned; + } + + /// 组织类型 → 中文标签(0=平台 1=渠道 2=代理商 3=门店 4=设备分组)。 + String _orgTypeLabel(int type) { + switch (type) { + case 1: + return '渠道'; + case 2: + return '代理商'; + case 3: + return '门店'; + case 4: + return '设备分组'; + default: + return '平台'; + } + } + + /// 用户归属展示:优先按所属组织类型展示「门店 / 渠道 / 代理商」, + /// 这是该字段最直观的归属信息(避免显示裸数字 roleId)。 + /// 组织类型无法解析(orgId 缺失或不在组织树)时,再回退到角色名 / 角色名数组。 + String _displayRoleName(SysUser user) { + final t = AppLocalizations.of(context); + final type = user.orgId.isNotEmpty ? _orgTypeMap[user.orgId] : null; + if (type != null) return _orgTypeLabel(type); + if (user.roleNames.isNotEmpty) return user.roleNames.join('/'); + if (user.roleName.isNotEmpty) return user.roleName; + final matched = _roles.where((r) => r.roleId == user.roleId).toList(); + if (matched.isNotEmpty) return matched.first.roleName; + return t.unassigned; + } + + Future _loadData({bool reset = false}) async { + if (_loading) return; + if (reset) { + _page = 1; + _hasMore = true; + _items.clear(); + } + setState(() { + _loading = true; + _error = null; + }); + try { + final scope = SessionOrgScope.instance; + // 首次加载时预取「当前用户组织范围内」的全部组织 ID, + // 用于客户端二次收敛,确保受限账号只能看到本组织及下属组织的用户。 + if (scope.enabled && _scopeOrgIds == null) { + _scopeOrgIds = await OrgService().scopedOrgIdSet(); + } + // 未手动选择组织时: + // - 管理员不附加 orgId,拉取全量用户; + // - 受限账号(渠道商/代理商)也不附加 orgId,由后端按当前会话的数据权限 + // 返回范围内用户,避免直接传 agent/channel 的 orgId 导致后端误判为空。 + // 手动选择组织后,按所选 orgId 精确筛选。 + final effectiveOrgId = _selectedOrgId; + // 用户名搜索不传给后端:后端按 username 大小写敏感匹配,会漏掉大小写 + // 不同的记录(如「ADMIN」搜不到「admin」)。改为前端不区分大小写过滤, + // 确保大小写均可命中。 + final list = await _service.getUserList( + page: _page, + limit: _limit, + username: null, + orgId: effectiveOrgId, + ); + // 客户端收敛:受限账号仅保留归属组织在范围内的用户(后端可能忽略 orgId 参数)。 + var filtered = scope.enabled && _scopeOrgIds != null + ? list.where((u) => _scopeOrgIds!.contains(u.orgId)).toList() + : list; + // 前端不区分大小写按用户名过滤(核心修复点)。 + final kwLower = _searchKeyword.trim().toLowerCase(); + if (kwLower.isNotEmpty) { + filtered = filtered + .where((u) => u.username.toLowerCase().contains(kwLower)) + .toList(); + } + _items.addAll(filtered); + _hasMore = list.length >= _limit; + // 列表接口不返回角色字段(Web 端亦不显示角色列); + // 对「角色未分配」的用户批量拉取详情以补全 roleName/roleId。 + _enrichRoles(filtered); + } on ApiException catch (e) { + _error = e.message; + } catch (e) { + _error = e.toString(); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + /// 对列表中「角色未分配」的用户批量拉取详情,补全 roleName/roleId。 + /// + /// /sys/user/list 不返回角色字段(Web 端列表亦无角色列), + /// 角色仅在 /sys/user/info/{id} 详情接口中返回。本方法以并发限制 3 + /// 逐批拉取,避免 N+1 压垮接口;失败时静默跳过(不阻断列表展示)。 + Future _enrichRoles(List fresh) async { + final needEnrich = fresh + .where((u) => + u.roleId.isEmpty && + u.roleName.isEmpty && + u.roleNames.isEmpty) + .toList(); + if (needEnrich.isEmpty || !mounted) return; + + const concurrency = 3; + for (var i = 0; i < needEnrich.length; i += concurrency) { + if (!mounted) return; + final batch = needEnrich.skip(i).take(concurrency).toList(); + final futures = batch.map((u) async { + try { + final detail = await _service.getUserInfo(u.id); + return MapEntry(u.id, detail); + } catch (_) { + return null; + } + }).toList(); + final results = await Future.wait(futures); + var changed = false; + for (final r in results) { + if (r == null || !mounted) continue; + final idx = _items.indexWhere((u) => u.id == r.key); + if (idx < 0) continue; + final detail = r.value; + if (detail.roleId.isNotEmpty || + detail.roleName.isNotEmpty || + detail.roleNames.isNotEmpty) { + _items[idx] = _items[idx].copyWith( + roleId: detail.roleId, + roleName: detail.roleName, + roleNames: detail.roleNames, + ); + changed = true; + } + } + if (changed && mounted) setState(() {}); + } + } + + Future _loadMore() async { + if (_loading || !_hasMore) return; + _page++; + await _loadData(); + } + + Future _refresh() async { + await _loadData(reset: true); + } + + void _onSearchChanged(String value) { + _searchKeyword = value; + _refresh(); + } + + Future _showForm({SysUser? user}) async { + final t = AppLocalizations.of(context); + if (!_rolesLoaded) await _loadRoles(); + + // 编辑时若角色信息缺失,拉取用户详情以正确初始化角色字段。 + SysUser? effectiveUser = user; + if (user != null && + user.roleId.isEmpty && + user.roleName.isEmpty && + user.roleNames.isEmpty) { + try { + effectiveUser = await _service.getUserInfo(user.id); + } catch (_) { + effectiveUser = user; + } + if (!mounted) return; + } + + final currentUser = ref.read(authProvider).user; + final result = await showModalBottomSheet>( + context: context, + isScrollControlled: true, + builder: (context) => _UserForm( + user: effectiveUser, + roles: _roles, + orgNodes: _orgNodes, + currentUserOrgId: currentUser?.orgId, + currentUserRoleId: currentUser?.roleId, + ), + ); + + if (result == null) return; + + try { + await _service.saveUser(result); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.saved)), + ); + _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('${t.saveFailed}:${formatApiError(e)}')), + ); + } + } + } + + Future _deleteUser(SysUser user) async { + final t = AppLocalizations.of(context); + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(t.deleteUserConfirmTitle), + content: Text(t.deleteUserConfirmContent(user.username)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(t.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(t.delete), + ), + ], + ), + ); + + if (confirmed != true) return; + + try { + await _service.deleteUser([int.tryParse(user.id) ?? 0]); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.deleted)), + ); + _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('${t.deleteFailed}:${formatApiError(e)}')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + return Scaffold( + appBar: AppBar( + automaticallyImplyLeading: false, + title: Text(t.userManagement), + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Row( + children: [ + // 所属组织(门店)筛选,参考 web 端下拉样式。 + Expanded( + flex: 2, + child: InputDecorator( + decoration: InputDecoration( + labelText: t.org, + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.account_tree), + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: _selectedOrgId, + hint: Text(t.all), + items: [ + DropdownMenuItem( + value: null, + child: Text(t.all), + ), + ..._orgNameMap.entries + .where((e) => e.key.isNotEmpty && e.value.isNotEmpty) + .map((e) => DropdownMenuItem( + value: e.key, + child: Text(e.value), + )), + ], + onChanged: (v) { + setState(() => _selectedOrgId = v); + _refresh(); + }, + ), + ), + ), + ), + const SizedBox(width: 8), + // 用户名搜索 + Expanded( + flex: 3, + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: t.searchUsernameHint, + 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()), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: () => _showForm(), + child: const Icon(Icons.person_add), + ), + ); + } + + /// 当前账号是否存在下级账号(即列表中存在 username 与当前登录账号不同的用户)。 + /// + /// - 当前用户未登录:按 false 处理; + /// - 列表为空 或 仅包含当前账号自身:false(展示自身账号信息); + /// - 包含其他用户:true(展示下级账号列表,不展示自身密码)。 + bool get _hasSubordinates { + final currentUser = ref.read(authProvider).user; + if (currentUser == null) return false; + return _items.any((u) => u.username != currentUser.account); + } + + Widget _buildBody() { + final t = AppLocalizations.of(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: Text(t.retry), + ), + ], + ), + ); + } + + final hasSubordinates = _hasSubordinates; + + // 数据加载完成且当前账号无下级账号:展示自身账号名与密码。 + if (!_loading && !hasSubordinates) { + return RefreshIndicator( + onRefresh: _refresh, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: _buildCurrentUserCard(), + ), + ); + } + + 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 Padding( + padding: const EdgeInsets.all(16), + child: Center(child: Text(t.noMoreData)), + ); + } + return const SizedBox.shrink(); + } + final user = _items[index]; + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: ListTile( + title: Text(user.username), + subtitle: Text( + '${t.roleLabel}:${_displayRoleName(user)} | ' + '${user.status == 0 ? t.statusNormal : t.statusDisabled} | ' + '${t.orgLabel}:${_displayOrgName(user)}', + ), + trailing: PopupMenuButton( + onSelected: (value) { + if (value == 'edit') { + _showForm(user: user); + } else if (value == 'delete') { + _deleteUser(user); + } + }, + itemBuilder: (context) => [ + PopupMenuItem(value: 'edit', child: Text(t.edit)), + PopupMenuItem(value: 'delete', child: Text(t.delete)), + ], + ), + ), + ); + }, + ), + ); + } + + /// 无下级账号时展示的「当前账号信息」卡片。 + /// + /// 展示当前登录账号的账号名与密码: + /// - 账号:从 [authProvider.user.account] 读取; + /// - 密码:从 [CredentialStorage.savedPassword] 读取,支持点击图标切换显隐; + /// 若用户未勾选「记住密码」导致本地无密码,则显示「未保存」。 + Widget _buildCurrentUserCard() { + final t = AppLocalizations.of(context); + final currentUser = ref.read(authProvider).user; + final account = currentUser?.account ?? ''; + final savedPassword = CredentialStorage.instance.savedPassword; + final hasPassword = savedPassword != null && savedPassword.isNotEmpty; + final displayPassword = _showOwnPassword + ? (savedPassword ?? t.passwordNotSaved) + : (hasPassword ? '••••••' : t.passwordNotSaved); + + return Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.account_circle, + color: Theme.of(context).colorScheme.primary, + size: 28, + ), + const SizedBox(width: 12), + Text( + t.currentAccountInfo, + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + const SizedBox(height: 20), + _buildInfoRow(label: t.account, value: account), + const SizedBox(height: 16), + _buildInfoRow( + label: t.password, + value: displayPassword, + trailing: hasPassword + ? IconButton( + icon: Icon( + _showOwnPassword + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + ), + tooltip: _showOwnPassword + ? t.hidePassword + : t.showPassword, + onPressed: () => setState( + () => _showOwnPassword = !_showOwnPassword, + ), + ) + : null, + ), + const SizedBox(height: 20), + Text( + t.noSubordinateAccount, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildInfoRow({ + required String label, + required String value, + Widget? trailing, + }) { + return Row( + children: [ + SizedBox( + width: 64, + child: Text( + '$label:', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + value, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + if (trailing != null) trailing, + ], + ); + } +} + +/// 组织下拉项:携带层级深度,用于按组织树自上而下排序并缩进展示。 +class _OrgOption { + const _OrgOption({ + required this.node, + required this.depth, + }); + + final OrgNode node; + final int depth; +} + +/// 用户表单。 +class _UserForm extends StatefulWidget { + const _UserForm({ + this.user, + this.roles = const [], + this.orgNodes = const [], + this.currentUserOrgId, + this.currentUserRoleId, + }); + + final SysUser? user; + final List roles; + final List orgNodes; + + /// 当前登录用户所属组织 ID,新增用户时作为默认值。 + final String? currentUserOrgId; + + /// 当前登录用户角色 ID,新增用户时作为默认值。 + final String? currentUserRoleId; + + @override + State<_UserForm> createState() => _UserFormState(); +} + +class _UserFormState extends State<_UserForm> { + final _formKey = GlobalKey(); + late TextEditingController _usernameCtrl; + late TextEditingController _emailCtrl; + late TextEditingController _mobileCtrl; + late TextEditingController _passwordCtrl; + late TextEditingController _confirmPasswordCtrl; + late String _roleId; + late int _status; + late String _orgId; + + /// 密码是否以密文显示(true 隐藏,false 显示)。 + bool _obscurePassword = true; + + /// 确认密码是否以密文显示(true 隐藏,false 显示)。 + bool _obscureConfirmPassword = true; + + List<_OrgOption> _orgOptions = []; + + @override + void initState() { + super.initState(); + final u = widget.user; + final isAdd = u == null; + _usernameCtrl = TextEditingController(text: u?.username ?? ''); + _emailCtrl = TextEditingController(text: u?.email ?? ''); + _mobileCtrl = TextEditingController(text: u?.mobile ?? ''); + _passwordCtrl = TextEditingController(); + _confirmPasswordCtrl = TextEditingController(); + + // 编辑模式下,密码输入后需要动态显示「确认密码」字段。 + // 仅在编辑态监听,新增态确认密码始终显示。 + if (u != null) { + _passwordCtrl.addListener(_onPasswordChanged); + } + + // 编辑时沿用原值;新增时默认选中当前登录用户的组织与角色。 + _roleId = u?.roleId ?? ''; + if (_roleId.isEmpty && isAdd) { + _roleId = widget.currentUserRoleId ?? ''; + } + // 若后端未返回 roleId,仅返回了 roleName/roleNames,尝试按名称匹配角色 ID。 + if (_roleId.isEmpty && u != null) { + final nameToMatch = u.roleName.isNotEmpty + ? u.roleName + : (u.roleNames.isNotEmpty ? u.roleNames.first : ''); + if (nameToMatch.isNotEmpty) { + final matched = _findRoleByName(widget.roles, nameToMatch); + if (matched != null) { + _roleId = matched.roleId; + } + } + } + + _status = u?.status ?? 0; + _orgId = u?.orgId ?? ''; + // 若后端未返回 orgId,仅返回了 orgName,尝试在组织树中按名称匹配组织 ID。 + if (_orgId.isEmpty && u != null && u.orgName.isNotEmpty) { + final matched = _findOrgByName(widget.orgNodes, u.orgName); + if (matched != null) { + _orgId = matched.id; + } + } + if (_orgId.isEmpty && isAdd) { + _orgId = widget.currentUserOrgId ?? ''; + } + _orgOptions = _buildOrgOptions(widget.orgNodes); + } + + /// 按角色名称查找角色,支持不区分大小写与前后空格。 + SysRole? _findRoleByName(List roles, String name) { + final target = name.trim(); + if (target.isEmpty) return null; + final targetLower = target.toLowerCase(); + return roles.cast().firstWhere( + (r) => r!.roleName.trim().toLowerCase() == targetLower, + orElse: () => null, + ); + } + + /// 在组织树/列表中按名称查找节点(递归),支持不区分大小写与前后空格。 + OrgNode? _findOrgByName(List nodes, String name) { + final target = name.trim(); + if (target.isEmpty) return null; + final targetLower = target.toLowerCase(); + for (final node in nodes) { + if (node.name.trim().toLowerCase() == targetLower) return node; + final found = _findOrgByName(node.children, name); + if (found != null) return found; + } + return null; + } + + /// 编辑模式下密码输入变化时的回调:用于动态显示/隐藏「确认密码」字段。 + void _onPasswordChanged() { + if (widget.user != null) setState(() {}); + } + + @override + void dispose() { + _usernameCtrl.dispose(); + _emailCtrl.dispose(); + _mobileCtrl.dispose(); + _passwordCtrl.removeListener(_onPasswordChanged); + _passwordCtrl.dispose(); + _confirmPasswordCtrl.dispose(); + super.dispose(); + } + + /// 编辑模式下,把原角色信息展示为只读文本。 + String _displayRoleForEdit(SysUser user) { + final t = AppLocalizations.of(context); + if (user.roleNames.isNotEmpty) return user.roleNames.join('/'); + if (user.roleName.isNotEmpty) return user.roleName; + if (user.roleId.isNotEmpty) return user.roleId; + return t.unassigned; + } + + /// 把表单内部选项转换为选择器需要的公共数据结构。 + List _toPickerOptions() { + return _orgOptions + .map((o) => OrgPickerOption( + node: o.node, + depth: o.depth, + selectable: true, + )) + .toList(); + } + + /// 根据输入的组织节点(树形或扁平)构建层级有序的下拉选项。 + List<_OrgOption> _buildOrgOptions(List nodes) { + final hasTree = nodes.any((n) => n.children.isNotEmpty); + if (hasTree) { + return _buildHierarchicalOptions(nodes); + } + return _buildHierarchyFromFlatList(nodes); + } + + /// 把组织树递归扁平化为带深度的下拉项。 + List<_OrgOption> _buildHierarchicalOptions(List nodes, + {int depth = 0}) { + final result = <_OrgOption>[]; + for (final node in nodes) { + result.add(_OrgOption(node: node, depth: depth)); + if (node.children.isNotEmpty) { + result.addAll( + _buildHierarchicalOptions(node.children, depth: depth + 1)); + } + } + return result; + } + + /// 当树接口不可用时,根据 parentId 将扁平列表重建为层级顺序。 + List<_OrgOption> _buildHierarchyFromFlatList(List nodes) { + final map = {}; + final roots = []; + for (final node in nodes) { + map[node.id] = node; + } + for (final node in nodes) { + if (node.parentId.isEmpty || !map.containsKey(node.parentId)) { + roots.add(node); + } else { + final parent = map[node.parentId]!; + map[node.parentId] = OrgNode( + id: parent.id, + name: parent.name, + type: parent.type, + parentId: parent.parentId, + children: [...parent.children, node], + address: parent.address, + contactPhone: parent.contactPhone, + activationCode: parent.activationCode, + province: parent.province, + city: parent.city, + themeId: parent.themeId, + ); + } + } + return _buildHierarchicalOptions(roots); + } + + void _submit() { + if (!_formKey.currentState!.validate()) return; + // 后端 status:1=启用/正常,0=禁用。App 内部 _status 以 0=正常、1=禁用 表示, + // 这里转换回后端值再提交,避免状态在保存后被反转。 + final backendStatus = _status == 0 ? 1 : 0; + final data = { + 'username': _usernameCtrl.text.trim(), + 'email': _emailCtrl.text.trim(), + 'mobile': _mobileCtrl.text.trim(), + 'status': backendStatus, + }; + + // 提交角色:后端更新接口以 roleIdList 为准(与 Web 端 saveUser 一致)。 + final u = widget.user; + if (_roleId.isNotEmpty) { + data['roleId'] = _roleId; + data['roleIdList'] = [_roleId]; + } else if (u?.roleId.isNotEmpty == true) { + data['roleId'] = u!.roleId; + data['roleIdList'] = [u.roleId]; + } + if (u != null && u.roleNames.isNotEmpty && data['roleIdList'] == null) { + data['roleName'] = u.roleNames.join(','); + } + + // 提交所属组织(门店 / 渠道商)。 + if (_orgId.isNotEmpty) { + data['orgId'] = _orgId; + } else if (u?.orgId.isNotEmpty == true) { + data['orgId'] = u!.orgId; + } + + if (u != null) { + data['id'] = u.id; + } + if (_passwordCtrl.text.isNotEmpty) { + data['password'] = _passwordCtrl.text; + } + Navigator.pop(context, data); + } + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + final isEdit = widget.user != 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 ? t.editUser : t.addUser, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + TextFormField( + controller: _usernameCtrl, + decoration: InputDecoration( + labelText: t.username, + border: const OutlineInputBorder(), + ), + validator: (v) => + (v == null || v.trim().isEmpty) ? t.usernameRequired : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: _emailCtrl, + decoration: InputDecoration( + labelText: t.email, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: _mobileCtrl, + decoration: InputDecoration( + labelText: t.mobile, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: _passwordCtrl, + obscureText: _obscurePassword, + decoration: InputDecoration( + labelText: t.password, + hintText: isEdit ? t.passwordEditHint : t.passwordRequired, + border: const OutlineInputBorder(), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + ), + onPressed: () => setState(() => _obscurePassword = !_obscurePassword), + ), + ), + validator: (v) { + if (!isEdit && (v == null || v.isEmpty)) { + return t.passwordRequired; + } + return null; + }, + ), + if (!isEdit || _passwordCtrl.text.isNotEmpty) ...[ + const SizedBox(height: 12), + TextFormField( + controller: _confirmPasswordCtrl, + obscureText: _obscureConfirmPassword, + decoration: InputDecoration( + labelText: t.confirmPassword, + hintText: t.confirmPasswordHint, + border: const OutlineInputBorder(), + suffixIcon: IconButton( + icon: Icon( + _obscureConfirmPassword + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + ), + onPressed: () => setState(() => _obscureConfirmPassword = !_obscureConfirmPassword), + ), + ), + validator: (v) { + if (!isEdit || _passwordCtrl.text.isNotEmpty) { + if (v == null || v.isEmpty) { + return t.confirmPasswordRequired; + } + if (v != _passwordCtrl.text) { + return t.passwordsDoNotMatch; + } + } + return null; + }, + ), + ], + const SizedBox(height: 12), + // 所属组织(门店 / 渠道商)选择器:进入编辑页自动展开并高亮已选项。 + if (widget.orgNodes.isEmpty && _orgId.isEmpty) + TextFormField( + initialValue: widget.user?.orgName ?? t.none, + enabled: false, + decoration: InputDecoration( + labelText: t.org, + border: const OutlineInputBorder(), + helperText: t.noOrgHint, + ), + ) + else + OrgPickerFormField( + value: _orgId.isEmpty ? null : _orgId, + labelText: t.org, + options: _toPickerOptions(), + searchHint: t.searchOrgHint, + emptyHint: t.noMatchingOrg, + // 修复:新增/编辑用户时不再自动弹出组织选择器, + // 需先填完表单基本信息、再主动点击「所属组织」字段才展开。 + autoOpen: false, + onChanged: (v) => setState(() => _orgId = v ?? ''), + ), + const SizedBox(height: 12), + // 角色选择器:可视化高亮勾选,编辑时按 roleId 自动选中; + // 新增默认选中当前登录用户角色(原逻辑),亦可不选中。 + if (widget.roles.isEmpty && _roleId.isEmpty && widget.user != null) + TextFormField( + initialValue: _displayRoleForEdit(widget.user!), + enabled: false, + decoration: InputDecoration( + labelText: t.role, + border: const OutlineInputBorder(), + helperText: t.rolesLoadFailed, + ), + ) + else + FormField( + initialValue: _roleId, + validator: (v) { + if (widget.user != null) return null; + return _roleId.isEmpty ? t.roleRequired : null; + }, + builder: (field) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.role, + style: Theme.of(context).textTheme.labelMedium, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: widget.roles.map((role) { + final selected = _roleId == role.roleId; + return ChoiceChip( + showCheckmark: false, + avatar: selected + ? Icon( + Icons.check, + size: 18, + color: Theme.of(context) + .colorScheme + .onPrimary, + ) + : null, + selectedColor: + Theme.of(context).colorScheme.primary, + labelStyle: TextStyle( + color: selected + ? Theme.of(context).colorScheme.onPrimary + : Theme.of(context).colorScheme.onSurface, + ), + selected: selected, + label: Text(role.roleName), + onSelected: (sel) { + setState(() => + _roleId = sel ? role.roleId : ''); + field.didChange(_roleId); + }, + ); + }).toList(), + ), + if (field.hasError) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + field.errorText ?? '', + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: Theme.of(context) + .colorScheme + .error, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + value: _status, + decoration: InputDecoration( + labelText: t.status, + border: const OutlineInputBorder(), + ), + items: [ + DropdownMenuItem(value: 0, child: Text(t.statusNormal)), + DropdownMenuItem(value: 1, child: Text(t.statusDisabled)), + ], + onChanged: (v) => setState(() => _status = v ?? 0), + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(t.cancel), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: _submit, + child: Text(t.save), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/alerts/alerts_page.dart b/lib/screens/alerts/alerts_page.dart new file mode 100644 index 0000000..62c346f --- /dev/null +++ b/lib/screens/alerts/alerts_page.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../models/alert.dart'; +import '../../app/theme.dart'; +import '../../providers/devices_provider.dart'; +import '../../utils/formatters.dart'; + +/// 告警列表页(底部导航第 2 个 tab)。 +class AlertsPage extends ConsumerWidget { + const AlertsPage({super.key}); + + Color _levelColor(BuildContext context, AlertLevel level) { + final semantic = Theme.of(context).extension(); + switch (level) { + case AlertLevel.critical: + return Colors.red; + case AlertLevel.warning: + return Colors.orange; + case AlertLevel.info: + return semantic?.info ?? Theme.of(context).colorScheme.primary; + } + } + + IconData _levelIcon(AlertLevel level) { + switch (level) { + case AlertLevel.critical: + return Icons.error; + case AlertLevel.warning: + return Icons.warning_amber; + case AlertLevel.info: + return Icons.info_outline; + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final alertsAsync = ref.watch(alertsProvider); + + return Scaffold( + appBar: AppBar(title: const Text('告警中心')), + body: alertsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('告警列表加载失败'), + const SizedBox(height: 8), + FilledButton.tonal( + onPressed: () => ref.refresh(alertsProvider), + child: const Text('重试'), + ), + ], + ), + ), + data: (alerts) { + if (alerts.isEmpty) { + return const Center(child: Text('暂无告警')); + } + return RefreshIndicator( + onRefresh: () async => ref.refresh(alertsProvider.future), + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + itemCount: alerts.length, + itemBuilder: (context, index) => + _buildAlertCard(context, alerts[index]), + ), + ); + }, + ), + ); + } + + Widget _buildAlertCard(BuildContext context, Alert alert) { + final color = _levelColor(context, alert.level); + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(_levelIcon(alert.level), color: color, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + alert.title, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + _buildLevelTag(alert, color), + ], + ), + const SizedBox(height: 8), + Text( + alert.message, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: Text( + alert.deviceName, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + Text( + Formatters.formatDateTime(alert.occurredAt), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildLevelTag(Alert alert, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: color.withOpacity(0.15), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + alert.acknowledged ? '${alert.level.label}·已确认' : alert.level.label, + style: TextStyle( + color: color, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} diff --git a/lib/screens/device/device_activation_page.dart b/lib/screens/device/device_activation_page.dart new file mode 100644 index 0000000..f92307c --- /dev/null +++ b/lib/screens/device/device_activation_page.dart @@ -0,0 +1,567 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../models/store_info.dart'; +import '../../services/device_service.dart'; +import '../../services/org_service.dart'; +import '../../services/api_client.dart'; +import '../../utils/formatters.dart'; +import '../../utils/i10n.dart'; + +/// 激活码管理页:门店选择、激活码信息、生成/重置。 +class DeviceActivationPage extends ConsumerStatefulWidget { + const DeviceActivationPage({super.key}); + + @override + ConsumerState createState() => + _DeviceActivationPageState(); +} + +class _DeviceActivationPageState extends ConsumerState { + final OrgService _orgService = OrgService(); + final DeviceService _deviceService = DeviceService(); + + AppLocalizations get t => AppLocalizations.of(context); + + List _stores = []; + String? _selectedStoreId; + bool _loadingStores = true; + + Map? _codeInfo; + bool _loading = false; + String? _error; + + @override + void initState() { + super.initState(); + _loadStores(); + } + + Future _loadStores() async { + try { + final stores = await _orgService.getMyStores(); + if (!mounted) return; + setState(() { + _stores = stores; + _loadingStores = false; + if (stores.isNotEmpty) { + _selectedStoreId = stores.first.orgId; + } + }); + if (_selectedStoreId != null) { + _loadCodeInfo(); + } + } catch (e) { + if (!mounted) return; + setState(() => _loadingStores = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context).storeListLoadFailed(e.toString()))), + ); + } + } + + Future _loadCodeInfo() async { + if (_selectedStoreId == null) return; + setState(() { + _loading = true; + _error = null; + }); + try { + final info = await _deviceService.getActivationCodeInfo( + _selectedStoreId!, + ); + if (!mounted) return; + setState(() { + _codeInfo = info; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = '$e'; + _loading = false; + }); + } + } + + void _showGenerateSheet() { + if (_selectedStoreId == null) return; + showModalBottomSheet( + useSafeArea: true, + context: context, + isScrollControlled: true, + builder: (ctx) => _GenerateCodeSheet( + deviceService: _deviceService, + orgId: _selectedStoreId!, + onSaved: () { + Navigator.pop(ctx); + _loadCodeInfo(); + }, + ), + ); + } + + Future _resetCode() async { + if (_selectedStoreId == null) return; + final t = AppLocalizations.of(context); + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(t.resetActivationCode), + content: Text(t.resetActivationCodeConfirm), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(t.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + style: TextButton.styleFrom(foregroundColor: Colors.orange), + child: Text(t.reset), + ), + ], + ), + ); + if (confirmed != true) return; + + try { + await _deviceService.resetActivationCode(_selectedStoreId!); + if (!mounted) return; + // 结果以页面中央弹窗展示(替代左下方 SnackBar)。 + _showResultDialog( + isSuccess: true, + title: t.activationCodeResetSuccess, + ); + _loadCodeInfo(); + } catch (e) { + if (!mounted) return; + _showResultDialog( + isSuccess: false, + title: t.activationCodeResetFailed(formatApiError(e)), + ); + } + } + + /// 居中弹窗展示激活码重置结果(成功/失败)。 + /// + /// 替代原左下角 SnackBar,保证用户清晰看到操作反馈。 + void _showResultDialog({ + required bool isSuccess, + required String title, + }) { + final t = AppLocalizations.of(context); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + icon: Icon( + isSuccess ? Icons.check_circle_outline : Icons.error_outline, + color: isSuccess ? Colors.green : Colors.red, + size: 42, + ), + title: Center(child: Text(title, textAlign: TextAlign.center)), + actionsAlignment: MainAxisAlignment.center, + actions: [ + FilledButton( + onPressed: () => Navigator.pop(ctx), + child: Text(t.confirm), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + return Scaffold( + appBar: AppBar( + automaticallyImplyLeading: false, + title: Text(t.activationCodeManagement), + ), + body: Column( + children: [ + _buildStoreSelector(), + const Divider(height: 1), + Expanded(child: _buildBody()), + ], + ), + ); + } + + Widget _buildStoreSelector() { + if (_loadingStores) { + return const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ); + } + if (_stores.isEmpty) { + return Padding( + padding: const EdgeInsets.all(16), + child: Text(t.noStoreAvailable), + ); + } + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: DropdownButtonFormField( + value: _selectedStoreId, + decoration: InputDecoration( + labelText: t.selectStore, + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.store), + ), + items: _stores + .map((s) => DropdownMenuItem( + value: s.orgId, + child: Text(s.name), + )) + .toList(), + onChanged: (v) { + setState(() => _selectedStoreId = v); + _loadCodeInfo(); + }, + ), + ); + } + + Widget _buildBody() { + if (_selectedStoreId == null) { + return Center(child: Text(t.pleaseSelectStoreFirst)); + } + if (_loading) { + return const Center(child: CircularProgressIndicator()); + } + if (_error != null) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(t.activationCodeLoadFailed(_error!)), + const SizedBox(height: 8), + FilledButton.tonal( + onPressed: _loadCodeInfo, + child: Text(t.retry), + ), + ], + ), + ); + } + if (_codeInfo == null) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.vpn_key_outlined, + size: 64, + color: Theme.of(context).colorScheme.outline), + const SizedBox(height: 16), + Text(t.noActivationCode), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: _showGenerateSheet, + icon: const Icon(Icons.add), + label: Text(t.generateActivationCode), + ), + ], + ), + ); + } + return RefreshIndicator( + onRefresh: _loadCodeInfo, + child: ListView( + padding: const EdgeInsets.all(16), + physics: const AlwaysScrollableScrollPhysics(), + children: [ + _buildCodeCard(), + const SizedBox(height: 16), + _buildInfoCard(), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: _showGenerateSheet, + icon: const Icon(Icons.refresh), + label: Text(t.generateActivationCode), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton.icon( + onPressed: _resetCode, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.orange, + ), + icon: const Icon(Icons.restart_alt), + label: Text(t.resetActivationCode), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildCodeCard() { + final code = _codeInfo!['code']?.toString() ?? ''; + final theme = Theme.of(context); + return Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.vpn_key, + color: theme.colorScheme.primary, size: 20), + const SizedBox(width: 8), + Text(t.currentActivationCode, + style: theme.textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold)), + ], + ), + const SizedBox(height: 16), + if (code.isEmpty) + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + t.activationCodeNoCodeHint, + style: const TextStyle(color: Colors.grey), + ), + ) + else + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.colorScheme.primary.withOpacity(0.4)), + ), + child: SelectableText( + code, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + fontFamily: 'monospace', + color: theme.colorScheme.onPrimaryContainer, + ), + ), + ), + if (code.isNotEmpty) ...[ + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + onPressed: () { + Clipboard.setData(ClipboardData(text: code)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.activationCodeCopied)), + ); + }, + icon: const Icon(Icons.copy, size: 18), + label: Text(t.copy), + ), + ], + ), + ], + ], + ), + ), + ); + } + + Widget _buildInfoCard() { + final theme = Theme.of(context); + final maxCount = _codeInfo!['maxActivateCount']; + final activatedCount = _codeInfo!['activatedCount']; + final createTime = _codeInfo!['createTime']; + + String _formatTime(dynamic t) { + if (t == null) return '—'; + final text = t.toString().trim(); + if (text.isEmpty) return '—'; + final dt = DateTime.tryParse(text) ?? + DateTime.tryParse(text.replaceFirst(' ', 'T')); + return dt != null ? Formatters.formatDateTime(dt) : text; + } + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + _infoRow( + theme, + t.maxActivateCount, + maxCount?.toString() ?? '—', + ), + _infoRow( + theme, + t.activatedCount, + activatedCount?.toString() ?? '—', + ), + _infoRow(theme, t.createTime, _formatTime(createTime)), + ], + ), + ), + ); + } + + Widget _infoRow(ThemeData theme, String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + SizedBox( + width: 100, + child: Text( + label, + style: theme.textTheme.bodyMedium + ?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ), + Expanded( + child: Text(value, + style: theme.textTheme.bodyMedium + ?.copyWith(fontWeight: FontWeight.w600)), + ), + ], + ), + ); + } +} + +/// 生成激活码表单。 +class _GenerateCodeSheet extends StatefulWidget { + const _GenerateCodeSheet({ + required this.deviceService, + required this.orgId, + required this.onSaved, + }); + + final DeviceService deviceService; + final String orgId; + final VoidCallback onSaved; + + @override + State<_GenerateCodeSheet> createState() => _GenerateCodeSheetState(); +} + +class _GenerateCodeSheetState extends State<_GenerateCodeSheet> { + final _formKey = GlobalKey(); + final _countController = TextEditingController(text: '1'); + bool _saving = false; + + @override + void dispose() { + _countController.dispose(); + super.dispose(); + } + + Future _save() async { + final t = AppLocalizations.of(context); + if (!_formKey.currentState!.validate()) return; + setState(() => _saving = true); + try { + final count = int.parse(_countController.text.trim()); + await widget.deviceService.generateActivationCode( + widget.orgId, + count, + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.activationCodeGenerated)), + ); + widget.onSaved(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.generateFailedWithError(e.toString()))), + ); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + return SingleChildScrollView( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Container( + padding: const EdgeInsets.all(20), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + t.generateActivationCode, + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text( + t.generateActivationCodeHint, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 13, + ), + ), + const SizedBox(height: 20), + TextFormField( + controller: _countController, + decoration: InputDecoration( + labelText: t.maxActivationCountLabel, + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.looks), + helperText: t.maxActivationCountHint, + ), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + validator: (v) { + final count = int.tryParse(v ?? ''); + if (count == null || count <= 0) { + return t.positiveIntegerRequired; + } + return null; + }, + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(t.generate), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/device/device_group_page.dart b/lib/screens/device/device_group_page.dart new file mode 100644 index 0000000..98e1724 --- /dev/null +++ b/lib/screens/device/device_group_page.dart @@ -0,0 +1,488 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../models/org_node.dart'; +import '../../models/store_info.dart'; +import '../../services/device_service.dart'; +import '../../services/org_service.dart'; + +/// 设备分组页:门店选择、分组列表、新增/编辑/删除。 +class DeviceGroupPage extends ConsumerStatefulWidget { + const DeviceGroupPage({super.key}); + + @override + ConsumerState createState() => _DeviceGroupPageState(); +} + +class _DeviceGroupPageState extends ConsumerState { + final OrgService _orgService = OrgService(); + final DeviceService _deviceService = DeviceService(); + final _scrollController = ScrollController(); + + List _stores = []; + String? _selectedStoreId; + bool _loadingStores = true; + + List _items = []; + bool _loading = false; + bool _hasMore = true; + int _page = 1; + String? _error; + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + _loadStores(); + } + + @override + void dispose() { + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent - 200) { + _loadMore(); + } + } + + Future _loadStores() async { + try { + final stores = await _orgService.getMyStores(); + if (!mounted) return; + setState(() { + _stores = stores; + _loadingStores = false; + if (stores.isNotEmpty) { + _selectedStoreId = stores.first.orgId; + } + }); + if (_selectedStoreId != null) { + _loadData(); + } + } catch (e) { + if (!mounted) return; + setState(() => _loadingStores = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('门店列表加载失败:$e')), + ); + } + } + + Future _loadData() async { + if (_selectedStoreId == null) return; + setState(() { + _loading = true; + _error = null; + _page = 1; + _hasMore = true; + }); + try { + final items = await _deviceService.getDeviceGroupList( + page: 1, + limit: 20, + storeId: _selectedStoreId!, + ); + if (!mounted) return; + setState(() { + _items = items; + _hasMore = items.length >= 20; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = '$e'; + _loading = false; + }); + } + } + + Future _loadMore() async { + if (_loading || !_hasMore || _selectedStoreId == null) return; + setState(() => _loading = true); + try { + final items = await _deviceService.getDeviceGroupList( + page: _page + 1, + limit: 20, + storeId: _selectedStoreId!, + ); + if (!mounted) return; + setState(() { + _page++; + _items.addAll(items); + _hasMore = items.length >= 20; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() => _loading = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('加载更多失败:$e')), + ); + } + } + + Future _deleteGroup(OrgNode group) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('删除分组'), + content: Text('确定要删除分组「${group.name}」吗?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('取消'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('删除'), + ), + ], + ), + ); + if (confirmed != true) return; + + try { + await _deviceService.deleteDeviceGroups([group.id]); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('分组已删除')), + ); + _loadData(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('删除失败:$e')), + ); + } + } + + void _showAddEditSheet({OrgNode? group}) { + if (_selectedStoreId == null) return; + showModalBottomSheet( + useSafeArea: true, + context: context, + isScrollControlled: true, + builder: (ctx) => _GroupFormSheet( + deviceService: _deviceService, + storeId: _selectedStoreId!, + group: group, + onSaved: () { + Navigator.pop(ctx); + _loadData(); + }, + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + automaticallyImplyLeading: false, + title: const Text('设备分组'), + ), + body: Column( + children: [ + _buildStoreSelector(), + const Divider(height: 1), + Expanded(child: _buildBody()), + ], + ), + floatingActionButton: _selectedStoreId == null + ? null + : FloatingActionButton( + onPressed: () => _showAddEditSheet(), + child: const Icon(Icons.add), + ), + ); + } + + Widget _buildStoreSelector() { + if (_loadingStores) { + return const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ); + } + if (_stores.isEmpty) { + return const Padding( + padding: EdgeInsets.all(16), + child: Text('暂无可选门店'), + ); + } + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: DropdownButtonFormField( + value: _selectedStoreId, + decoration: const InputDecoration( + labelText: '选择门店', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.store), + ), + items: _stores + .map((s) => DropdownMenuItem( + value: s.orgId, + child: Text(s.name), + )) + .toList(), + onChanged: (v) { + setState(() => _selectedStoreId = v); + _loadData(); + }, + ), + ); + } + + Widget _buildBody() { + if (_selectedStoreId == null) { + return const Center(child: Text('请先选择门店')); + } + if (_loading && _items.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + if (_error != null && _items.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('加载失败:$_error'), + const SizedBox(height: 8), + FilledButton.tonal( + onPressed: _loadData, + child: const Text('重试'), + ), + ], + ), + ); + } + if (_items.isEmpty) { + return RefreshIndicator( + onRefresh: _loadData, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const [ + SizedBox(height: 200), + Center(child: Text('暂无分组数据')), + ], + ), + ); + } + return RefreshIndicator( + onRefresh: _loadData, + child: ListView.builder( + controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), + itemCount: _items.length + 1, + itemBuilder: (context, index) { + if (index == _items.length) { + return _loading + ? const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ) + : const SizedBox.shrink(); + } + final group = _items[index]; + return _GroupListTile( + group: group, + onEdit: () => _showAddEditSheet(group: group), + onDelete: () => _deleteGroup(group), + ); + }, + ), + ); + } +} + +/// 分组列表项。 +class _GroupListTile extends StatelessWidget { + const _GroupListTile({ + required this.group, + required this.onEdit, + required this.onDelete, + }); + + final OrgNode group; + final VoidCallback onEdit; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: ListTile( + leading: const CircleAvatar( + child: Icon(Icons.folder_outlined), + ), + title: Text(group.name), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + if (group.address.isNotEmpty) + _line(Icons.location_on, group.address), + if (group.contactPhone.isNotEmpty) + _line(Icons.phone, group.contactPhone), + _line(Icons.tag, 'ID: ${group.id}'), + ], + ), + trailing: PopupMenuButton( + onSelected: (value) { + if (value == 'edit') { + onEdit(); + } else if (value == 'delete') { + onDelete(); + } + }, + itemBuilder: (ctx) => [ + const PopupMenuItem(value: 'edit', child: Text('编辑')), + const PopupMenuItem(value: 'delete', child: Text('删除')), + ], + ), + ), + ); + } + + Widget _line(IconData icon, String text) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: Row( + children: [ + Icon(icon, size: 14, color: Colors.grey), + const SizedBox(width: 4), + Expanded(child: Text(text, style: const TextStyle(fontSize: 13))), + ], + ), + ); + } +} + +/// 新增/编辑分组表单。 +class _GroupFormSheet extends StatefulWidget { + const _GroupFormSheet({ + required this.deviceService, + required this.storeId, + this.group, + required this.onSaved, + }); + + final DeviceService deviceService; + final String storeId; + final OrgNode? group; + final VoidCallback onSaved; + + @override + State<_GroupFormSheet> createState() => _GroupFormSheetState(); +} + +class _GroupFormSheetState extends State<_GroupFormSheet> { + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + bool _saving = false; + + @override + void initState() { + super.initState(); + if (widget.group != null) { + _nameController.text = widget.group!.name; + } + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _saving = true); + try { + final data = { + 'name': _nameController.text.trim(), + 'storeId': widget.storeId, + if (widget.group != null) 'id': widget.group!.id, + }; + if (widget.group != null) { + await widget.deviceService.updateDeviceGroup(data); + } else { + await widget.deviceService.saveDeviceGroup(data); + } + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(widget.group != null ? '分组已更新' : '分组已创建'), + ), + ); + widget.onSaved(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('保存失败:$e')), + ); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final isEdit = widget.group != null; + return SingleChildScrollView( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Container( + padding: const EdgeInsets.all(20), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + isEdit ? '编辑分组' : '新增分组', + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 20), + TextFormField( + controller: _nameController, + decoration: const InputDecoration( + labelText: '分组名称', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.folder), + ), + validator: (v) => + v == null || v.trim().isEmpty ? '请输入分组名称' : null, + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(isEdit ? '保存' : '创建'), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/device/device_scan_page.dart b/lib/screens/device/device_scan_page.dart new file mode 100644 index 0000000..7952c5b --- /dev/null +++ b/lib/screens/device/device_scan_page.dart @@ -0,0 +1,1023 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import '../../models/device.dart'; +import '../../models/org_node.dart'; +import '../../providers/devices_provider.dart'; +import '../../services/api_client.dart'; +import '../../services/device_service.dart'; +import '../../services/org_service.dart'; +import '../../utils/i10n.dart'; +import '../../utils/store_local_cache.dart'; +import '../../models/store_info.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 扫码页(mobile_scanner 封装,含相册识别)。 +/// +/// 路由参数: +/// - `mode`:`query`(查询设备跳详情)| `bind`(扫码绑定设备到门店/分组); +/// - `deviceId`:bind 模式下已选中设备(本设计从绑定弹窗中取门店); +/// - `storeId` / `storeName` / `groupId`:bind 模式预填目标归属(可选)。 +class DeviceScanPage extends ConsumerStatefulWidget { + const DeviceScanPage({ + super.key, + required this.mode, + this.deviceId, + this.storeId, + this.storeName, + this.groupId, + }); + + /// 模式:query / bind。 + final String mode; + + /// 设备 ID(bind 模式透传)。 + final String? deviceId; + + /// 预填门店 ID(bind 模式)。 + final String? storeId; + + /// 预填门店名称(bind 模式)。 + final String? storeName; + + /// 预填分组 ID(bind 模式)。 + final String? groupId; + + @override + ConsumerState createState() => _DeviceScanPageState(); +} + +class _DeviceScanPageState extends ConsumerState { + final MobileScannerController _controller = MobileScannerController( + detectionSpeed: DetectionSpeed.normal, + ); + bool _handled = false; + + // ========== 自适应放大参数 ========== + /// 条码宽度小于画面宽度的该比例时,视为「过小」并触发放大。 + static const double _kSmallBarcodeThreshold = 0.12; + + /// 条码宽度大于画面宽度的该比例时,视为「足够大」,可逐步缩小。 + static const double _kLargeBarcodeThreshold = 0.30; + + /// 每次放大/缩小的步进(0.0~1.0)。 + static const double _kZoomStep = 0.18; + + /// 最大缩放上限,避免过度放大导致画面抖动或失焦。 + static const double _kMaxZoomScale = 0.90; + + /// 连续检测到「小条码」的帧数阈值。 + static const int _kSmallFramesThreshold = 3; + + /// 连续未检测到条码的帧数阈值。 + static const int _kNoBarcodeFramesThreshold = 6; + + /// 连续检测到「大条码」的帧数阈值。 + static const int _kLargeFramesThreshold = 3; + + /// 缩放调整防抖间隔(毫秒)。 + static const int _kZoomDebounceMs = 700; + + /// 当前目标缩放比例(0.0 = 最小缩放,1.0 = 最大缩放)。 + double _currentZoomScale = 0.0; + + /// 上一次缩放调整时间,用于防抖。 + DateTime? _lastZoomAdjustment; + + /// 用户手动控制缩放后,自适应缩放暂停到此时间点,避免自动逻辑与人手操作打架。 + DateTime? _manualZoomUntil; + + /// 连续检测到小条码/无条码/大条码的帧数。 + int _smallFrames = 0; + int _noBarcodeFrames = 0; + int _largeFrames = 0; + + /// 相机权限/初始化状态:null=检查中, true=就绪, false=无权限或失败。 + bool? _cameraReady; + String? _cameraError; + + /// 扫码进入绑定表单后识别到的 MAC(非空表示当前处于表单态而非相机扫码态)。 + String? _scannedMac; + + /// 激活模式下可选门店列表(bind 模式预填门店,不展示该下拉)。 + List _storeOptions = const []; + + /// 当前选中的门店(bind 模式为预填门店,激活模式为用户选择)。 + StoreInfo? _selectedStore; + + /// 当前门店下的分组列表。 + List _groupOptions = const []; + + /// 当前选中的分组。 + OrgNode? _selectedGroup; + + /// 房号输入。 + final TextEditingController _roomNoController = TextEditingController(); + + /// 表单校验错误文案(空表示无错误)。 + String? _storeError; + String? _groupError; + String? _roomError; + + /// 分组列表加载中标记。 + bool _loadingGroups = false; + + /// 提交中标记(避免重复提交)。 + bool _submitting = false; + + /// 激活模式:没有预填门店时,需在扫码后选择目标门店。 + bool get _isBind => widget.mode == 'bind'; + bool get _isActivate => widget.mode == 'bind' && + (widget.storeId == null || widget.storeId!.isEmpty); + + @override + void initState() { + super.initState(); + _initCamera(); + } + + /// 申请相机权限并启动扫描;失败时给出明确提示而非崩溃。 + Future _initCamera() async { + final t = AppLocalizations.of(context); + try { + final status = await Permission.camera.request(); + if (!mounted) return; + if (!status.isGranted) { + setState(() { + _cameraReady = false; + _cameraError = '${t.cameraPermissionDenied}\n${t.cameraPermissionDeniedHint}'; + }); + return; + } + setState(() => _cameraReady = true); + } catch (e) { + if (!mounted) return; + setState(() { + _cameraReady = false; + _cameraError = '${t.cameraInitializing}: $e'; + }); + } + } + + @override + void dispose() { + _roomNoController.dispose(); + _controller.dispose(); + super.dispose(); + } + + void _onDetect(BarcodeCapture capture) { + if (_handled) return; + final barcode = capture.barcodes.firstOrNull; + _applyAdaptiveZoom(barcode, capture); + final code = barcode?.rawValue; + if (code == null || code.isEmpty) return; + _handlePayload(code); + } + + /// 根据条码在画面中的尺寸自适应调整摄像头缩放。 + /// + /// 规则: + /// - 条码过小(< [_kSmallBarcodeThreshold])或长时间未识别 → 逐步放大; + /// - 条码已足够大(> [_kLargeBarcodeThreshold]) → 逐步恢复默认视图; + /// - 扫描成功后由调用方主动 [_resetAdaptiveZoom] 恢复默认视图。 + void _applyAdaptiveZoom(Barcode? barcode, BarcodeCapture capture) { + if (_scannedMac != null) return; + if (!_controller.value.isRunning) return; + + final now = DateTime.now(); + if (_lastZoomAdjustment != null && + now.difference(_lastZoomAdjustment!).inMilliseconds < _kZoomDebounceMs) { + return; + } + // 用户正在手动控制缩放时暂停自适应,避免自动逻辑与人手操作打架。 + if (_manualZoomUntil != null && now.isBefore(_manualZoomUntil!)) { + return; + } + + double? barcodeWidthRatio; + if (barcode != null && barcode.size.width > 0 && barcode.size.height > 0) { + final maxBarcodeDim = math.max(barcode.size.width, barcode.size.height); + if (maxBarcodeDim <= 1.0) { + // iOS 等平台返回的是归一化坐标(0~1),直接当作画面比例使用。 + barcodeWidthRatio = maxBarcodeDim; + } else { + // Android 等平台返回的是像素坐标,需按预览尺寸归一化。 + final previewSize = capture.size.width > 0 && capture.size.height > 0 + ? capture.size + : _controller.value.size; + if (previewSize.width > 0 && previewSize.height > 0) { + final maxPreviewDim = math.max(previewSize.width, previewSize.height); + barcodeWidthRatio = maxBarcodeDim / maxPreviewDim; + } + } + } + + if (barcodeWidthRatio != null) { + if (barcodeWidthRatio < _kSmallBarcodeThreshold) { + _smallFrames++; + _largeFrames = 0; + _noBarcodeFrames = 0; + } else if (barcodeWidthRatio > _kLargeBarcodeThreshold) { + _largeFrames++; + _smallFrames = 0; + _noBarcodeFrames = 0; + } else { + // 尺寸适中:只重置极端计数,保持当前缩放,避免抖动。 + _smallFrames = 0; + _largeFrames = 0; + } + } else { + _noBarcodeFrames++; + _smallFrames = 0; + _largeFrames = 0; + } + + if (_smallFrames >= _kSmallFramesThreshold || + _noBarcodeFrames >= _kNoBarcodeFramesThreshold) { + unawaited(_zoomIn()); + } else if (_largeFrames >= _kLargeFramesThreshold && _currentZoomScale > 0) { + unawaited(_zoomOut()); + } + } + + /// 用户手动设置缩放比例(Slider / +/- 按钮)。 + /// + /// 手动操作后自适应缩放会暂停数秒,避免立刻被自动逻辑覆盖。 + void _setManualZoom(double value, {bool apply = true}) { + final clamped = value.clamp(0.0, _kMaxZoomScale); + _currentZoomScale = clamped; + _manualZoomUntil = DateTime.now().add(const Duration(seconds: 3)); + _lastZoomAdjustment = DateTime.now(); + if (mounted) setState(() {}); + if (apply) { + unawaited(_applyZoomToCamera()); + } + } + + /// 将 [_currentZoomScale] 同步到相机(带运行态检查与失败静默)。 + Future _applyZoomToCamera() async { + try { + if (_controller.value.isRunning) { + await _controller.setZoomScale(_currentZoomScale); + } + } catch (_) { + // 缩放失败不阻塞扫码流程。 + } + } + + /// 放大一步:增加 [_kZoomStep],不超过 [_kMaxZoomScale]。 + Future _zoomIn() async { + if (_currentZoomScale >= _kMaxZoomScale) return; + _currentZoomScale = math.min(_currentZoomScale + _kZoomStep, _kMaxZoomScale); + _lastZoomAdjustment = DateTime.now(); + _smallFrames = 0; + _noBarcodeFrames = 0; + if (mounted) setState(() {}); + await _applyZoomToCamera(); + } + + /// 缩小一步:减少 [_kZoomStep],不低于 0.0。 + Future _zoomOut() async { + if (_currentZoomScale <= 0) return; + _currentZoomScale = math.max(_currentZoomScale - _kZoomStep, 0.0); + _lastZoomAdjustment = DateTime.now(); + _largeFrames = 0; + if (mounted) setState(() {}); + await _applyZoomToCamera(); + } + + /// 恢复默认视图:重置缩放与所有计数器。 + Future _resetAdaptiveZoom() async { + _currentZoomScale = 0.0; + _smallFrames = 0; + _noBarcodeFrames = 0; + _largeFrames = 0; + _lastZoomAdjustment = null; + _manualZoomUntil = null; + if (mounted) setState(() {}); + try { + await _controller.resetZoomScale(); + } catch (_) { + // 相机未运行时 resetZoomScale 会静默失败,无需处理。 + } + } + + /// 解析扫码 payload:query 模式本地查找设备跳详情; + /// bind 模式弹窗确认绑定门店/分组。 + Future _handlePayload(String payload) async { + if (_handled) return; + _handled = true; + + if (!_isBind) { + await _queryDevice(payload); + return; + } + await _enterBindForm(payload); + } + + /// 查询模式:解析二维码(纯 MAC 或 JSON)后在本地列表匹配,命中跳详情; + /// 本地未命中时再按 MAC/SN 到后端查询,覆盖「设备已激活但尚未进入本地缓存列表」的场景。 + Future _queryDevice(String payload) async { + final t = AppLocalizations.of(context); + final parsed = _parseScanPayload(payload); + // 二维码内容无法识别为任何设备标识(既非 MAC 也非含 mac/id/sn 的 JSON)。 + if (parsed.mac == null && parsed.id == null && parsed.sn == null) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.scanPayloadUnrecognized)), + ); + _handled = false; + return; + } + try { + final devices = await ref.read(devicesProvider.future); + Device? match; + for (final device in devices) { + if (parsed.mac != null && + _normalizeMac(device.mac) == _normalizeMac(parsed.mac!)) { + match = device; + break; + } + if (parsed.id != null && + device.id.toLowerCase() == parsed.id!.toLowerCase()) { + match = device; + break; + } + if (parsed.sn != null && + device.deviceSn.toLowerCase() == parsed.sn!.toLowerCase()) { + match = device; + break; + } + } + if (!mounted) return; + if (match != null) { + await _resetAdaptiveZoom(); + if (!mounted) return; + context.push('/device/${match.id}'); + return; + } + // 本地列表未命中:尝试后端按 MAC/SN 查询(设备可能已激活但本地缓存尚未刷新)。 + final String identifier = parsed.mac ?? parsed.id ?? parsed.sn!; + Device? fresh; + try { + if (parsed.mac != null) { + final list = + await DeviceService().fetchDevices(mac: parsed.mac, limit: 1); + if (list.isNotEmpty) fresh = list.first; + } else if (parsed.sn != null) { + final list = await DeviceService() + .fetchDevices(deviceSn: parsed.sn, limit: 1); + if (list.isNotEmpty) fresh = list.first; + } + // id 仅作本地匹配;后端按 id 查询走 fetchDeviceById 会吞掉登录失效, + // 故此处不再对 id-only 场景做后端兜底。 + } on Object catch (err) { + // 登录态失效需上抛,由外层统一跳转登录页;其余查询异常忽略,走友好提示。 + if (isUnauthorizedError(err)) rethrow; + } + if (!mounted) return; + if (fresh != null) { + // 刷新门店筛选Provider族,确保返回设备列表页能看到该设备。 + ref.invalidate(devicesByStoreProvider); + await _resetAdaptiveZoom(); + if (!mounted) return; + context.push('/device/${fresh.id}'); + return; + } + // 二维码有效但设备暂未就绪(未联网 / 未同步 / 未归属当前账号等)。 + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.scanDeviceNotReady(identifier))), + ); + _handled = false; + } catch (e) { + if (!mounted) return; + // 登录态失效:拦截器已清空令牌并触发路由重定向到登录页, + // 此处不再展示底层错误,直接兜底跳转登录页。 + if (isUnauthorizedError(e)) { + context.go('/login'); + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.scanQueryFailed(formatApiError(e)))), + ); + _handled = false; + } + } + + // ================ 扫码绑定表单(页面内表单,替代原分步弹窗) ================ + + /// 绑定/激活模式:扫码成功后进入页面内表单。 + /// + /// 表单含「门店分组」下拉(从分组列表选取)与「房号」输入框, + /// 由用户在页面上确认门店分组与房号后,再真正写入后端。 + /// - 激活模式(无预填门店):先加载可选门店下拉,选择门店后级联加载其分组; + /// - 绑定模式(路由已预填门店):门店固定展示,直接进入分组选择。 + Future _enterBindForm(String payload) async { + final t = AppLocalizations.of(context); + final mac = _extractMacFromPayload(payload); + if (mac == null || mac.isEmpty) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.scanPayloadNoMac)), + ); + _handled = false; + return; + } + if (!mounted) return; + setState(() => _scannedMac = mac); + // 扫码成功:先恢复默认视图,再停止相机。 + await _resetAdaptiveZoom(); + // 表单态停止相机预览,避免持续占用摄像头。 + await _controller.stop(); + if (!mounted) return; + + if (_isActivate) { + try { + _storeOptions = await OrgService().getMyStores(); + } catch (e) { + if (!mounted) return; + if (isUnauthorizedError(e)) { + context.go('/login'); + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.scanLoadStoresFailed(formatApiError(e)))), + ); + } + } else { + _selectedStore = StoreInfo( + orgId: widget.storeId ?? '', + name: widget.storeName ?? '', + address: '', + contactPhone: '', + activationCode: '', + status: 0, + expireTime: null, + createTime: null, + ); + await _loadGroups(_selectedStore!.orgId, _selectedStore!.name); + } + if (!mounted) return; + setState(() {}); + } + + /// 加载指定门店下的分组列表,供「门店分组」下拉选取。 + Future _loadGroups(String storeId, String storeName) async { + final t = AppLocalizations.of(context); + if (storeId.isEmpty) { + setState(() { + _groupOptions = const []; + _selectedGroup = null; + _groupError = null; + }); + return; + } + setState(() => _loadingGroups = true); + try { + final groups = await DeviceService().getDeviceGroupList(storeId: storeId); + if (!mounted) return; + setState(() { + _groupOptions = groups; + _selectedGroup = null; + _groupError = groups.isEmpty ? t.scanNoGroups(storeName) : null; + }); + } catch (e) { + if (!mounted) return; + if (isUnauthorizedError(e)) { + context.go('/login'); + return; + } + setState(() { + _groupOptions = const []; + _selectedGroup = null; + _groupError = t.scanLoadGroupsFailed(formatApiError(e)); + }); + } finally { + if (mounted) setState(() => _loadingGroups = false); + } + } + + /// 激活模式:用户切换门店后级联重新加载分组。 + Future _onStoreChanged(StoreInfo? store) async { + if (store == null) return; + setState(() { + _selectedStore = store; + _storeError = null; + }); + await _loadGroups(store.orgId, store.name); + } + + /// 校验表单:门店(仅激活模式必填)、门店分组。房号为选填项。 + /// + /// 返回 true 表示校验通过。校验失败时将对应错误文案写入各 *_error 字段, + /// 由输入框下方 errorText 实时展示。房号允许为空,仅当用户填写了内容时才 + /// 校验其格式(中英文/数字/连字符/斜杠,长度 1-20)。 + bool _validateForm() { + final t = AppLocalizations.of(context); + final storeError = (_isActivate && _selectedStore == null) ? t.scanStoreRequired : null; + final groupError = _selectedGroup == null ? t.scanGroupRequired : null; + final trimmedRoom = _roomNoController.text.trim(); + // 房号选填:为空直接通过;仅非空时校验格式。 + final roomError = trimmedRoom.isEmpty + ? null + : (_isRoomNoValid(trimmedRoom) ? null : t.scanRoomInvalid); + setState(() { + _storeError = storeError; + _groupError = groupError; + _roomError = roomError; + }); + return storeError == null && groupError == null && roomError == null; + } + + /// 房号格式:非空、长度 1~20、仅含中英文/数字/连字符/斜杠,不含空白。 + static bool _isRoomNoValid(String room) { + if (room.length > 20) return false; + return RegExp(r'^[一-龥A-Za-z0-9\-\/]+$').hasMatch(room); + } + + /// 提交绑定/激活:校验通过后写入后端并刷新设备列表。 + Future _submitBind() async { + final t = AppLocalizations.of(context); + if (_submitting) return; + if (!_validateForm()) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.scanFormInvalid)), + ); + return; + } + final mac = _scannedMac!; + final store = _selectedStore!; + final group = _selectedGroup!; + final room = _roomNoController.text.trim(); + setState(() => _submitting = true); + try { + await DeviceService().activateDeviceByScan( + mac: mac, + storeId: store.orgId, + groupId: group.id, + roomNo: room, + ); + await StoreLocalCache.instance.saveDeviceBinding( + mac, + store.orgId, + store.name, + group.id, + ); + // 刷新门店筛选Provider族,确保返回设备列表页同步新绑定的设备。 + ref.invalidate(devicesByStoreProvider); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(_isActivate ? t.scanActivateSuccess : t.scanBindSuccess)), + ); + context.pop(); + } catch (e) { + if (!mounted) return; + if (isUnauthorizedError(e)) { + context.go('/login'); + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.scanBindFailed(formatApiError(e)))), + ); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + /// 重置回扫码态:清空表单并重启相机。 + void _resetScan() { + _roomNoController.clear(); + setState(() { + _scannedMac = null; + _storeOptions = const []; + _selectedStore = null; + _groupOptions = const []; + _selectedGroup = null; + _storeError = null; + _groupError = null; + _roomError = null; + _loadingGroups = false; + _submitting = false; + }); + _handled = false; + unawaited(_controller.start().then((_) { + if (mounted) _resetAdaptiveZoom(); + })); + } + + /// 从扫码 payload 中提取 MAC 地址。 + /// + /// 兼容格式: + /// - 纯 MAC 字符串,如 `18:EB:D4:A7:D3:A3` 或 `18-EB-D4-A7-D3-A3`。 + /// - JSON,如 `{"mac":"18:EB:D4:A7:D3:A3"}`。 + /// 提取失败返回 null。 + String? _extractMacFromPayload(String payload) { + final trimmed = payload.trim(); + // 1. 直接是 MAC。 + if (_isMacAddress(trimmed)) return trimmed.toUpperCase(); + // 2. 尝试 JSON。 + try { + final decoded = jsonDecode(trimmed); + if (decoded is Map) { + for (final key in const ['mac', 'macAddress', 'mac_address', 'deviceMac']) { + final value = decoded[key]; + if (value is String && _isMacAddress(value)) { + return value.toUpperCase(); + } + } + } + } catch (_) { + // 忽略解析异常。 + } + return null; + } + + /// 判断 [value] 是否为 MAC 地址格式。 + static bool _isMacAddress(String value) { + return RegExp(r'^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$') + .hasMatch(value.trim()); + } + + /// 归一化 MAC:去分隔符并转大写,便于忽略 `:` / `-` 与大小写差异的比较。 + static String _normalizeMac(String mac) => + mac.toUpperCase().replaceAll(RegExp(r'[^0-9A-F]'), ''); + + /// 解析扫码 payload,提取可用于匹配设备的标识(mac / id / sn)。 + /// + /// 兼容:纯 MAC(含 `:` / `-` 分隔或无分隔)、 + /// JSON({"mac":...}/{"id":...}/{"sn":...}/{"deviceUid":...} 等)。 + ({String? mac, String? id, String? sn}) _parseScanPayload(String payload) { + final trimmed = payload.trim(); + if (trimmed.isEmpty) return (mac: null, id: null, sn: null); + // 1. 纯 MAC。 + if (_isMacAddress(trimmed)) { + return (mac: trimmed.toUpperCase(), id: null, sn: null); + } + // 2. 尝试 JSON。 + try { + final decoded = jsonDecode(trimmed); + if (decoded is Map) { + String? mac; + for (final key in const [ + 'mac', + 'macAddress', + 'mac_address', + 'deviceMac', + ]) { + final value = decoded[key]; + if (value is String && _isMacAddress(value)) { + mac = value.toUpperCase(); + break; + } + } + final idVal = decoded['id'] ?? decoded['deviceId'] ?? decoded['deviceUid']; + final snVal = decoded['sn'] ?? decoded['deviceSn'] ?? decoded['deviceSN']; + return ( + mac: mac, + id: idVal is String && idVal.isNotEmpty ? idVal : null, + sn: snVal is String && snVal.isNotEmpty ? snVal : null, + ); + } + } catch (_) { + // 非 JSON。 + } + return (mac: null, id: null, sn: null); + } + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + final title = _isActivate + ? t.scanTitleActivate + : (_isBind ? t.scanTitleBind : t.scanTitleQuery); + + return Scaffold( + appBar: AppBar( + automaticallyImplyLeading: false, + title: Text(title), + // 表单态提供关闭按钮,等价于「重新扫码」。 + actions: _scannedMac != null + ? [ + IconButton( + icon: const Icon(Icons.close), + tooltip: t.cancel, + onPressed: _resetScan, + ), + ] + : null, + ), + body: _cameraReady != true + ? _buildCameraStatus(t) + : (_scannedMac != null ? _buildBindForm(t) : _buildScanner(t)), + ); + } + + /// 相机未就绪(权限/初始化中/失败)时的占位界面。 + Widget _buildCameraStatus(AppLocalizations t) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _cameraReady == false + ? Icons.camera_alt_outlined + : Icons.hourglass_empty, + size: 56, + color: Theme.of(context).colorScheme.outline, + ), + const SizedBox(height: 16), + Text( + _cameraReady == false + ? (_cameraError ?? t.cameraUnavailable) + : t.cameraInitializing, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ), + if (_cameraReady == false) ...[ + const SizedBox(height: 16), + FilledButton.tonal( + onPressed: () { + setState(() => _cameraReady = null); + _initCamera(); + }, + child: Text(t.retry), + ), + ], + ], + ), + ), + ); + } + + /// 扫码相机界面(含取景框与底部提示)。 + Widget _buildScanner(AppLocalizations t) { + final hint = _isActivate + ? t.scanHintActivate + : (_isBind ? t.scanHintBind : t.scanHintQuery); + return Stack( + children: [ + MobileScanner( + controller: _controller, + onDetect: _onDetect, + errorBuilder: (context, error, child) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 56, color: Colors.red), + const SizedBox(height: 16), + Text( + t.cameraOpenFailed(error.errorCode.name), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + FilledButton.tonal( + onPressed: () => _controller.start(), + child: Text(t.retry), + ), + ], + ), + ), + ); + }, + ), + // 扫码框提示。 + Center( + child: Container( + width: 240, + height: 240, + decoration: BoxDecoration( + border: Border.all(color: Colors.white, width: 2), + borderRadius: BorderRadius.circular(12), + ), + ), + ), + // 手动缩放控制条(Slider + +/- 按钮),相机就绪且运行时才显示。 + Positioned( + bottom: 90, + left: 16, + right: 16, + child: ValueListenableBuilder( + valueListenable: _controller, + builder: (context, state, child) { + if (!state.isInitialized || !state.isRunning) { + return const SizedBox.shrink(); + } + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + IconButton( + icon: const Icon(Icons.remove, color: Colors.white), + tooltip: '缩小', + onPressed: () => _setManualZoom( + _currentZoomScale - _kZoomStep, + ), + ), + Expanded( + child: Slider( + value: _currentZoomScale, + min: 0.0, + max: _kMaxZoomScale, + divisions: (_kMaxZoomScale / _kZoomStep).round(), + activeColor: Colors.white, + inactiveColor: Colors.white38, + onChanged: _setManualZoom, + ), + ), + IconButton( + icon: const Icon(Icons.add, color: Colors.white), + tooltip: '放大', + onPressed: () => _setManualZoom( + _currentZoomScale + _kZoomStep, + ), + ), + ], + ), + Text( + '缩放 ${(_currentZoomScale * 100).toStringAsFixed(0)}%', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ], + ), + ); + }, + ), + ), + Positioned( + bottom: 32, + left: 0, + right: 0, + child: Center( + child: Text( + hint, + style: const TextStyle( + color: Colors.white, + backgroundColor: Colors.black54, + fontSize: 14, + ), + ), + ), + ), + ], + ); + } + + /// 扫码成功后的绑定/激活表单:门店分组下拉 + 房号输入 + 校验与提交。 + Widget _buildBindForm(AppLocalizations t) { + final isActivate = _isActivate; + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 已识别设备(MAC)。 + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t.scanFormDeviceTitle, + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Row( + children: [ + Icon(Icons.qr_code_2, + size: 20, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 8), + Expanded( + child: Text('${t.macAddress}:$_scannedMac', + style: Theme.of(context).textTheme.bodyLarge), + ), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 16), + // 门店(激活模式可下拉选择;绑定模式固定展示)。 + if (isActivate) + DropdownButtonFormField( + value: _selectedStore, + items: _storeOptions + .map((s) => DropdownMenuItem( + value: s, + child: Text(s.name.isNotEmpty ? s.name : s.orgId), + )) + .toList(), + onChanged: _submitting ? null : (v) => _onStoreChanged(v), + decoration: InputDecoration( + labelText: t.selectStore, + border: const OutlineInputBorder(), + errorText: _storeError, + ), + hint: Text(t.pleaseSelect), + ) + else + InputDecorator( + decoration: InputDecoration( + labelText: t.belongStore, + border: const OutlineInputBorder(), + ), + child: Text(_selectedStore?.name.isNotEmpty == true + ? _selectedStore!.name + : (_selectedStore?.orgId ?? '')), + ), + const SizedBox(height: 16), + // 门店分组。 + _loadingGroups + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 14), + child: Row( + children: [ + SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + SizedBox(width: 12), + Text(t.groupListLoading), + ], + ), + ) + : DropdownButtonFormField( + value: _selectedGroup, + items: _groupOptions + .map((g) => DropdownMenuItem( + value: g, + child: Text(g.name.isNotEmpty ? g.name : g.id), + )) + .toList(), + onChanged: _submitting + ? null + : (v) => setState(() => _selectedGroup = v), + decoration: InputDecoration( + labelText: t.scanGroupLabel, + border: const OutlineInputBorder(), + errorText: _groupError, + hintText: t.pleaseSelect, + ), + ), + const SizedBox(height: 16), + // 房号(选填)。 + TextFormField( + controller: _roomNoController, + enabled: !_submitting, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + labelText: t.roomNoOptional, + border: const OutlineInputBorder(), + errorText: _roomError, + hintText: t.scanRoomHint, + ), + onChanged: (_) { + if (_roomError != null) setState(() => _roomError = null); + }, + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _submitting ? null : _submitBind, + child: _submitting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text(_isActivate ? t.scanActivate : t.scanBind), + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: _submitting ? null : _resetScan, + child: Text(t.scanRescan), + ), + ], + ), + ); + } +} diff --git a/lib/screens/device/store_config_page.dart b/lib/screens/device/store_config_page.dart new file mode 100644 index 0000000..d388186 --- /dev/null +++ b/lib/screens/device/store_config_page.dart @@ -0,0 +1,554 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../models/store_info.dart'; +import '../../services/api_client.dart'; +import '../../services/device_service.dart'; +import '../../services/org_service.dart'; +import '../../utils/formatters.dart'; + +/// 门店配置页:门店选择、配置表单、保存/重置密码。 +class StoreConfigPage extends ConsumerStatefulWidget { + const StoreConfigPage({super.key}); + + @override + ConsumerState createState() => _StoreConfigPageState(); +} + +class _StoreConfigPageState extends ConsumerState { + final OrgService _orgService = OrgService(); + final DeviceService _deviceService = DeviceService(); + + List _stores = []; + String? _selectedStoreId; + bool _loadingStores = true; + + Map? _config; + bool _loading = false; + bool _saving = false; + bool _disabled = false; + String? _error; + + // 表单控制器 + final _defaultVolumeController = TextEditingController(); + final _heartbeatIntervalController = TextEditingController(); + final _storePasswordController = TextEditingController(); + final _maxVolumeController = TextEditingController(); + final _offlineThresholdController = TextEditingController(); + final _mediaVolumeController = TextEditingController(); + final _expireTimeController = TextEditingController(); + + @override + void initState() { + super.initState(); + _loadStores(); + } + + @override + void dispose() { + _defaultVolumeController.dispose(); + _heartbeatIntervalController.dispose(); + _storePasswordController.dispose(); + _maxVolumeController.dispose(); + _offlineThresholdController.dispose(); + _mediaVolumeController.dispose(); + _expireTimeController.dispose(); + super.dispose(); + } + + Future _loadStores() async { + try { + final stores = await _orgService.getMyStores(); + if (!mounted) return; + setState(() { + _stores = stores; + _loadingStores = false; + if (stores.isNotEmpty) { + _selectedStoreId = stores.first.orgId; + } + }); + if (_selectedStoreId != null) { + _loadConfig(); + } + } catch (e) { + if (!mounted) return; + setState(() => _loadingStores = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('门店列表加载失败:$e')), + ); + } + } + + Future _loadConfig() async { + if (_selectedStoreId == null) return; + setState(() { + _loading = true; + _error = null; + }); + try { + final config = await _deviceService.getStoreConfig(_selectedStoreId!); + if (!mounted) return; + _populateControllers(config); + setState(() { + _config = config; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + final friendly = formatApiError(e, + fallback: '门店配置加载失败,请稍后重试'); + setState(() { + _error = '$friendly\n\n若持续出现「未知异常」,请联系后端排查 /device/storeConfig/info/{orgId} 接口。'; + _loading = false; + }); + } + } + + void _populateControllers(Map config) { + // 字段名以 Web 后台 StoreConfig 模型为准(systemDefaultVolume / password), + // 同时兼容旧移动端字段名(defaultVolume / storePassword),避免回显为空。 + _defaultVolumeController.text = + _toString(config['systemDefaultVolume'] ?? config['defaultVolume']); + _heartbeatIntervalController.text = + _toString(config['heartbeatInterval']); + _storePasswordController.text = + _toString(config['password'] ?? config['storePassword']); + _maxVolumeController.text = _toString(config['maxVolume']); + _offlineThresholdController.text = + _toString(config['offlineThreshold']); + _mediaVolumeController.text = + _toString(config['mediaVolume'] ?? config['media_volume']); + _expireTimeController.text = _toString(config['expireTime']); + _disabled = _parseDisabled(config['disabled']); + } + + /// 解析禁用状态:true / 非零 num / 字符串 '1' 视为禁用=true,其余 false。 + static bool _parseDisabled(dynamic value) { + if (value == true) return true; + if (value is num) return value != 0; + if (value is String) return value.trim() == '1'; + return false; + } + + static String _toString(dynamic value) { + if (value == null) return ''; + return value.toString(); + } + + Future _save() async { + if (_selectedStoreId == null) return; + setState(() => _saving = true); + try { + // 字段名以 Web 后台 StoreConfig 模型为准(见 screen-front src/api/device.ts): + // systemDefaultVolume / maxVolume / heartbeatInterval / password / expireTime。 + // 仅发送非空字段,避免把后端既有值覆盖为空;密码留空则不传(与 Web 一致, + // 防止误清空)。offlineThreshold 为旧移动端遗留字段,后端忽略无害,保留发送。 + final data = {'orgId': _selectedStoreId}; + final sysDef = _defaultVolumeController.text.trim(); + final vSysDef = int.tryParse(sysDef); + if (sysDef.isNotEmpty && vSysDef != null) { + data['systemDefaultVolume'] = vSysDef; + } + final max = _maxVolumeController.text.trim(); + final vMax = int.tryParse(max); + if (max.isNotEmpty && vMax != null) data['maxVolume'] = vMax; + final hb = _heartbeatIntervalController.text.trim(); + final vHb = int.tryParse(hb); + if (hb.isNotEmpty && vHb != null) data['heartbeatInterval'] = vHb; + final off = _offlineThresholdController.text.trim(); + final vOff = int.tryParse(off); + if (off.isNotEmpty && vOff != null) data['offlineThreshold'] = vOff; + final pwd = _storePasswordController.text.trim(); + if (pwd.isNotEmpty) data['password'] = pwd; + final exp = _expireTimeController.text.trim(); + if (exp.isNotEmpty) data['expireTime'] = exp; + final media = _mediaVolumeController.text.trim(); + final vMedia = int.tryParse(media); + if (media.isNotEmpty && vMedia != null) data['mediaVolume'] = vMedia; + // disabled 始终发送当前开关状态(布尔值),确保与 Web 后台一致。 + data['disabled'] = _disabled; + await _deviceService.createOrUpdateStoreConfig(data); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('配置已保存')), + ); + _loadConfig(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('保存失败:${formatApiError(e)}')), + ); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _resetPassword() async { + if (_selectedStoreId == null) return; + final confirmed = await showDialog( + 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), + style: TextButton.styleFrom(foregroundColor: Colors.orange), + child: const Text('重置'), + ), + ], + ), + ); + if (confirmed != true) return; + + try { + await _deviceService.resetStoreConfigPassword(_selectedStoreId!); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('密码已重置')), + ); + _loadConfig(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('重置失败:${formatApiError(e)}')), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + automaticallyImplyLeading: false, + title: const Text('门店配置'), + ), + body: Column( + children: [ + _buildStoreSelector(), + const Divider(height: 1), + Expanded(child: _buildBody()), + ], + ), + ); + } + + Widget _buildStoreSelector() { + if (_loadingStores) { + return const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ); + } + if (_stores.isEmpty) { + return const Padding( + padding: EdgeInsets.all(16), + child: Text('暂无可选门店'), + ); + } + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: DropdownButtonFormField( + value: _selectedStoreId, + decoration: const InputDecoration( + labelText: '选择门店', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.store), + ), + items: _stores + .map((s) => DropdownMenuItem( + value: s.orgId, + child: Text(s.name), + )) + .toList(), + onChanged: (v) { + setState(() => _selectedStoreId = v); + _loadConfig(); + }, + ), + ); + } + + Widget _buildBody() { + if (_selectedStoreId == null) { + return const Center(child: Text('请先选择门店')); + } + 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: _loadConfig, + child: const Text('重试'), + ), + ], + ), + ); + } + if (_config == null) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.settings_outlined, + size: 64, + color: Theme.of(context).colorScheme.outline), + const SizedBox(height: 16), + const Text('暂无配置信息'), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: _save, + icon: const Icon(Icons.add), + label: const Text('创建配置'), + ), + ], + ), + ); + } + return RefreshIndicator( + onRefresh: _loadConfig, + child: ListView( + padding: const EdgeInsets.all(16), + physics: const AlwaysScrollableScrollPhysics(), + children: [ + _buildConfigForm(), + const SizedBox(height: 16), + _buildExtraInfo(), + const SizedBox(height: 24), + _buildActions(), + ], + ), + ); + } + + Widget _buildConfigForm() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '配置项', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + TextFormField( + controller: _defaultVolumeController, + decoration: const InputDecoration( + labelText: '系统默认音量', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.volume_up), + helperText: '设备系统默认音量 (0-100)', + ), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 12), + TextFormField( + controller: _mediaVolumeController, + decoration: const InputDecoration( + labelText: '媒体区域音量', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.volume_down), + helperText: '媒体播放区域音量 (0-100)', + ), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 12), + TextFormField( + controller: _maxVolumeController, + decoration: const InputDecoration( + labelText: '最大音量', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.volume_up_sharp), + helperText: '设备允许的最大音量', + ), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 12), + TextFormField( + controller: _heartbeatIntervalController, + decoration: const InputDecoration( + labelText: '心跳间隔 (秒)', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.favorite), + helperText: '设备心跳上报间隔', + ), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 12), + TextFormField( + controller: _offlineThresholdController, + decoration: const InputDecoration( + labelText: '离线阈值 (秒)', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.timer_off), + helperText: '超过此时间无心跳则判定离线', + ), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 12), + TextFormField( + controller: _storePasswordController, + decoration: const InputDecoration( + labelText: '门店密码', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.lock), + helperText: '设备绑定门店时的验证密码', + ), + obscureText: true, + ), + const SizedBox(height: 12), + TextFormField( + controller: _expireTimeController, + decoration: InputDecoration( + labelText: '到期时间', + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.event), + helperText: '留空则不修改;格式 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss', + suffixIcon: IconButton( + icon: const Icon(Icons.calendar_month), + tooltip: '选择日期', + onPressed: _saving ? null : _pickExpireDate, + ), + ), + keyboardType: TextInputType.datetime, + ), + const SizedBox(height: 12), + Row( + children: [ + const SizedBox(width: 84, child: Text('禁用')), + SegmentedButton( + segments: const [ + ButtonSegment(value: false, label: Text('否')), + ButtonSegment(value: true, label: Text('是')), + ], + selected: {_disabled}, + onSelectionChanged: (set) => setState(() => _disabled = set.first), + ), + ], + ), + ], + ), + ), + ); + } + + Future _pickExpireDate() async { + final current = _expireTimeController.text.trim(); + final initial = DateTime.tryParse(current) ?? + DateTime.tryParse(current.replaceFirst(' ', 'T')) ?? + DateTime.now(); + final picked = await showDatePicker( + context: context, + initialDate: initial, + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (picked != null && mounted) { + setState(() => + _expireTimeController.text = picked.toIso8601String().substring(0, 10)); + } + } + + Widget _buildExtraInfo() { + final theme = Theme.of(context); + final createTime = _config?['createTime']; + final updateTime = _config?['updateTime']; + + String _fmt(dynamic t) { + if (t == null) return '—'; + final text = t.toString().trim(); + if (text.isEmpty) return '—'; + final dt = DateTime.tryParse(text) ?? + DateTime.tryParse(text.replaceFirst(' ', 'T')); + return dt != null ? Formatters.formatDateTime(dt) : text; + } + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '其他信息', + style: theme.textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + _infoRow(theme, '创建时间', _fmt(createTime)), + _infoRow(theme, '更新时间', _fmt(updateTime)), + ], + ), + ), + ); + } + + Widget _buildActions() { + return Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: _saving ? null : _save, + icon: _saving + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save), + label: const Text('保存配置'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton.icon( + onPressed: _resetPassword, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.orange, + ), + icon: const Icon(Icons.password), + label: const Text('重置密码'), + ), + ), + ], + ); + } + + Widget _infoRow(ThemeData theme, String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + SizedBox( + width: 80, + child: Text( + label, + style: theme.textTheme.bodyMedium + ?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ), + Expanded( + child: Text(value, + style: theme.textTheme.bodyMedium + ?.copyWith(fontWeight: FontWeight.w600)), + ), + ], + ), + ); + } +} diff --git a/lib/screens/home/device_detail_page.dart b/lib/screens/home/device_detail_page.dart new file mode 100644 index 0000000..100a858 --- /dev/null +++ b/lib/screens/home/device_detail_page.dart @@ -0,0 +1,618 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../models/device.dart'; +import '../../models/org_node.dart'; +import '../../providers/devices_provider.dart'; +import '../../providers/metrics_visibility_provider.dart'; +import '../../services/api_client.dart'; +import '../../services/device_service.dart'; +import '../../services/org_service.dart'; +import '../../utils/formatters.dart'; +import '../../utils/i10n.dart'; +import '../../app/theme.dart'; +import '../../utils/responsive.dart'; +import '../../widgets/device_command_sheet.dart'; +import '../../widgets/metric_card.dart'; +import '../../widgets/status_chip.dart'; + +/// 设备详情页:指标卡(CPU/内存/磁盘/网络)、状态与运行时长。 +class DeviceDetailPage extends ConsumerWidget { + const DeviceDetailPage({super.key, required this.deviceId}); + + /// 设备 ID。 + final String deviceId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final deviceAsync = ref.watch(deviceDetailProvider(deviceId)); + final showMetrics = ref.watch(showRuntimeMetricsProvider); + final t = AppLocalizations.of(context); + + return Scaffold( + appBar: AppBar(automaticallyImplyLeading: false, title: Text(t.deviceDetails)), + body: deviceAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('$error'), + const SizedBox(height: 8), + FilledButton.tonal( + onPressed: () => + ref.refresh(deviceDetailProvider(deviceId)), + child: Text(t.retry), + ), + ], + ), + ), + data: (device) => Column( + children: [ + Expanded( + child: RefreshIndicator( + onRefresh: () async => + ref.refresh(deviceDetailProvider(deviceId).future), + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(16), + children: [ + _buildHeader(context, ref, device), + const SizedBox(height: 16), + _buildBasicInfo(context, device), + const SizedBox(height: 16), + _buildConfigParams(context, device), + const SizedBox(height: 16), + _buildNetworkInfo(context, device), + const SizedBox(height: 16), + _buildRuntimeData(context, device), + if (showMetrics) ...[ + const SizedBox(height: 16), + _buildMetricsGrid(context, device), + ], + ], + ), + ), + ), + _buildStickyActions(context, ref, device), + ], + ), + ), + ); + } + + Widget _buildHeader(BuildContext context, WidgetRef ref, Device device) { + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + device.macLabel, + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + if (device.model.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + '${AppLocalizations.of(context).modelLabel}:${device.model}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + StatusChip(status: device.status), + ], + ); + } + + /// 底部常驻操作栏(发送指令 / 编辑分组),滚动时保持可见。 + Widget _buildStickyActions( + BuildContext context, + WidgetRef ref, + Device device, + ) { + final t = AppLocalizations.of(context); + final scheme = Theme.of(context).colorScheme; + return Container( + decoration: BoxDecoration( + color: scheme.surface, + border: Border(top: BorderSide(color: scheme.outlineVariant)), + boxShadow: AppShadows.sm, + ), + padding: const EdgeInsets.fromLTRB(16, 10, 16, 10), + child: SafeArea( + top: false, + child: Row( + children: [ + Expanded( + child: FilledButton.tonal( + onPressed: () => _showCommandSheet(context, device), + child: Text(t.sendCommand), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton.tonal( + onPressed: () => _showEditSheet(context, ref, device), + child: Text(t.editGroup), + ), + ), + ], + ), + ), + ); + } + + /// 信息行标签宽度随窗口尺寸自适应(窄屏 96 / 中屏 128 / 宽屏 160)。 + double _labelWidth(BuildContext context) { + final w = context.responsive.windowSize; + return w == AppWindowSize.compact + ? 96 + : w == AppWindowSize.medium + ? 128 + : 160; + } + + /// 修改设备分组 / 房号。 + void _showEditSheet(BuildContext context, WidgetRef ref, Device device) { + showModalBottomSheet( + useSafeArea: true, + context: context, + isScrollControlled: true, + builder: (ctx) => DeviceGroupRoomSheet( + device: device, + onSaved: (groupId, roomNo) async { + Navigator.pop(ctx); + try { + await DeviceService().updateDevice({ + 'id': device.id, + // 后端 /device/info/update 要求 mac 非空,修改分组/房号时须回传原 MAC。 + 'mac': device.mac, + 'groupId': groupId, + 'roomId': roomNo, + }); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context).saveSuccess)), + ); + ref.refresh(deviceDetailProvider(deviceId)); + } + } on ApiException catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.message)), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${AppLocalizations.of(context).saveFailed}:${formatApiError(e)}')), + ); + } + } + }, + ), + ); + } + + void _showCommandSheet(BuildContext context, Device device) { showModalBottomSheet( + useSafeArea: true, + context: context, + isScrollControlled: true, + builder: (ctx) => DeviceCommandSheet( + device: device, + deviceService: DeviceService(), + ), + ); + } + + /// 带标题的分区块卡片。 + Widget _sectionCard( + BuildContext context, + String title, + List rows, + ) { + final theme = Theme.of(context); + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + const Divider(height: 1), + const SizedBox(height: 8), + ...rows, + ], + ), + ), + ); + } + + /// 基本信息:设备名称 / 设备 ID / 型号 / 状态。 + Widget _buildBasicInfo(BuildContext context, Device device) { + final t = AppLocalizations.of(context); + final theme = Theme.of(context); + return _sectionCard(context, t.basicInfo, [ + _infoRow(context, theme, t.deviceName, + device.macLabel.isEmpty ? t.none : device.macLabel), + _infoRow(context, theme, t.deviceId, + device.id.isEmpty ? t.none : device.id), + _infoRow(context, theme, t.modelLabel, + device.model.isEmpty ? t.none : device.model), + Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: _labelWidth(context), + child: Text( + t.status, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded(child: StatusChip(status: device.status)), + ], + ), + ), + ]); + } + + /// 配置参数:所属门店 / 分组 / 房号 / 序列号 / UID / 创建时间。 + Widget _buildConfigParams(BuildContext context, Device device) { + final t = AppLocalizations.of(context); + final theme = Theme.of(context); + return _sectionCard(context, t.configParams, [ + _infoRow(context, theme, t.belongStore, + device.storeName.isEmpty ? t.none : device.storeName), + _infoRow(context, theme, t.belongGroup, + device.groupId.isEmpty ? t.notGrouped : device.groupId), + _infoRow(context, theme, t.roomNo, + device.roomNo.isEmpty ? t.none : device.roomNo), + _infoRow(context, theme, t.deviceSnLabel, + device.deviceSn.isEmpty ? t.none : device.deviceSn), + _infoRow(context, theme, t.deviceUidLabel, + device.deviceUid.isEmpty ? t.none : device.deviceUid), + _infoRow(context, theme, t.createTime, + device.createTime != null + ? Formatters.formatDateTime(device.createTime!) + : t.none), + ]); + } + + /// 网络信息:IP 地址 / MAC 地址。 + Widget _buildNetworkInfo(BuildContext context, Device device) { + final t = AppLocalizations.of(context); + final theme = Theme.of(context); + return _sectionCard(context, t.networkInfo, [ + _infoRow(context, theme, t.ipAddress, + device.ip.isEmpty ? t.none : device.ip), + _infoRow(context, theme, t.macAddress, + device.mac.isEmpty ? t.none : device.mac), + ]); + } + + /// 运行时数据:在线时长 / 最后活跃时间。 + Widget _buildRuntimeData(BuildContext context, Device device) { + final t = AppLocalizations.of(context); + final theme = Theme.of(context); + return _sectionCard(context, t.runtimeData, [ + _infoRow(context, theme, t.uptime, + device.uptimeSeconds > 0 + ? t.formatUptime(device.uptimeSeconds) + : t.none), + _infoRow(context, theme, t.lastActiveTime, + device.lastSyncTime != null + ? Formatters.formatDateTime(device.lastSyncTime!) + : t.none), + ]); + } + + Widget _infoRow(BuildContext context, ThemeData theme, String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: _labelWidth(context), + child: Text( + label, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + value, + style: theme.textTheme.bodyMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } + + Widget _buildMetricsGrid(BuildContext context, Device device) { + final t = AppLocalizations.of(context); + final metrics = device.metrics; + // 真实后端不提供运行指标:无指标数据时显示 “—”(仅 mock 模式有值)。 + final hasMetrics = device.hasMetrics; + final none = t.none; + return GridView.count( + crossAxisCount: context.responsive.gridCount(minTileWidth: 300), + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 1.15, + children: [ + MetricCard( + icon: Icons.memory, + title: t.cpuUsage, + value: hasMetrics ? Formatters.formatPercent(metrics.cpuUsage) : none, + progress: hasMetrics ? metrics.cpuUsage : null, + accentColor: + hasMetrics && metrics.cpuUsage > 0.8 ? Colors.red : null, + ), + MetricCard( + icon: Icons.storage, + title: t.memoryUsage, + value: + hasMetrics ? Formatters.formatPercent(metrics.memoryUsage) : none, + progress: hasMetrics ? metrics.memoryUsage : null, + accentColor: + hasMetrics && metrics.memoryUsage > 0.85 ? Colors.red : null, + ), + MetricCard( + icon: Icons.sd_storage, + title: t.diskUsage, + value: + hasMetrics ? Formatters.formatPercent(metrics.diskUsage) : none, + progress: hasMetrics ? metrics.diskUsage : null, + accentColor: + hasMetrics && metrics.diskUsage > 0.8 ? Colors.orange : null, + ), + MetricCard( + icon: Icons.network_check, + title: t.networkRxTx, + value: hasMetrics + ? '${metrics.networkRxKbps.toStringAsFixed(1)} / ' + '${metrics.networkTxKbps.toStringAsFixed(1)} KB/s' + : none, + ), + ], + ); + } +} + +/// 修改设备分组 / 房号底部表单。 +/// +/// 分组改为「分组名称」下拉选择(与 Web 端一致):进入时按设备所属门店调用 +/// `/sys/store/deviceGroups/{storeId}` 拉取该门店下专属分组,下拉显示 [OrgNode.name], +/// 提交 [OrgNode.id]。房号仍保留文本输入。 +class DeviceGroupRoomSheet extends StatefulWidget { + const DeviceGroupRoomSheet({ + super.key, + required this.device, + required this.onSaved, + }); + + final Device device; + final Future Function(String groupId, String roomNo) onSaved; + + @override + State createState() => _DeviceGroupRoomSheetState(); +} + +class _DeviceGroupRoomSheetState extends State { + final _formKey = GlobalKey(); + late final TextEditingController _roomNoCtrl; + bool _saving = false; + + /// 当前门店下的设备分组列表。 + List _groups = const []; + bool _groupsLoading = false; + String? _groupsError; + + /// 选中的分组 ID(null 表示「未分组」)。 + String? _selectedGroupId; + + @override + void initState() { + super.initState(); + _roomNoCtrl = TextEditingController(text: widget.device.roomNo); + _selectedGroupId = + widget.device.groupId.isEmpty ? null : widget.device.groupId; + _loadGroups(); + } + + @override + void dispose() { + _roomNoCtrl.dispose(); + super.dispose(); + } + + /// 拉取当前门店下的设备分组列表。 + /// + /// 优先使用 `/device/group/list?storeId=xxx`,该接口支持按门店筛选且已在 + /// [DeviceService] 中实现;若调用失败则降级为读取「当前分组」一项,避免阻塞 + /// 用户修改房号或保存现有分组。 + Future _loadGroups() async { + if (widget.device.storeId.isEmpty) { + if (mounted) setState(() => _groupsLoading = false); + return; + } + if (mounted) setState(() => _groupsLoading = true); + try { + _groups = await DeviceService().getDeviceGroupList( + storeId: widget.device.storeId, + ); + _groupsError = null; + } on ApiException catch (e) { + _groupsError = e.message; + _groups = const []; + } catch (e) { + _groupsError = formatApiError(e, fallback: '分组加载失败,请检查网络'); + _groups = const []; + } finally { + if (mounted) setState(() => _groupsLoading = false); + } + } + + /// 构造下拉项:首项「未分组」,随后各分组(显示名称,值为 ID)。 + /// 若当前分组不在列表中(如后端未返回或跨门店),追加一项以便正确回显。 + List> _buildGroupItems(AppLocalizations t) { + final items = >[ + DropdownMenuItem( + value: '', + child: Text(t.notGrouped), + ), + ]; + final seen = {''}; + for (final group in _groups) { + if (seen.contains(group.id)) continue; + seen.add(group.id); + items.add( + DropdownMenuItem( + value: group.id, + child: Text(group.name.isNotEmpty ? group.name : group.id), + ), + ); + } + final current = widget.device.groupId; + if (current.isNotEmpty && !seen.contains(current)) { + items.add( + DropdownMenuItem( + value: current, + child: Text(current), + ), + ); + } + return items; + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _saving = true); + try { + await widget.onSaved( + _selectedGroupId ?? '', + _roomNoCtrl.text.trim(), + ); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + return SingleChildScrollView( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Container( + padding: const EdgeInsets.all(20), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${t.editGroup} / ${t.roomNo}', + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + // 所属分组:名称下拉选择(提交分组 ID)。 + DropdownButtonFormField( + value: _selectedGroupId ?? '', + isExpanded: true, + decoration: InputDecoration( + labelText: t.belongGroup, + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.dvr), + ), + items: _buildGroupItems(t), + onChanged: _groupsLoading + ? null + : (value) => setState(() => _selectedGroupId = value), + ), + const SizedBox(height: 8), + if (_groupsLoading) + Padding( + padding: const EdgeInsets.only(left: 4), + child: Row( + children: [ + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 8), + Text(t.groupListLoading), + ], + ), + ) + else if (_groupsError != null) + Padding( + padding: const EdgeInsets.only(left: 4), + child: Text( + '${t.loadFailed}:$_groupsError', + style: const TextStyle(color: Colors.redAccent, fontSize: 12), + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: _roomNoCtrl, + decoration: InputDecoration( + labelText: t.roomNo, + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.meeting_room), + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _saving ? null : _submit, + child: _saving + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(t.save), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/home/device_list_page.dart b/lib/screens/home/device_list_page.dart new file mode 100644 index 0000000..47bbb8f --- /dev/null +++ b/lib/screens/home/device_list_page.dart @@ -0,0 +1,492 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:go_router/go_router.dart'; + +import '../../models/device.dart'; +import '../../models/org_node.dart'; +import '../../models/store_info.dart'; +import '../../providers/devices_provider.dart'; +import '../../services/api_client.dart'; +import '../../services/org_service.dart'; +import '../../utils/i10n.dart'; +import '../../utils/session_org_scope.dart'; +import '../../widgets/device_list_tile.dart'; +import '../../widgets/org_tree_filter.dart'; + +/// 设备列表页(底部导航第 2 个 tab),支持关键字搜索与组织树筛选。 +class DeviceListPage extends ConsumerStatefulWidget { + const DeviceListPage({super.key}); + + @override + ConsumerState createState() => _DeviceListPageState(); +} + +class _DeviceListPageState extends ConsumerState + with WidgetsBindingObserver { + final _searchController = TextEditingController(); + String _keyword = ''; + + final _modelController = TextEditingController(); + String _modelKeyword = ''; + + /// 门店名称搜索关键字:按所属门店名称过滤设备,与门店管理页的「搜索门店名称」对齐。 + final _storeNameController = TextEditingController(); + String _storeNameKeyword = ''; + + /// 可选门店列表(异步加载后填充,用于受限账号默认范围及门店名称反查)。 + List _stores = []; + + /// 设备列表(页面态,替代 devicesByStoreProvider 的 watch)。 + List _devices = []; + bool _loadingDevices = false; + Object? _deviceError; + + /// 组织树筛选选中的节点 ID 集合(空集表示不过滤)。 + Set _selectedOrgIds = {}; + + /// 组织树(异步加载,失败则置空,树筛选不可用)。 + List? _orgTree; + + /// 已发起删除、等待后端确认期间临时隐藏的设备 ID,避免行先回弹再消失。 + final Set _pendingDeleteIds = {}; + + /// 用于跳过首次启动时的 resumed 事件,避免与 initState 初始加载重复请求。 + bool _initialResumeHandled = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _initData(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _searchController.dispose(); + _modelController.dispose(); + _storeNameController.dispose(); + super.dispose(); + } + + /// 初始化:加载门店列表(受限账号默认首店)、组织树,随后拉取设备。 + Future _initData() async { + await _loadStores(); + if (!mounted) return; + await _loadOrgTree(); + if (!mounted) return; + await _loadDevices(); + } + + /// 应用从后台切回前台时刷新设备/门店列表,以同步 Web 端等外部变更。 + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + if (state != AppLifecycleState.resumed) return; + if (!_initialResumeHandled) { + _initialResumeHandled = true; + return; + } + _loadStores(); + _loadDevices(); + } + + Future _loadStores() async { + // 静默加载门店列表,失败不阻断设备列表。 + try { + _stores = await OrgService().getMyStores(); + } catch (e) { + _stores = []; + } finally { + if (mounted) setState(() {}); + } + } + + /// 异步加载组织树(静默失败:仅影响组织树筛选,不影响设备列表本身)。 + Future _loadOrgTree() async { + try { + _orgTree = await OrgService().getOrgTree(); + } catch (_) { + _orgTree = null; + } + } + + /// 按组织树筛选 / 受限账号 / 全部门店拉取设备,并叠加本地型号/关键词过滤。 + Future _loadDevices() async { + setState(() { + _loadingDevices = true; + _deviceError = null; + }); + try { + final deviceService = ref.read(deviceServiceProvider); + List devices; + + if (_selectedOrgIds.isEmpty) { + // 未选组织树: + if (SessionOrgScope.instance.enabled) { + // 受限账号:不能有「全部门店」视图(否则会拉取跨组织设备)。 + // 改为拉取其组织范围内【全部门店】的设备(而非仅第一家), + // 既见全本组织设备、又不越权。门店列表本就按范围收敛,取其 orgId 集合即可。 + final storeIds = _stores + .map((s) => s.orgId) + .where((id) => id.isNotEmpty) + .toSet(); + if (storeIds.isEmpty) { + // 异常兜底:连一家门店都没有时,不调用全量接口,避免越权拉取跨组织设备。 + devices = const []; + } else { + devices = await deviceService.fetchDevicesByOrgSet(storeIds); + } + } else { + // 管理员:全量设备。 + devices = await deviceService.fetchDevices(); + } + } else { + // 组织树筛选:仅收集每个选中节点的【门店(type=3)后代】orgId。 + final storeIds = {}; + for (final id in _selectedOrgIds) { + storeIds.addAll( + OrgService.collectDescendantStoreIds(_orgTree ?? [], id), + ); + } + if (storeIds.isEmpty) { + // 选中的节点没有任何门店后代(如设备分组 / 无下属门店的空代理): + // 直接返回空结果——绝不回退为「用节点自身 id 充当 storeOrgId」, + // 也绝不退化为「全量设备」(fetchDevicesByOrgSet 空集会退化为跨组织全量)。 + devices = const []; + } else { + devices = await deviceService.fetchDevicesByOrgSet(storeIds); + } + } + if (!mounted) return; + _devices = devices; + setState(() => _loadingDevices = false); + } catch (e) { + if (!mounted) return; + _deviceError = e; + setState(() => _loadingDevices = false); + } + } + + /// 打开组织树筛选抽屉,回填选中集合并重新拉取设备。 + /// + /// 返回 null(取消)时保持原筛选不变;返回非空集合(含空集合)时覆盖 [_selectedOrgIds]。 + Future _openOrgFilter() async { + final result = await showOrgTreeFilterSheet( + context, + initialSelected: _selectedOrgIds, + ); + if (result == null) return; + setState(() => _selectedOrgIds = result); + await _loadDevices(); + } + + /// 按门店名称搜索:保存关键字到 [_storeNameKeyword] 并触发本地过滤。 + /// 搜索结果由 [_filter] 在构建时实时应用,并在下拉刷新/生命周期刷新后保持。 + void _onStoreSearch() { + setState(() { + _storeNameKeyword = _storeNameController.text.trim(); + }); + } + + /// 取设备所属门店的展示名称(优先 [Device.storeName],其次按 storeId 在 [_stores] 中反查)。 + String _deviceStoreName(Device device) { + if (device.storeName.isNotEmpty) return device.storeName; + for (final s in _stores) { + if (s.orgId == device.storeId) return s.name; + } + return ''; + } + + /// 组织树筛选按钮文案:空选显示「全部门店/全部组织」; + /// 有选显示首个选中节点名 + 超量「(+N)」。 + String _orgFilterLabel() { + if (_selectedOrgIds.isEmpty) return '全部门店/全部组织'; + if (_orgTree == null) return '已选 ${_selectedOrgIds.length} 项'; + final byId = {}; + void index(List ns) { + for (final n in ns) { + byId[n.id] = n; + index(n.children); + } + } + + index(_orgTree!); + final names = []; + for (final id in _selectedOrgIds) { + final n = byId[id]; + if (n != null) names.add(n.name); + } + if (names.isEmpty) return '已选 ${_selectedOrgIds.length} 项'; + if (names.length == 1) return names.first; + return '${names.first} (+${names.length - 1})'; + } + + List _filter(List devices) { + final keyword = _keyword.trim().toLowerCase(); + final model = _modelKeyword.trim().toLowerCase(); + final storeName = _storeNameKeyword.trim().toLowerCase(); + // 过滤掉已发起删除、正在等待后端确认的设备(用与行 key 一致的唯一标识)。 + final visible = devices.where((d) => !_pendingDeleteIds.contains(_rowKey(d))); + if (keyword.isEmpty && model.isEmpty && storeName.isEmpty) { + return visible.toList(); + } + return visible.where((device) { + final matchesKeyword = keyword.isEmpty || + device.mac.toLowerCase().contains(keyword) || + device.name.toLowerCase().contains(keyword) || + device.roomNo.toLowerCase().contains(keyword) || + device.ip.contains(keyword); + final matchesModel = model.isEmpty || + device.model.toLowerCase().contains(model) || + device.deviceSn.toLowerCase().contains(model); + final matchesStoreName = storeName.isEmpty || + _deviceStoreName(device).toLowerCase().contains(storeName); + return matchesKeyword && matchesModel && matchesStoreName; + }).toList(); + } + + /// 行唯一键:优先用设备 id,缺失时回退 mac,避免空 id 导致 ValueKey('') 重复触发断言。 + String _rowKey(Device d) => '${d.id.isNotEmpty ? d.id : ''}__${d.mac}'; + + /// 滑动后的删除确认:弹出对话框,确认才调用后端删除,取消保留。 + Future _confirmDeleteDevice(Device device) async { + final t = AppLocalizations.of(context); + final name = device.macRoomLabel; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(t.confirmDelete), + content: Text(t.deleteConfirmContent(name)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(t.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(t.delete), + ), + ], + ), + ); + if (confirmed != true) return false; + // 先本地隐藏,避免删除过程中行回弹再消失造成闪烁。 + final key = _rowKey(device); + _pendingDeleteIds.add(key); + if (mounted) setState(() {}); + try { + await ref.read(deviceServiceProvider).deleteDevice(device.id); + if (!mounted) return true; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.deleted)), + ); + await _loadDevices(); + _pendingDeleteIds.remove(key); + if (mounted) setState(() {}); + return true; + } catch (e) { + _pendingDeleteIds.remove(key); + if (!mounted) return false; + setState(() {}); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${t.deleteFailed}:${formatApiError(e)}')), + ); + return false; + } + } + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + + return Scaffold( + appBar: AppBar( + automaticallyImplyLeading: false, + title: Text(t.deviceList), + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => context.push('/device/scan?mode=bind&activate=1'), + icon: const Icon(Icons.add_a_photo), + label: Text(t.scanActivateDevice), + ), + body: Column( + children: [ + // 顶部筛选区:与门店管理页保持一致。 + // 第一行:门店名称搜索 + 独立组织筛选图标;第二行:设备关键字/型号搜索。 + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Column( + children: [ + // 门店名称搜索 + 组织筛选图标(独立,不与其它筛选项混排)。 + Row( + children: [ + Expanded( + child: TextField( + controller: _storeNameController, + decoration: InputDecoration( + hintText: t.searchStoreHint, + prefixIcon: const Icon(Icons.search_outlined), + suffixIcon: _storeNameController.text.isEmpty + ? null + : IconButton( + icon: const Icon(Icons.clear_outlined), + onPressed: () { + _storeNameController.clear(); + setState(() => _storeNameKeyword = ''); + }, + ), + ), + onSubmitted: (_) => _onStoreSearch(), + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.account_tree_outlined), + tooltip: '组织筛选', + onPressed: _openOrgFilter, + ), + ], + ), + const SizedBox(height: 8), + // 设备关键字 / 型号搜索。 + Row( + children: [ + Expanded( + flex: 3, + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: t.searchDeviceHint, + prefixIcon: const Icon(Icons.search), + suffixIcon: _keyword.isEmpty + ? null + : IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + setState(() => _keyword = ''); + }, + ), + ), + onChanged: (value) => setState(() => _keyword = value), + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 2, + child: TextField( + controller: _modelController, + decoration: InputDecoration( + hintText: t.searchModelHint, + prefixIcon: const Icon(Icons.memory), + suffixIcon: _modelKeyword.isEmpty + ? null + : IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _modelController.clear(); + setState(() => _modelKeyword = ''); + }, + ), + ), + onChanged: (value) => + setState(() => _modelKeyword = value), + ), + ), + ], + ), + if (_selectedOrgIds.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Wrap( + children: [ + Chip( + label: Text(_orgFilterLabel()), + deleteIcon: const Icon(Icons.close, size: 18), + onDeleted: () { + setState(() { + _selectedOrgIds = {}; + }); + _loadDevices(); + }, + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 8), + Expanded(child: _buildDeviceList()), + ], + ), + ); + } + + /// 根据页面态([_devices]/[_loadingDevices]/[_deviceError])渲染设备列表。 + Widget _buildDeviceList() { + final t = AppLocalizations.of(context); + if (_loadingDevices) { + return const Center(child: CircularProgressIndicator()); + } + if (_deviceError != null) { + // 登录态失效:拦截器已清空令牌并触发路由重定向到登录页, + // 此处不再展示底层错误,直接显示加载占位(路由会接管跳转)。 + if (isUnauthorizedError(_deviceError!)) { + return const Center(child: CircularProgressIndicator()); + } + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(t.deviceListLoadFailed(formatApiError(_deviceError!))), + const SizedBox(height: 8), + FilledButton.tonal( + onPressed: _loadDevices, + child: Text(t.retry), + ), + ], + ), + ); + } + final filtered = _filter(_devices); + if (filtered.isEmpty) { + return Center(child: Text(t.noMatchedDevices)); + } + return RefreshIndicator( + onRefresh: _loadDevices, + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + itemCount: filtered.length, + itemBuilder: (context, index) { + final device = filtered[index]; + // 向左滑动(end side)揭示右侧红色「删除」按钮;点击按钮才弹确认框。 + // 不用 Dismissible:后者松手过阈值即自动弹窗,无法满足「先露按钮再点击」的交互。 + return Slidable( + key: ValueKey(_rowKey(device)), + endActionPane: ActionPane( + motion: const ScrollMotion(), + // 不自动 dismiss,删除以「确认弹窗 + 后端成功」为准。 + dismissible: null, + children: [ + SlidableAction( + onPressed: (_) => _confirmDeleteDevice(device), + backgroundColor: Colors.red, + foregroundColor: Colors.white, + icon: Icons.delete_outline, + label: t.delete, + ), + ], + ), + child: DeviceListTile( + device: device, + onTap: () => context.push('/device/${device.id}'), + ), + ); + }, + ), + ); + } +} diff --git a/lib/screens/home/home_page.dart b/lib/screens/home/home_page.dart new file mode 100644 index 0000000..7de9cf8 --- /dev/null +++ b/lib/screens/home/home_page.dart @@ -0,0 +1,261 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../models/device.dart'; +import '../../providers/dashboard_provider.dart'; +import '../../providers/devices_provider.dart'; +import '../../app/theme.dart'; +import '../../utils/i10n.dart'; +import '../../utils/responsive.dart'; +import '../../providers/auth_provider.dart'; +import '../../utils/app_update.dart'; +import '../../utils/permissions.dart'; +import '../../widgets/cloud_card.dart'; + +/// 首页:仪表盘概览(底部导航第 1 个 tab)。 +/// +/// 展示设备统计卡片与各模块快捷入口。 +class HomePage extends ConsumerStatefulWidget { + const HomePage({super.key}); + + @override + ConsumerState createState() => _HomePageState(); +} + +class _HomePageState extends ConsumerState { + @override + void initState() { + super.initState(); + // 加载仪表盘统计(设备/门店总数)。 + Future.microtask( + () => ref.read(dashboardStatsProvider.notifier).load(), + ); + // 启动后静默检测新版本(无更新/失败均不打扰,仅会话内首次有更新时弹窗)。 + final orgId = ref.read(authProvider).user?.orgId; + final ctx = context; + Future.microtask(() { + if (!ctx.mounted) return; + checkAndPromptUpdate( + ctx, + silentIfNoUpdate: true, + orgId: orgId, + ); + }); + } + + @override + Widget build(BuildContext context) { + final devicesAsync = ref.watch(devicesProvider); + final t = AppLocalizations.of(context); + + return Scaffold( + appBar: AppBar(title: Text(t.appTitle)), + body: devicesAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, __) => _buildDashboard(context, null), + data: (devices) => _buildDashboard(context, devices), + ), + ); + } + + Widget _buildDashboard(BuildContext context, List? devices) { + final stats = ref.watch(dashboardStatsProvider); + final totalCount = stats.deviceTotal; + final storeTotal = stats.storeTotal; + // 在线/离线统计改为后端真实聚合值(按 status 拉 totalCount), + // 不再用首屏列表客户端统计,避免与设备总数(12 万级)严重不符。 + final onlineCount = stats.onlineCount; + final offlineCount = stats.offlineCount; + final t = AppLocalizations.of(context); + + // 统计卡配色纳入橙白语义体系(仅改色,图标图形与 size 冻结不动)。 + final scheme = Theme.of(context).colorScheme; + final semantic = Theme.of(context).extension(); + + return RefreshIndicator( + onRefresh: () async { + final _ = await ref.refresh(devicesProvider.future); + await ref.read(dashboardStatsProvider.notifier).load(); + }, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(16), + children: [ + _buildBrandBanner(context), + const SizedBox(height: 16), + // 统计卡片 + GridView.count( + crossAxisCount: context.responsive.gridCount(minTileWidth: 300), + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 1.4, + children: [ + _StatCard( + title: t.totalDevices, + value: '$totalCount', + icon: Icons.devices, + color: scheme.primary, + ), + _StatCard( + title: t.totalStores, + value: '$storeTotal', + icon: Icons.store, + color: scheme.tertiary, + ), + _StatCard( + title: t.onlineDevices, + value: '$onlineCount', + icon: Icons.check_circle_outline, + color: semantic?.success ?? scheme.tertiary, + ), + _StatCard( + title: t.offlineDevices, + value: '$offlineCount', + icon: Icons.highlight_off, + color: scheme.onSurfaceVariant, + ), + ], + ), + const SizedBox(height: 24), + // 快捷操作 + Text( + t.quickActions, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _QuickAction( + label: t.deviceManagement, + icon: Icons.devices, + onTap: () => context.go('/device'), + ), + ), + Expanded( + child: _QuickAction( + label: t.storeManagement, + icon: Icons.store, + onTap: () => context.go('/store'), + ), + ), + if (SessionPermissions.canViewUserManagement( + ref.read(authProvider).user)) + Expanded( + child: _QuickAction( + label: t.userManagement, + icon: Icons.people, + onTap: () => context.push('/admin/sys/user'), + ), + ), + ], + ), + ], + ), + ); + } + + /// 品牌云卡横幅:首页招牌区块(橙→金渐变)。 + Widget _buildBrandBanner(BuildContext context) { + final t = AppLocalizations.of(context); + return CloudCard( + child: Row( + children: [ + const Icon(Icons.cloud, size: 36), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.appHeroTitle, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + const Text( + '终端管控·内容运营·门店管理', + style: TextStyle(fontSize: 13), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _StatCard extends StatelessWidget { + const _StatCard({ + required this.title, + required this.value, + required this.icon, + required this.color, + }); + + final String title; + final String value; + final IconData icon; + final Color color; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: color, size: 28), + const SizedBox(height: 8), + Text( + value, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Text(title, style: Theme.of(context).textTheme.bodySmall), + ], + ), + ), + ); + } +} + +class _QuickAction extends StatelessWidget { + const _QuickAction({ + required this.label, + required this.icon, + required this.onTap, + }); + + final String label; + final IconData icon; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircleAvatar( + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + child: Icon(icon, color: Theme.of(context).colorScheme.primary), + ), + const SizedBox(height: 4), + Text(label, style: Theme.of(context).textTheme.bodySmall), + ], + ), + ); + } +} diff --git a/lib/screens/login/login_page.dart b/lib/screens/login/login_page.dart new file mode 100644 index 0000000..fb3392b --- /dev/null +++ b/lib/screens/login/login_page.dart @@ -0,0 +1,509 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../providers/auth_provider.dart'; +import '../../app/theme.dart'; +import '../../services/auth_service.dart'; +import '../../utils/constants.dart'; +import '../../utils/credential_storage.dart'; +import '../../utils/i10n.dart'; +import '../../config/app_config.dart'; +import '../../widgets/cloud_card.dart'; + +/// 登录页。 +class LoginPage extends ConsumerStatefulWidget { + const LoginPage({super.key}); + + @override + ConsumerState createState() => _LoginPageState(); +} + +class _LoginPageState extends ConsumerState { + final _formKey = GlobalKey(); + final _accountController = TextEditingController(); + final _passwordController = TextEditingController(); + final _captchaController = TextEditingController(); + bool _obscurePassword = true; + + /// 是否记住密码。 + bool _rememberPassword = false; + + /// 当前验证码图片数据。 + Uint8List? _captchaBytes; + + /// 当前验证码 UUID,登录时必须原样回传。 + String _captchaUuid = ''; + + /// 是否正在加载验证码。 + bool _captchaLoading = false; + + /// 验证码加载/刷新错误提示。 + String? _captchaError; + + @override + void initState() { + super.initState(); + _safeLoadSavedCredentials(); + if (AppConfig.enableCaptcha) { + _safeRefreshCaptcha(); + } + } + + /// 加载已保存的账号密码(如果之前勾选了「记住密码」)。 + /// + /// 包一层 try-catch:release 模式下任何同步/异步异常都不应让登录页崩成灰屏。 + void _safeLoadSavedCredentials() { + try { + _loadSavedCredentials(); + } catch (_) { + // 读取本地凭据失败时使用空表单,不阻塞登录。 + } + } + + /// 验证码加载包一层 try-catch,失败时通过 _refreshCaptcha 内部的 catch 兜底。 + void _safeRefreshCaptcha() { + try { + _refreshCaptcha(); + } catch (_) { + // 极端情况下同步抛错,确保页面仍可渲染。 + if (mounted) { + setState(() { + _captchaError = '验证码加载失败,请重试'; + }); + } + } + } + + /// 加载已保存的账号密码(如果之前勾选了「记住密码」)。 + void _loadSavedCredentials() { + final cs = CredentialStorage.instance; + if (cs.rememberPassword) { + _rememberPassword = true; + _accountController.text = cs.savedAccount ?? ''; + _passwordController.text = cs.savedPassword ?? ''; + } + } + + @override + void dispose() { + _accountController.dispose(); + _passwordController.dispose(); + _captchaController.dispose(); + super.dispose(); + } + + Future _refreshCaptcha() async { + if (!mounted) return; + setState(() { + _captchaLoading = true; + _captchaError = null; + }); + try { + final captcha = await AuthService().fetchCaptcha(); + if (!mounted) return; + setState(() { + _captchaUuid = captcha.uuid; + _captchaBytes = Uint8List.fromList(captcha.imageBytes); + _captchaLoading = false; + }); + } on AuthException catch (e) { + if (!mounted) return; + setState(() { + _captchaError = e.message; + _captchaLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _captchaError = '验证码加载失败,点击重试'; + _captchaLoading = false; + }); + } + } + + Future _submit() async { + if (!(_formKey.currentState?.validate() ?? false)) { + return; + } + final success = await ref.read(authProvider.notifier).login( + account: _accountController.text.trim(), + password: _passwordController.text, + captcha: _captchaController.text.trim(), + uuid: _captchaUuid, + rememberPassword: _rememberPassword, + ); + if (!success && mounted && AppConfig.enableCaptcha) { + // 登录失败后刷新验证码,并清空输入。 + _captchaController.clear(); + await _refreshCaptcha(); + } + } + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + final authState = ref.watch(authProvider); + // 跟随主题:原有硬编码深色是为了规避 M3 动态取色;现改为显式 ColorScheme, + // 直接取 token,浅色模式即为暖白品牌主场(与 logo 橙白一致)。 + final scheme = Theme.of(context).colorScheme; + final inputFillColor = scheme.surfaceContainerHighest; + final inputBorderColor = scheme.outline; + final inputTextColor = scheme.onSurface; + final inputLabelColor = scheme.onSurfaceVariant; + final cardColor = scheme.surfaceContainerLow; + + return Scaffold( + backgroundColor: scheme.surface, + body: Container( + decoration: BoxDecoration( + // 浅色模式铺 logo 橙→金渐变(品牌渗透);深色模式保持暖黑 surface。 + gradient: scheme.brightness == Brightness.light + ? AppBrand.cloudGradient + : null, + color: scheme.brightness == Brightness.light ? null : scheme.surface, + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Card( + color: cardColor, + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Image.asset( + 'assets/images/logo.png', + width: 112, + height: 112, + ), + const SizedBox(height: 16), + CloudCard( + child: Row( + children: [ + const Icon(Icons.cloud, size: 36), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppConstants.appName, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + t.appSubtitle, + style: const TextStyle(fontSize: 13), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 32), + TextFormField( + controller: _accountController, + style: TextStyle(color: inputTextColor), + decoration: InputDecoration( + labelText: '账号', + labelStyle: TextStyle(color: inputLabelColor), + prefixIconColor: inputLabelColor, + filled: true, + fillColor: inputFillColor, + border: OutlineInputBorder( + borderSide: BorderSide(color: inputBorderColor), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: inputBorderColor), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: scheme.primary), + ), + prefixIcon: const Icon(Icons.person_outline), + ), + validator: (value) => + (value == null || value.trim().isEmpty) + ? '请输入账号' + : null, + ), + const SizedBox(height: 16), + TextFormField( + controller: _passwordController, + obscureText: _obscurePassword, + style: TextStyle(color: inputTextColor), + decoration: InputDecoration( + labelText: '密码', + labelStyle: TextStyle(color: inputLabelColor), + prefixIconColor: inputLabelColor, + suffixIconColor: inputLabelColor, + filled: true, + fillColor: inputFillColor, + border: OutlineInputBorder( + borderSide: BorderSide(color: inputBorderColor), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: inputBorderColor), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: scheme.primary), + ), + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_off + : Icons.visibility, + color: inputLabelColor, + ), + onPressed: () => setState( + () => _obscurePassword = !_obscurePassword, + ), + ), + ), + validator: (value) => + (value == null || value.isEmpty) ? '请输入密码' : null, + onFieldSubmitted: (_) => _submit(), + ), + const SizedBox(height: 16), + if (AppConfig.enableCaptcha) ...[ + const SizedBox(height: 16), + // 验证码区域 + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + flex: 5, + child: TextFormField( + controller: _captchaController, + style: TextStyle(color: inputTextColor), + decoration: InputDecoration( + labelText: '验证码', + labelStyle: + TextStyle(color: inputLabelColor), + prefixIconColor: inputLabelColor, + filled: true, + fillColor: inputFillColor, + border: OutlineInputBorder( + borderSide: + BorderSide(color: inputBorderColor), + ), + enabledBorder: OutlineInputBorder( + borderSide: + BorderSide(color: inputBorderColor), + ), + focusedBorder: OutlineInputBorder( + borderSide: + BorderSide(color: scheme.primary), + ), + prefixIcon: const Icon(Icons.security_outlined), + ), + textInputAction: TextInputAction.done, + validator: (value) => + (value == null || value.trim().isEmpty) + ? '请输入验证码' + : null, + onFieldSubmitted: (_) => _submit(), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 3, + child: SizedBox( + height: 52, + child: InkWell( + onTap: _captchaLoading + ? null + : _refreshCaptcha, + borderRadius: BorderRadius.circular(12), + child: Container( + decoration: BoxDecoration( + color: inputFillColor, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: inputBorderColor, + ), + ), + alignment: Alignment.center, + child: _buildCaptchaImage(), + ), + ), + ), + ), + ], + ), + if (_captchaError != null) ...[ + const SizedBox(height: 8), + Text( + _captchaError!, + style: TextStyle( + color: scheme.error, + fontSize: 12, + ), + ), + ], + ], + if (authState.errorMessage != null) ...[ + const SizedBox(height: 12), + Text( + authState.errorMessage!, + style: TextStyle( + color: scheme.error, + ), + ), + ], + const SizedBox(height: 12), + // 记住密码 + Row( + children: [ + Checkbox( + value: _rememberPassword, + onChanged: (v) => setState( + () => _rememberPassword = v ?? false, + ), + fillColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.selected) + ? scheme.primary + : inputBorderColor, + ), + checkColor: scheme.onPrimary, + ), + GestureDetector( + onTap: () => setState( + () => _rememberPassword = !_rememberPassword, + ), + child: Text( + '记住密码', + style: TextStyle(color: inputTextColor), + ), + ), + ], + ), + const SizedBox(height: 12), + FilledButton( + onPressed: authState.isLoading ? null : _submit, + style: FilledButton.styleFrom( + backgroundColor: scheme.primary, + foregroundColor: scheme.onPrimary, + disabledBackgroundColor: + scheme.onSurface.withOpacity(0.12), + padding: const EdgeInsets.symmetric(vertical: 16), + ), + child: authState.isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: scheme.onPrimary, + ), + ) + : const Text('登 录'), + ), + const SizedBox(height: 16), + // 仅 mock 模式显示演示账号;真实模式下提示无意义 + if (AppConstants.useMock) + Text( + '演示账号:${AppConstants.mockAccount} / ' + '${AppConstants.mockPassword}', + textAlign: TextAlign.center, + style: TextStyle( + color: scheme.onSurfaceVariant, fontSize: 12), + ), + const SizedBox(height: 8), + Text( + 'v6-fix68', + textAlign: TextAlign.center, + style: TextStyle( + color: scheme.onSurfaceVariant.withOpacity(0.5), + fontSize: 10), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + + Widget _buildCaptchaImage() { + if (_captchaLoading) { + return const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ); + } + final bytes = _captchaBytes; + if (bytes != null && bytes.isNotEmpty) { + return ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.memory( + bytes, + fit: BoxFit.contain, + width: double.infinity, + height: double.infinity, + errorBuilder: (context, error, stackTrace) { + return const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.broken_image_outlined, + size: 20, + color: Color(0xFFB0B0B0), + ), + SizedBox(height: 2), + Text( + '点击刷新', + style: TextStyle( + fontSize: 10, + color: Color(0xFFB0B0B0), + ), + ), + ], + ); + }, + ), + ); + } + return const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.refresh, + size: 20, + color: Color(0xFFB0B0B0), + ), + SizedBox(height: 2), + Text( + '点击刷新', + style: TextStyle( + fontSize: 10, + color: Color(0xFFB0B0B0), + ), + ), + ], + ); + } +} diff --git a/lib/screens/profile/settings_page.dart b/lib/screens/profile/settings_page.dart new file mode 100644 index 0000000..5fc8244 --- /dev/null +++ b/lib/screens/profile/settings_page.dart @@ -0,0 +1,430 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../providers/auth_provider.dart'; +import '../../providers/locale_provider.dart'; +import '../../providers/theme_provider.dart'; +import '../../providers/display_provider.dart'; +import '../../services/api_client.dart'; +import '../../services/sys_service.dart'; +import '../../utils/constants.dart'; +import '../../utils/i10n.dart'; +import '../../utils/app_update.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +/// 设置页(底部导航第 3 个 tab):主题切换 / 语言占位 / 关于 / 退出登录。 +class SettingsPage extends ConsumerWidget { + const SettingsPage({super.key}); + + Future _showChangePasswordDialog(BuildContext context, WidgetRef ref) async { + final t = AppLocalizations.of(context); + final oldCtrl = TextEditingController(); + final newCtrl = TextEditingController(); + final confirmCtrl = TextEditingController(); + bool saving = false; + + await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setState) => AlertDialog( + title: Text(t.passwordDialogTitle), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: oldCtrl, + obscureText: true, + decoration: InputDecoration( + labelText: t.oldPassword, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: newCtrl, + obscureText: true, + decoration: InputDecoration( + labelText: t.newPassword, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: confirmCtrl, + obscureText: true, + decoration: InputDecoration( + labelText: t.confirmNewPassword, + border: const OutlineInputBorder(), + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: saving ? null : () => Navigator.pop(ctx), + child: Text(t.cancel), + ), + FilledButton( + onPressed: saving + ? null + : () async { + final oldPwd = oldCtrl.text.trim(); + final newPwd = newCtrl.text.trim(); + final confirmPwd = confirmCtrl.text.trim(); + if (oldPwd.isEmpty || newPwd.isEmpty) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar(content: Text(t.pleaseFillPassword)), + ); + return; + } + if (newPwd != confirmPwd) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar(content: Text(t.passwordsDoNotMatch)), + ); + return; + } + setState(() => saving = true); + try { + await SystemService().updatePassword(oldPwd, newPwd); + if (!ctx.mounted) return; + Navigator.pop(ctx); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.passwordChangeSuccess)), + ); + // 修改成功后安全登出,要求用新密码重新登录。 + Future.delayed(const Duration(seconds: 1), () { + if (context.mounted) { + ref.read(authProvider.notifier).logout(); + } + }); + } on ApiException catch (e) { + setState(() => saving = false); + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar(content: Text(e.message)), + ); + } catch (e) { + setState(() => saving = false); + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar(content: Text(t.passwordChangeFailed(formatApiError(e)))), + ); + } + }, + child: saving + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(t.save), + ), + ], + ), + ), + ); + + oldCtrl.dispose(); + newCtrl.dispose(); + confirmCtrl.dispose(); + } + + String _themeModeLabel(ThemeMode mode, AppLocalizations t) { + switch (mode) { + case ThemeMode.dark: + return t.themeDark; + case ThemeMode.light: + return t.themeLight; + case ThemeMode.system: + return t.themeSystem; + } + } + + String _orientationLabel(DisplayOrientation mode, AppLocalizations t) { + switch (mode) { + case DisplayOrientation.system: + return t.orientationSystem; + case DisplayOrientation.portrait: + return t.orientationPortrait; + case DisplayOrientation.landscape: + return t.orientationLandscape; + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final themeMode = ref.watch(themeModeProvider); + final appLocale = ref.watch(localeProvider); + final displayOrientation = ref.watch(displayOrientationProvider); + final authState = ref.watch(authProvider); + final t = AppLocalizations.of(context); + + return Scaffold( + appBar: AppBar(title: Text(t.settings)), + body: ListView( + children: [ + if (authState.user != null) + ListTile( + leading: const CircleAvatar(child: Icon(Icons.person)), + title: Text(authState.user!.displayName), + subtitle: Text( + authState.user!.role.isEmpty + ? '${t.account}:${authState.user!.account}' + : authState.user!.role, + ), + ), + const Divider(), + // 主题切换:深色 / 浅色 / 跟随系统 + _AppSettingsTile( + leading: const Icon(Icons.dark_mode_outlined), + title: t.themeMode, + subtitle: _themeModeLabel(themeMode, t), + control: SegmentedButton( + segments: [ + ButtonSegment( + value: ThemeMode.dark, + icon: const Icon(Icons.dark_mode), + label: Text(t.themeDark), + ), + ButtonSegment( + value: ThemeMode.light, + icon: const Icon(Icons.light_mode), + label: Text(t.themeLight), + ), + ButtonSegment( + value: ThemeMode.system, + icon: const Icon(Icons.settings_suggest), + label: Text(t.themeSystem), + ), + ], + selected: {themeMode}, + onSelectionChanged: (selection) => ref + .read(themeModeProvider.notifier) + .setThemeMode(selection.first), + ), + ), + // 语言切换:简体中文 / English + _AppSettingsTile( + leading: const Icon(Icons.language_outlined), + title: t.language, + subtitle: appLocale.label, + control: SegmentedButton( + segments: const [ + ButtonSegment(value: AppLocale.zh, label: Text('中文')), + ButtonSegment(value: AppLocale.en, label: Text('EN')), + ], + selected: {appLocale}, + onSelectionChanged: (selection) { + try { + ref.read(localeProvider.notifier).setLocale(selection.first); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('语言切换失败:$e')), + ); + } + }, + ), + ), + const Divider(), + // 显示方向:跟随系统 / 竖屏 / 横屏(手动切换屏幕方向,布局自动适配)。 + _AppSettingsTile( + leading: const Icon(Icons.screen_rotation_outlined), + title: t.orientationMode, + subtitle: _orientationLabel(displayOrientation, t), + control: SegmentedButton( + segments: [ + ButtonSegment( + value: DisplayOrientation.system, + icon: const Icon(Icons.screen_rotation), + label: Text(t.orientationSystem), + ), + ButtonSegment( + value: DisplayOrientation.portrait, + icon: const Icon(Icons.stay_current_portrait), + label: Text(t.orientationPortrait), + ), + ButtonSegment( + value: DisplayOrientation.landscape, + icon: const Icon(Icons.stay_current_landscape), + label: Text(t.orientationLandscape), + ), + ], + selected: {displayOrientation}, + onSelectionChanged: (selection) => ref + .read(displayOrientationProvider.notifier) + .set(selection.first), + ), + ), + const Divider(), + // 修改密码 + ListTile( + leading: const Icon(Icons.lock_outline), + title: Text(t.changePassword), + onTap: () => _showChangePasswordDialog(context, ref), + ), + const Divider(), + // 检查更新 + ListTile( + leading: const Icon(Icons.system_update_alt_outlined), + title: Text(t.checkUpdate), + subtitle: _VersionSubtitle(t: t), + onTap: () => checkAndPromptUpdate( + context, + silentIfNoUpdate: false, + orgId: ref.read(authProvider).user?.orgId, + ), + ), + const Divider(), + // 关于 + ListTile( + leading: const Icon(Icons.info_outline), + title: Text(t.about), + subtitle: _VersionSubtitle(t: t, includeName: true), + onTap: () async { + final pkg = await currentPackageInfo; + if (!context.mounted) return; + showAboutDialog( + context: context, + applicationName: AppConstants.appName, + applicationVersion: '${pkg.version} (${pkg.buildNumber})', + applicationIcon: Image.asset( + 'assets/images/logo.png', + width: 64, + height: 64, + ), + applicationLegalese: 'Device/system operation admin mobile demo', + ); + }, + ), + const Divider(), + // 退出登录 + ListTile( + leading: Icon( + Icons.logout, + color: Theme.of(context).colorScheme.error, + ), + title: Text( + t.logout, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + onTap: () async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(t.logout), + content: const Text('确定要退出当前账号吗?'), + 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.logout), + ), + ], + ), + ); + if (confirmed == true && context.mounted) { + ref.read(authProvider.notifier).logout(); + } + }, + ), + ], + ), + ); + } +} + +/// 设置项:图标 + 标题 + 副标题在上,控件整行铺满在下。 +/// +/// 用于 [SegmentedButton] 等较宽的控件,避免塞进 [ListTile.trailing] +/// 在窄屏溢出。 +class _AppSettingsTile extends StatelessWidget { + const _AppSettingsTile({ + required this.title, + required this.control, + this.subtitle, + this.leading, + }); + + final String title; + final Widget control; + final String? subtitle; + final Widget? leading; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (leading != null) ...[ + leading!, + const SizedBox(width: 12), + ], + Expanded( + child: Text( + title, + style: theme.textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + ), + ], + ), + if (subtitle != null) ...[ + const SizedBox(height: 2), + Padding( + padding: EdgeInsets.only(left: leading != null ? 32 : 0), + child: Text( + subtitle!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + const SizedBox(height: 12), + SizedBox(width: double.infinity, child: control), + ], + ), + ); + } +} + +/// 当前 App 版本副标题:异步读取 [PackageInfo] 展示版本名与构建号。 +/// +/// [includeName]=true 时前缀应用名(用于「关于」项)。 +class _VersionSubtitle extends StatelessWidget { + const _VersionSubtitle({required this.t, this.includeName = false}); + + final AppLocalizations t; + final bool includeName; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: currentPackageInfo, + builder: (ctx, snap) { + final info = snap.data; + if (includeName) { + // 「关于」项:副标题只展示应用名,版本详情交由「关于」弹窗承载, + // 避免与应用名 + 版本号 + 构建号叠加成冗余且暴露内部构建号的文案。 + return const Text(AppConstants.appName); + } + // 「检查更新」项:展示当前已安装版本名(不暴露内部构建号)。 + final version = info != null ? info.version : t.loading; + return Text('${t.currentVersion} $version'); + }, + ); + } +} diff --git a/lib/screens/store/org_tree_page.dart b/lib/screens/store/org_tree_page.dart new file mode 100644 index 0000000..cfc730c --- /dev/null +++ b/lib/screens/store/org_tree_page.dart @@ -0,0 +1,554 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../models/org_node.dart'; +import '../../services/org_service.dart'; +import '../../utils/session_org_scope.dart'; + +/// 组织架构树页:递归树展示、颜色编码、新增/删除/详情。 +class OrgTreePage extends ConsumerStatefulWidget { + const OrgTreePage({super.key}); + + @override + ConsumerState createState() => _OrgTreePageState(); +} + +class _OrgTreePageState extends ConsumerState { + final OrgService _orgService = OrgService(); + + List _tree = []; + bool _loading = false; + String? _error; + OrgNode? _selectedNode; + + /// 受限账号(带 orgId 的渠道/代理商等)仅有只读查看权限,隐藏新增/删除等写操作入口。 + bool get _canEdit => !SessionOrgScope.instance.enabled; + + @override + void initState() { + super.initState(); + _loadTree(); + } + + Future _loadTree() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final tree = await _orgService.getOrgTree(); + if (!mounted) return; + setState(() { + _tree = tree; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = '$e'; + _loading = false; + }); + } + } + + Future _deleteNode(OrgNode node) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('删除组织'), + content: Text('确定要删除「${node.name}」吗?子节点也会一并删除。'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('取消'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('删除'), + ), + ], + ), + ); + if (confirmed != true) return; + + try { + await _orgService.deleteOrg(node.id); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('组织已删除')), + ); + _loadTree(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('删除失败:$e')), + ); + } + } + + void _showAddSheet({OrgNode? parent}) { + showModalBottomSheet( + useSafeArea: true, + context: context, + isScrollControlled: true, + builder: (ctx) => _OrgFormSheet( + orgService: _orgService, + parentId: parent?.id, + parentName: parent?.name, + onSaved: () { + Navigator.pop(ctx); + _loadTree(); + }, + ), + ); + } + + void _showDetailSheet(OrgNode node) { + showModalBottomSheet( + useSafeArea: true, + context: context, + isScrollControlled: true, + builder: (ctx) => _OrgDetailSheet( + node: node, + canEdit: _canEdit, + onAddChild: () { + Navigator.pop(ctx); + _showAddSheet(parent: node); + }, + onDelete: () { + Navigator.pop(ctx); + _deleteNode(node); + }, + ), + ); + } + + @override + Widget build(BuildContext context) { + final canEdit = _canEdit; + return Scaffold( + appBar: AppBar(automaticallyImplyLeading: false, title: const Text('组织架构')), + body: _buildBody(), + floatingActionButton: canEdit + ? FloatingActionButton( + onPressed: () => _showAddSheet(parent: _selectedNode), + child: const Icon(Icons.add), + ) + : null, + ); + } + + Widget _buildBody() { + final canEdit = _canEdit; + 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: _loadTree, + child: const Text('重试'), + ), + ], + ), + ); + } + if (_tree.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.account_tree_outlined, + size: 64, color: Theme.of(context).colorScheme.outline), + const SizedBox(height: 16), + const Text('暂无组织数据'), + const SizedBox(height: 8), + if (canEdit) const Text('点击 + 添加组织'), + ], + ), + ); + } + return RefreshIndicator( + onRefresh: _loadTree, + child: ListView( + padding: const EdgeInsets.symmetric(vertical: 8), + physics: const AlwaysScrollableScrollPhysics(), + children: _tree.map((node) => _buildTreeNode(node, 0)).toList(), + ), + ); + } + + Widget _buildTreeNode(OrgNode node, int depth) { + final hasChildren = node.children.isNotEmpty; + final typeInfo = _orgTypeInfo(node.type); + final canEdit = _canEdit; + + if (hasChildren) { + return ExpansionTile( + key: ValueKey('org-${node.id}'), + leading: _typeDot(typeInfo.color), + title: _nodeTitle(node, typeInfo), + subtitle: Text('${node.children.length} 个子节点', + style: const TextStyle(fontSize: 12)), + children: [ + ...node.children.map((child) => _buildTreeNode(child, depth + 1)), + if (canEdit) + Padding( + padding: const EdgeInsets.only(left: 56, top: 4, bottom: 4), + child: Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: () => _showAddSheet(parent: node), + icon: const Icon(Icons.add, size: 18), + label: const Text('添加子节点'), + ), + ), + ), + ], + ); + } + + return ListTile( + key: ValueKey('org-${node.id}'), + leading: _typeDot(typeInfo.color), + title: _nodeTitle(node, typeInfo), + onLongPress: () => _selectNode(node), + onTap: () => _showDetailSheet(node), + trailing: PopupMenuButton( + onSelected: (value) { + if (value == 'add') { + _showAddSheet(parent: node); + } else if (value == 'detail') { + _showDetailSheet(node); + } else if (value == 'delete') { + _deleteNode(node); + } + }, + itemBuilder: (ctx) => [ + if (canEdit) + const PopupMenuItem(value: 'add', child: Text('添加子节点')), + const PopupMenuItem(value: 'detail', child: Text('查看详情')), + if (canEdit) + const PopupMenuItem(value: 'delete', child: Text('删除')), + ], + ), + ); + } + + void _selectNode(OrgNode node) { + setState(() => _selectedNode = node); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('已选择「${node.name}」为父节点,点击 + 添加子组织')), + ); + } + + Widget _nodeTitle(OrgNode node, _TypeInfo info) { + return Row( + children: [ + Expanded( + child: Text( + node.name, + style: TextStyle(fontWeight: FontWeight.w600), + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: info.color.withOpacity(0.15), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: info.color.withOpacity(0.6)), + ), + child: Text( + info.label, + style: TextStyle( + color: info.color, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ); + } + + Widget _typeDot(Color color) { + return Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + ); + } + + _TypeInfo _orgTypeInfo(int type) { + switch (type) { + case 0: + return const _TypeInfo('平台', Colors.purple); + case 1: + return const _TypeInfo('渠道', Color(0xFF0B6397)); // 语义信息蓝 + case 2: + return const _TypeInfo('代理商', Colors.orange); + case 3: + return const _TypeInfo('门店', Colors.green); + case 4: + return const _TypeInfo('设备分组', Colors.grey); + default: + return const _TypeInfo('未知', Colors.grey); + } + } +} + +class _TypeInfo { + const _TypeInfo(this.label, this.color); + final String label; + final Color color; +} + +/// 组织详情底部弹窗。 +class _OrgDetailSheet extends StatelessWidget { + const _OrgDetailSheet({ + required this.node, + required this.onAddChild, + required this.onDelete, + this.canEdit = true, + }); + + final OrgNode node; + final VoidCallback onAddChild; + final VoidCallback onDelete; + final bool canEdit; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SingleChildScrollView( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Container( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + node.name, + style: theme.textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + const Divider(height: 24), + _row(theme, '节点ID', node.id), + _row(theme, '父级ID', node.parentId), + _row(theme, '地址', node.address), + _row(theme, '联系电话', node.contactPhone), + _row(theme, '激活码', node.activationCode), + const SizedBox(height: 20), + if (canEdit) + Row( + children: [ + Expanded( + child: FilledButton.tonal( + onPressed: onAddChild, + child: const Text('添加子节点'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton( + onPressed: onDelete, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red, + ), + child: const Text('删除'), + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _row(ThemeData theme, String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 80, + child: Text( + label, + style: theme.textTheme.bodyMedium + ?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ), + Expanded( + child: Text(value.isEmpty ? '—' : value), + ), + ], + ), + ); + } +} + +/// 新增组织表单。 +class _OrgFormSheet extends StatefulWidget { + const _OrgFormSheet({ + required this.orgService, + this.parentId, + this.parentName, + required this.onSaved, + }); + + final OrgService orgService; + final String? parentId; + final String? parentName; + final VoidCallback onSaved; + + @override + State<_OrgFormSheet> createState() => _OrgFormSheetState(); +} + +class _OrgFormSheetState extends State<_OrgFormSheet> { + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + int _type = 1; + bool _saving = false; + + static const _typeOptions = [ + _TypeOption(0, '平台'), + _TypeOption(1, '渠道'), + _TypeOption(2, '代理商'), + _TypeOption(3, '门店'), + _TypeOption(4, '设备分组'), + ]; + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _saving = true); + try { + final data = { + 'name': _nameController.text.trim(), + 'type': _type, + 'parentId': widget.parentId ?? '', + }; + await widget.orgService.saveOrUpdateOrg(data); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('组织已创建')), + ); + widget.onSaved(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('保存失败:$e')), + ); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Container( + padding: const EdgeInsets.all(20), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '新增组织', + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + if (widget.parentName != null) ...[ + const SizedBox(height: 8), + Text( + '父级组织:${widget.parentName}', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + const SizedBox(height: 20), + TextFormField( + controller: _nameController, + decoration: const InputDecoration( + labelText: '组织名称', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.label), + ), + validator: (v) => + v == null || v.trim().isEmpty ? '请输入组织名称' : null, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + value: _type, + decoration: const InputDecoration( + labelText: '组织类型', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.category), + ), + items: _typeOptions + .map((opt) => DropdownMenuItem( + value: opt.type, + child: Text(opt.label), + )) + .toList(), + onChanged: (v) { + if (v != null) setState(() => _type = v); + }, + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('创建'), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _TypeOption { + const _TypeOption(this.type, this.label); + final int type; + final String label; +} diff --git a/lib/screens/store/store_detail_page.dart b/lib/screens/store/store_detail_page.dart new file mode 100644 index 0000000..5741847 --- /dev/null +++ b/lib/screens/store/store_detail_page.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +/// 门店详情页(桩页面)。 +class StoreDetailPage extends StatelessWidget { + const StoreDetailPage({super.key, required this.storeId}); + + final String storeId; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(automaticallyImplyLeading: false, title: const Text('门店详情')), + body: Center(child: Text('门店 $storeId\n功能开发中')), + ); + } +} diff --git a/lib/screens/store/store_list_page.dart b/lib/screens/store/store_list_page.dart new file mode 100644 index 0000000..f51a575 --- /dev/null +++ b/lib/screens/store/store_list_page.dart @@ -0,0 +1,1771 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../data/region_data.dart'; +import '../../models/org_node.dart'; +import '../../models/store_config.dart'; +import '../../models/store_info.dart'; +import '../../models/store_theme.dart'; +import '../../models/theme_group.dart'; +import '../../models/theme_template.dart'; +import '../../providers/auth_provider.dart'; +import '../../services/api_client.dart'; +import '../../services/device_service.dart'; +import '../../services/org_service.dart'; +import '../../services/theme_service.dart'; +import '../../utils/formatters.dart'; +import '../../utils/i10n.dart'; +import '../../widgets/device_command_sheet.dart'; +import '../../widgets/org_picker_sheet.dart'; +import '../../widgets/region_picker.dart'; +import '../../widgets/store_theme_grid.dart'; +import '../../widgets/store_theme_group_list.dart'; +import '../../widgets/store_theme_preview.dart'; +import '../../widgets/app_bottom_sheet.dart'; +import '../../widgets/cloud_card.dart'; +import '../../widgets/org_tree_filter.dart'; + +/// 门店管理页:搜索、分页列表、新增/编辑/删除/详情。 +class StoreListPage extends ConsumerStatefulWidget { + const StoreListPage({super.key}); + + @override + ConsumerState createState() => _StoreListPageState(); +} + +class _StoreListPageState extends ConsumerState { + final OrgService _orgService = OrgService(); + final _searchController = TextEditingController(); + + List _allItems = []; + List _items = []; + bool _loading = false; + String? _error; + String _keyword = ''; + + /// 组织树筛选选中的节点 ID 集合(空集表示不过滤)。 + Set _selectedOrgIds = {}; + + /// 组织树(异步加载,失败则置空,树筛选不可用但不影响门店列表)。 + List? _orgTree; + + @override + void initState() { + super.initState(); + _loadOrgTree(); + _loadData(); + } + + /// 异步加载组织树(静默失败:仅影响组织树筛选,不影响门店列表本身)。 + Future _loadOrgTree() async { + try { + _orgTree = await _orgService.getOrgTree(); + } catch (_) { + _orgTree = null; + } + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future _loadData() async { + setState(() { + _loading = true; + _error = null; + }); + try { + // 确保省市区数据源已加载,门店卡片/详情才能用 RegionData 反查省/市/区名称。 + await RegionData.instance.load(); + // 优先从组织树(/sys/org/tree)中收集 type=3 的门店; + // 若组织树接口不可用,自动回退到 /sys/store/myStores。 + final items = await _orgService.getMyStores(); + if (!mounted) return; + _allItems = items; + _applyFilter(); + setState(() => _loading = false); + // 非阻塞回填到期时间:组织树不携带 expireTime, + // 用与详情弹窗同源的 /sys/store/listByLoginOrg 按 orgId 补填, + // 失败或不返回则保持「无到期时间」,不影响既有列表与加载逻辑。 + _enrichExpiry(); + } catch (e) { + if (!mounted) return; + setState(() { + _error = formatApiError(e, fallback: '门店加载失败,请稍后重试'); + _loading = false; + }); + } + } + + /// 非阻塞回填门店到期时间。 + /// + /// 组织树(/sys/org/tree)与通用门店列表接口通常不返回 expireTime,导致列表项 + /// 恒为「无到期时间」。两步走: + /// 1) 复用与详情弹窗同源的 [OrgService.getStoreListByLoginOrg](一次请求)按 orgId 补填; + /// 2) 仍缺失的门店,逐店调用权威端点 [OrgService.getStoreConfig] + /// (GET /device/storeConfig/info/{orgId},与 Web 后台门店配置弹窗同源)兜底, + /// 并行请求、单店失败静默忽略,避免影响其它门店与列表展示。 + /// 仅当门店当前 expireTime 为空时才覆盖,避免冲掉已带值。任何异常均被吞掉, + /// 保证不破坏既有列表与加载逻辑。 + Future _enrichExpiry() async { + // 第 1 步:单次请求批量补填。 + final stillMissing = []; + try { + final full = await _orgService.getStoreListByLoginOrg(); + if (!mounted) return; + final byOrg = {}; + for (final s in full) { + byOrg[s.orgId] = s; + } + var changed = false; + _allItems = _allItems.map((s) { + final f = byOrg[s.orgId]; + if (f != null && f.expireTime != null && s.expireTime == null) { + changed = true; + return s.copyWith(expireTime: f.expireTime); + } + return s; + }).toList(); + if (changed) { + _applyFilter(); + if (mounted) setState(() {}); + } + stillMissing.addAll(_allItems.where((s) => s.expireTime == null)); + } catch (_) { + // listByLoginOrg 失败,仍尝试逐店兜底。 + stillMissing.addAll(_allItems.where((s) => s.expireTime == null)); + } + + // 第 2 步:逐店权威兜底(仅对仍缺失的门店)。 + if (stillMissing.isEmpty || !mounted) return; + await Future.wait(stillMissing.map((s) async { + try { + final cfg = await _orgService.getStoreConfig(s.orgId); + if (!mounted) return; + if (cfg.expireTime != null) { + _allItems = _allItems + .map((x) => x.orgId == s.orgId + ? x.copyWith(expireTime: cfg.expireTime) + : x) + .toList(); + _applyFilter(); + if (mounted) setState(() {}); + } + } catch (_) { + // 单店失败忽略。 + } + })); + } + + void _applyFilter() { + final keyword = _keyword.trim().toLowerCase(); + List filtered; + if (keyword.isEmpty) { + filtered = List.of(_allItems); + } else { + filtered = _allItems + .where((s) => s.name.toLowerCase().contains(keyword)) + .toList(); + } + // 组织树筛选:增量叠加,与门店名搜索 AND 组合。 + // 每个选中节点的全部后代 ID(含自身)均视为命中,仅保留归属这些组织的门店。 + if (_selectedOrgIds.isNotEmpty && _orgTree != null) { + final allowed = {}; + for (final id in _selectedOrgIds) { + allowed.addAll(OrgService.collectDescendantIds(_orgTree!, id)); + } + filtered = filtered.where((s) => allowed.contains(s.orgId)).toList(); + } + _items = filtered; + // 与 web 后台一致:按创建时间倒序(最新的排最前面)。 + // createTime 缺失时按 orgId 数值倒序兜底(新建门店 id 通常更大), + // 避免退化为字符串比较导致的乱序。 + _items.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; + final ai = int.tryParse(a.orgId); + final bi = int.tryParse(b.orgId); + if (ai != null && bi != null) return bi.compareTo(ai); + return b.orgId.compareTo(a.orgId); + }); + } + + void _onSearch() { + setState(() { + _keyword = _searchController.text; + _applyFilter(); + }); + } + + /// 打开组织树筛选抽屉,回填选中集合并重新过滤。 + /// + /// 返回 null(取消)时保持原筛选不变;返回非空集合(含空集合)时覆盖 [_selectedOrgIds]。 + Future _openOrgFilter() async { + final result = await showOrgTreeFilterSheet( + context, + initialSelected: _selectedOrgIds, + ); + if (result == null) return; + setState(() { + _selectedOrgIds = result; + _applyFilter(); + }); + } + + Future _deleteStore(StoreInfo store) async { + final t = AppLocalizations.of(context); + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(t.storeDeleteConfirmTitle), + content: Text(t.storeDeleteConfirmContent(store.name)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(t.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(t.delete), + ), + ], + ), + ); + if (confirmed != true) return; + + try { + await _orgService.deleteOrg(store.orgId); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.storeDeleted)), + ); + _loadData(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${t.deleteFailed}:${formatApiError(e)}')), + ); + } + } + + void _showAddEditSheet({StoreInfo? store}) { + final currentUser = ref.read(authProvider).user; + final t = AppLocalizations.of(context); + final formKey = GlobalKey<_StoreFormSheetState>(); + showAppBottomSheet( + context: context, + title: store == null ? t.addStore : t.editStore, + heightFactor: 0.92, + body: _StoreFormSheet( + key: formKey, + orgService: _orgService, + store: store, + currentUserOrgId: currentUser?.orgId, + onSaved: () { + Navigator.pop(context); + _loadData(); + }, + ), + // 保存按钮吸底,始终可见(不再埋在滚动区末尾)。 + footer: SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () { + final st = formKey.currentState; + if (st == null || st.isSaving) return; + st.requestSave(); + }, + child: Text(store == null ? t.create : t.save), + ), + ), + ); + } + + void _showDetailSheet(StoreInfo store) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (ctx) => _StoreDetailSheet( + store: store, + onEdit: () { + Navigator.pop(ctx); + _showAddEditSheet(store: store); + }, + onDelete: () { + Navigator.pop(ctx); + _deleteStore(store); + }, + onCommands: () { + Navigator.pop(ctx); + _showStoreCommandSheet(store); + }, + ), + ); + } + + /// 打开门店批量指令弹窗(按 orgId 下发到该门店全部设备)。 + void _showStoreCommandSheet(StoreInfo store) { + final t = AppLocalizations.of(context); + if (store.orgId.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.noOrgId)), + ); + return; + } + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (ctx) => DeviceCommandSheet( + orgId: store.orgId, + orgName: store.name, + deviceService: DeviceService(), + ), + ); + } + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + return Scaffold( + appBar: AppBar(title: Text(t.storeManagement)), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: CloudCard( + child: Row( + children: [ + const Icon(Icons.cloud, size: 32), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.storeManagement, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2), + const Text( + '终端管控·内容运营·门店管理', + style: TextStyle(fontSize: 12), + ), + ], + ), + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: t.searchStoreHint, + prefixIcon: const Icon(Icons.search_outlined), + suffixIcon: _searchController.text.isEmpty + ? null + : IconButton( + icon: const Icon(Icons.clear_outlined), + onPressed: () { + _searchController.clear(); + setState(() => _keyword = ''); + _loadData(); + }, + ), + ), + onSubmitted: (_) => _onSearch(), + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.account_tree_outlined), + tooltip: '组织筛选', + onPressed: _openOrgFilter, + ), + ], + ), + ), + if (_selectedOrgIds.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 4), + child: Wrap( + children: [ + Chip( + label: Text('组织筛选 (${_selectedOrgIds.length})'), + deleteIcon: const Icon(Icons.close, size: 18), + onDeleted: () { + setState(() { + _selectedOrgIds = {}; + _applyFilter(); + }); + }, + ), + ], + ), + ), + Expanded(child: _buildBody()), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: () => _showAddEditSheet(), + child: const Icon(Icons.add_outlined), + ), + ); + } + + Widget _buildBody() { + final t = AppLocalizations.of(context); + if (_loading && _items.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + if (_error != null && _items.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 (_items.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: _items.length, + itemBuilder: (context, index) { + final store = _items[index]; + return _StoreListTile( + store: store, + onTap: () => _showDetailSheet(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.regionText.trim().isNotEmpty) + _infoLine(context, Icons.location_on_outlined, store.regionText), + if (store.contactPhone.isNotEmpty) + _infoLine(context, Icons.phone_outlined, store.contactPhone), + if (store.activationCode.isNotEmpty) + _infoLine(context, Icons.vpn_key_outlined, store.activationCode), + // 到期时间:始终展示,三态明确区分—— + // 无到期时间 / 已过期(红色标注)/ 正常(YYYY-MM-DD)。 + _StoreExpireLine(store: store), + if (store.createTime != null) + _infoLine( + context, + Icons.access_time_outlined, + '${t.createTime}:${Formatters.formatDateTime(store.createTime!)}', + ), + if (store.deviceLimit != null) + _infoLine( + context, + Icons.devices_outlined, + '${t.deviceLimit}:${store.deviceLimit} ${t.units}', + ), + if (store.themeId?.isNotEmpty == true) + _infoLine( + context, + Icons.palette_outlined, + '${t.themeSkin}:${StoreThemePresets.byId(store.themeId)?.label ?? store.themeId!}', + ), + ], + ), + trailing: const Icon(Icons.chevron_right), + ), + ); + } + + Widget _infoLine(BuildContext context, IconData icon, String text, + {Color? color}) { + final effective = color ?? Theme.of(context).colorScheme.outline; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: Row( + children: [ + Icon(icon, size: 14, color: effective), + const SizedBox(width: 4), + Expanded( + child: Text( + text, + style: TextStyle(fontSize: 13, color: color), + ), + ), + ], + ), + ); + } +} + +/// 门店到期时间行:始终展示,三态明确区分。 +/// +/// - 无到期时间:显示「无到期时间」(中性灰,不含日期); +/// - 已过期:日期标红并追加「已过期」标注; +/// - 正常:仅日期 `YYYY-MM-DD`([Formatters.formatDate])。 +class _StoreExpireLine extends StatelessWidget { + const _StoreExpireLine({required this.store}); + + final StoreInfo store; + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + final exp = store.expireTime; + final expired = exp != null && exp.isBefore(DateTime.now()); + final color = expired ? Colors.red : null; + final text = exp == null + ? t.noExpireTime + : '${Formatters.formatDate(exp)}${expired ? '(${t.expired})' : ''}'; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: Row( + children: [ + Icon( + Icons.event_outlined, + size: 14, + color: color ?? Theme.of(context).colorScheme.outline, + ), + const SizedBox(width: 4), + Expanded( + child: Text(text, style: TextStyle(fontSize: 13, color: color)), + ), + ], + ), + ); + } +} + +/// 门店详情底部弹窗。 +/// +/// 组织树 /sys/org/tree 返回的门店字段不全(缺到期/创建时间、状态、手机号/激活码也常为空), +/// 因此弹窗打开时额外调用 /sys/store/listByLoginOrg 拉取完整门店数据并按 orgId 匹配回填。 +class _StoreDetailSheet extends StatefulWidget { + const _StoreDetailSheet({ + required this.store, + required this.onEdit, + required this.onDelete, + required this.onCommands, + }); + + final StoreInfo store; + final VoidCallback onEdit; + final VoidCallback onDelete; + final VoidCallback onCommands; + + @override + State<_StoreDetailSheet> createState() => _StoreDetailSheetState(); +} + +/// 门店详情聚合数据:门店信息 + 激活码信息。 +class _StoreDetailData { + const _StoreDetailData(this.store, this.activationCode); + + final StoreInfo store; + final String? activationCode; +} + +class _StoreDetailSheetState extends State<_StoreDetailSheet> { + late final Future<_StoreDetailData> _detailFuture; + + @override + void initState() { + super.initState(); + _detailFuture = _fetchDetail(); + } + + Future<_StoreDetailData> _fetchDetail() async { + StoreInfo? detail; + String? code; + + // 确保省市区数据源已加载,详情页才能反查省/市/区名称。 + await RegionData.instance.load(); + + // 1) 拉取门店完整字段(电话、到期/创建时间等)。 + try { + final list = await OrgService().getStoreListByLoginOrg(); + detail = list.firstWhere((s) => s.orgId == widget.store.orgId); + } catch (_) { + detail = null; + } + + // 1.5) 用与 Web 同款的逐店配置接口补全权威到期时间 + // (组织树/通用列表可能不返回 expireTime,此端点才是其权威来源)。 + try { + final cfg = await OrgService().getStoreConfig(widget.store.orgId); + if (cfg.expireTime != null) { + detail = (detail ?? widget.store).copyWith(expireTime: cfg.expireTime); + } + } catch (_) { + // 忽略,沿用 listByLoginOrg 或原始值。 + } + + // 2) 拉取真实激活码(门店列表接口通常不返回激活码)。 + try { + final info = await DeviceService().getActivationCodeInfo(widget.store.orgId); + code = info['code']?.toString(); + } catch (_) { + code = null; + } + + final store = detail ?? widget.store; + // 优先使用激活码接口返回的 code;若接口无值,再回退门店字段里的 activationCode。 + final activationCode = code?.isNotEmpty == true + ? code! + : store.activationCode.isNotEmpty + ? store.activationCode + : null; + return _StoreDetailData(store, activationCode); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder<_StoreDetailData>( + future: _detailFuture, + builder: (context, snapshot) { + final store = snapshot.data?.store ?? widget.store; + final activationCode = snapshot.data?.activationCode ?? store.activationCode; + final theme = Theme.of(context); + final t = AppLocalizations.of(context); + return SingleChildScrollView( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Container( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Text( + store.name, + style: theme.textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: (store.status == 1 ? Colors.grey : Colors.green) + .withOpacity(0.15), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: + (store.status == 1 ? Colors.grey : Colors.green) + .withOpacity(0.6), + ), + ), + child: Text( + store.status == 1 ? t.statusDisabled : t.statusNormal, + style: TextStyle( + color: + store.status == 1 ? Colors.grey : Colors.green, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const Divider(height: 24), + _row(theme, t.storeId, store.orgId), + _row(theme, t.region, _buildRegionNames(store)), + _row( + theme, + t.addressDetail, + store.addressDetail?.trim().isNotEmpty == true + ? store.addressDetail! + : store.address, + ), + _row(theme, t.contactPhone, store.contactPhone), + _row(theme, t.activationCode, activationCode), + _row( + theme, + t.expireTime, + store.expireTime != null + ? (store.expireTime!.isBefore(DateTime.now()) + ? '${Formatters.formatDate(store.expireTime!)}(${t.expired})' + : Formatters.formatDate(store.expireTime!)) + : t.noExpireTime, + color: store.expireTime != null && + store.expireTime!.isBefore(DateTime.now()) + ? Colors.red + : null, + ), + _row( + theme, + t.createTime, + store.createTime != null + ? Formatters.formatDateTime(store.createTime!) + : t.none, + ), + _row( + theme, + t.deviceLimit, + store.deviceLimit != null + ? '${store.deviceLimit} ${t.units}' + : t.none, + ), + _row( + theme, + t.themeSkin, + StoreThemePresets.byId(store.themeId)?.label ?? + (store.themeId?.isNotEmpty == true + ? store.themeId! + : t.none), + ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: FilledButton.tonal( + onPressed: widget.onEdit, + child: Text(t.edit), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton.tonal( + onPressed: widget.onCommands, + style: FilledButton.styleFrom( + foregroundColor: Colors.teal, + ), + child: Text(t.storeCommands), + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: widget.onDelete, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red, + ), + child: Text(t.delete), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + } + + /// 由省/市/区编码反查名称,拼接为「省 / 市 / 区」展示文本。 + String _buildRegionNames(StoreInfo s) { + final parts = []; + if (s.provinceCode != null && s.provinceCode!.isNotEmpty) { + final n = RegionData.instance.nameOf(s.provinceCode!); + if (n.isNotEmpty) parts.add(n); + } + if (s.cityCode != null && s.cityCode!.isNotEmpty) { + final n = RegionData.instance.nameOf(s.cityCode!); + if (n.isNotEmpty) parts.add(n); + } + if (s.districtCode != null && s.districtCode!.isNotEmpty) { + final n = RegionData.instance.nameOf(s.districtCode!); + if (n.isNotEmpty) parts.add(n); + } + return parts.join(' / '); + } + + Widget _row(ThemeData theme, String label, String value, {Color? color}) { + final display = value.trim().isEmpty ? '—' : value; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 80, + child: Text( + label, + style: theme.textTheme.bodyMedium + ?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ), + Expanded(child: Text(display, style: TextStyle(color: color))), + ], + ), + ); + } +} + +/// 新增/编辑门店表单。 +class _StoreFormSheet extends StatefulWidget { + const _StoreFormSheet({ + super.key, + required this.orgService, + this.store, + this.currentUserOrgId, + required this.onSaved, + }); + + final OrgService orgService; + final StoreInfo? store; + + /// 当前登录用户所属组织 ID,用于新增门店时默认选中其上级组织。 + final String? currentUserOrgId; + final VoidCallback onSaved; + + @override + State<_StoreFormSheet> createState() => _StoreFormSheetState(); +} + +/// 组织下拉项:携带层级深度,用于按组织树自上而下排序并缩进展示。 +class _OrgOption { + const _OrgOption({ + required this.node, + required this.depth, + }); + + final OrgNode node; + final int depth; + + /// 是否为可选的组织节点(门店上级只允许渠道 type=1 或代理商 type=2)。 + bool get selectable => node.type == 1 || node.type == 2; +} + +class _StoreFormSheetState extends State<_StoreFormSheet> { + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + final _phoneController = TextEditingController(); + final _addressDetailController = TextEditingController(); + final _terminalCountController = TextEditingController(); + + List<_OrgOption> _orgOptions = []; + String? _parentId; + bool _loadingOrgs = true; + bool _saving = false; + + // 编辑门店时加载的激活码信息(最大/已用终端数、当前激活码)。 + Map? _activationInfo; + bool _loadingActivationInfo = false; + + // 省市区选择结果。 + String? _provinceCode; + String? _cityCode; + String? _districtCode; + String _regionLabel = ''; + + // 主题皮肤。 + String? _themeId; + List _templates = []; + bool _loadingTemplates = true; + + // 门店界面配置(按设备分组展示已添加/激活的主题)。 + List _themeGroups = []; + bool _loadingThemeGroups = false; + String? _themeGroupsError; + + @override + void initState() { + super.initState(); + final store = widget.store; + if (store != null) { + _nameController.text = store.name; + _phoneController.text = store.contactPhone; + _provinceCode = store.provinceCode; + _cityCode = store.cityCode; + _districtCode = store.districtCode; + _addressDetailController.text = store.addressDetail ?? store.address; + _themeId = store.themeId; + _terminalCountController.text = store.deviceLimit?.toString() ?? ''; + _parentId = store.parentId; + // 用省市区编码反查名称拼接展示。 + _regionLabel = _buildRegionLabel(); + } else { + // 新增门店时,默认上级组织为当前用户所在组织(若有)。 + final currentOrg = widget.currentUserOrgId; + if (currentOrg != null && currentOrg.isNotEmpty) { + _parentId = currentOrg; + } + } + _loadOrgs(); + _loadRegionData().then((_) { + // 省市区数据异步加载完成后,刷新初始标签(编辑态回填名称需依赖 RegionData 反查)。 + if (mounted) setState(() => _regionLabel = _buildRegionLabel()); + }); + _loadTemplates(); + if (widget.store != null) { + _loadThemeGroups(); + _loadActivationInfo(); + } + } + + @override + void dispose() { + _nameController.dispose(); + _phoneController.dispose(); + _addressDetailController.dispose(); + _terminalCountController.dispose(); + super.dispose(); + } + + /// 供吸底保存按钮读取保存态。 + bool get isSaving => _saving; + + /// 供吸底保存按钮触发保存(含表单校验 + 结果弹窗)。 + void requestSave() => _save(); + + /// 拼接省市区展示文本(用 RegionData 反查名称)。 + String _buildRegionLabel() { + final region = RegionData.instance; + final names = []; + if (_provinceCode != null && _provinceCode!.isNotEmpty) { + final p = region.nameOf(_provinceCode!); + if (p.isNotEmpty) names.add(p); + } + if (_cityCode != null && _cityCode!.isNotEmpty) { + final c = region.nameOf(_cityCode!); + if (c.isNotEmpty) names.add(c); + } + if (_districtCode != null && _districtCode!.isNotEmpty) { + final d = region.nameOf(_districtCode!); + if (d.isNotEmpty) names.add(d); + } + return names.join(' '); + } + + Future _loadOrgs() async { + try { + // 改用组织树接口,保留层级关系;下拉按层级自上而下排序并缩进展示。 + final tree = await widget.orgService.getOrgTree(); + if (!mounted) return; + final options = _buildHierarchicalOptions(tree); + setState(() { + _orgOptions = options; + _resolveDefaultParent(); + _loadingOrgs = false; + }); + } catch (e) { + // 回退到扁平列表:仍尝试按 parentId 重建层级顺序。 + try { + final nodes = await widget.orgService.getOrgFlatList(); + if (!mounted) return; + final options = _buildHierarchyFromFlatList(nodes); + setState(() { + _orgOptions = options; + _resolveDefaultParent(); + _loadingOrgs = false; + }); + } catch (_) { + if (!mounted) return; + setState(() => _loadingOrgs = false); + } + } + } + + /// 兜底补全门店的默认上级组织: + /// 1) 优先使用 store.parentId; + /// 2) 若 store 未返回 parentId,则在已加载的组织树中按 store.orgId 找到该门店节点,取其 parentId; + /// 3) 新增或仍为空时,默认选中当前登录用户所在组织。 + void _resolveDefaultParent() { + final store = widget.store; + if (_parentId == null || _parentId!.isEmpty) { + if (store != null) { + if (store.parentId?.isNotEmpty == true) { + _parentId = store.parentId; + } else if (store.orgId.isNotEmpty) { + final node = _findOrgNodeById(_orgOptions, store.orgId); + if (node != null && node.parentId.isNotEmpty) { + _parentId = node.parentId; + } + } + } + if ((_parentId == null || _parentId!.isEmpty) && + widget.currentUserOrgId?.isNotEmpty == true) { + _parentId = widget.currentUserOrgId; + } + } + } + + /// 在已构建的组织选项中按 ID 查找节点。 + OrgNode? _findOrgNodeById(List<_OrgOption> options, String id) { + if (id.isEmpty) return null; + for (final option in options) { + if (option.node.id == id) return option.node; + } + return null; + } + + /// 把组织选项转换为选择器使用的公共数据结构。 + List _toPickerOptions() { + return _orgOptions + .map((o) => OrgPickerOption( + node: o.node, + depth: o.depth, + selectable: o.selectable, + )) + .toList(); + } + + /// 把组织树递归扁平化为带深度的下拉项。 + List<_OrgOption> _buildHierarchicalOptions(List nodes, {int depth = 0}) { + final result = <_OrgOption>[]; + for (final node in nodes) { + result.add(_OrgOption(node: node, depth: depth)); + if (node.children.isNotEmpty) { + result.addAll(_buildHierarchicalOptions(node.children, depth: depth + 1)); + } + } + return result; + } + + /// 当树接口不可用时,根据 parentId 将扁平列表重建为层级顺序。 + List<_OrgOption> _buildHierarchyFromFlatList(List nodes) { + final map = {}; + final roots = []; + for (final node in nodes) { + map[node.id] = node; + } + for (final node in nodes) { + if (node.parentId.isEmpty || !map.containsKey(node.parentId)) { + roots.add(node); + } else { + // 由于 OrgNode 是 immutable,需要重建 parent 的 children 列表。 + final parent = map[node.parentId]!; + map[node.parentId] = OrgNode( + id: parent.id, + name: parent.name, + type: parent.type, + parentId: parent.parentId, + children: [...parent.children, node], + address: parent.address, + contactPhone: parent.contactPhone, + activationCode: parent.activationCode, + province: parent.province, + city: parent.city, + themeId: parent.themeId, + ); + } + } + return _buildHierarchicalOptions(roots); + } + + /// 懒加载省市区数据(仅首次)。 + Future _loadRegionData() async { + try { + await RegionData.instance.load(); + } catch (_) { + // 数据加载失败不影响主流程,选择器将显示空列表并提示。 + } + } + + /// 从 web 后台拉取主题模板列表,失败时回退到硬编码预设。 + /// + /// 仅用于新增门店时的默认皮肤选择;编辑门店改用 [_loadThemeGroups]。 + Future _loadTemplates() async { + try { + final list = await ThemeService().getThemeTemplateAll(); + if (!mounted) return; + setState(() { + _templates = list; + _loadingTemplates = false; + }); + } catch (_) { + if (!mounted) return; + setState(() { + _templates = StoreThemePresets.all + .map((t) => ThemeTemplate( + id: t.id, + name: t.label, + status: 'ENABLED', + )) + .toList(); + _loadingTemplates = false; + }); + } + } + + /// 编辑门店时渲染界面配置区块。 + Widget _buildThemeGroupsSection() { + final t = AppLocalizations.of(context); + if (_loadingThemeGroups) { + return const SizedBox( + height: 80, + child: Center(child: CircularProgressIndicator()), + ); + } + if (_themeGroupsError != null) { + return SizedBox( + height: 80, + child: Center( + child: Text( + _themeGroupsError!, + textAlign: TextAlign.center, + ), + ), + ); + } + return StoreThemeGroupList( + groups: _themeGroups, + emptyHint: t.noInterfaceConfig, + onThemeTap: (groupId, theme) { + // 点击可提示切换主题;真正切换需调用 /theme/page/active。 + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${t.belongGroup} $groupId · ${theme.name}'), + duration: const Duration(seconds: 1), + ), + ); + }, + ); + } + + /// 拉取门店下各设备分组的主题列表(界面配置)。 + Future _loadThemeGroups() async { + final orgId = widget.store?.orgId; + if (orgId == null || orgId.isEmpty) return; + setState(() { + _loadingThemeGroups = true; + _themeGroupsError = null; + }); + try { + final groups = await ThemeService().getStoreThemeGroups(orgId); + if (!mounted) return; + setState(() { + _themeGroups = groups; + _loadingThemeGroups = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _themeGroupsError = formatApiError(e, fallback: '界面配置加载失败'); + _loadingThemeGroups = false; + }); + } + } + + /// 加载当前门店的激活码信息(最大/已用终端数、当前激活码)。 + Future _loadActivationInfo() async { + final orgId = widget.store?.orgId; + if (orgId == null || orgId.isEmpty) return; + setState(() => _loadingActivationInfo = true); + try { + final info = await DeviceService().getActivationCodeInfo(orgId); + if (!mounted) return; + setState(() { + _activationInfo = info; + _loadingActivationInfo = false; + }); + // 回填「终端数」为当前最大终端数,方便用户查看/修改。 + final maxCount = info['maxActivateCount']?.toString(); + if (maxCount != null && maxCount.isNotEmpty) { + _terminalCountController.text = maxCount; + } + } catch (_) { + if (!mounted) return; + setState(() { + _activationInfo = null; + _loadingActivationInfo = false; + }); + } + } + + /// 点击「创建激活码」:为当前门店生成一个激活码(每次仅一个)。 + Future _generateActivationCode() async { + final t = AppLocalizations.of(context); + final orgId = widget.store?.orgId; + if (orgId == null || orgId.isEmpty) return; + final maxCount = int.tryParse(_terminalCountController.text.trim()) ?? 1; + setState(() => _saving = true); + try { + await DeviceService().generateActivationCode(orgId, maxCount); + await _loadActivationInfo(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.activationCodeGenerated)), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${t.generateFailed}:${formatApiError(e)}')), + ); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + /// 编辑门店时:展示最大/已用终端数 + 当前激活码 + 创建按钮。 + Widget _buildActivationCodeSection(AppLocalizations t) { + if (_loadingActivationInfo) { + return const SizedBox( + height: 80, + child: Center(child: CircularProgressIndicator()), + ); + } + final info = _activationInfo; + final code = info?['code']?.toString() ?? ''; + final max = info?['maxActivateCount']?.toString() ?? + _terminalCountController.text; + final used = info?['activatedCount']?.toString() ?? + info?['usedCount']?.toString() ?? + '0'; + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.activeActivationCode, + style: Theme.of(context) + .textTheme + .titleSmall + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _activationInfoRow(t.maxTerminalCount, max), + ), + Expanded( + child: _activationInfoRow(t.usedTerminalCount, used), + ), + ], + ), + const SizedBox(height: 12), + if (code.isEmpty) + Text( + t.noActiveActivationCode, + style: TextStyle( + color: Theme.of(context).colorScheme.outline, + ), + ) + else + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .primary + .withOpacity(0.4), + ), + ), + child: SelectableText( + code, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + fontFamily: 'monospace', + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _saving ? null : _generateActivationCode, + icon: const Icon(Icons.add_outlined), + label: Text(t.createActivationCode), + ), + ), + const SizedBox(height: 4), + Text( + t.activationCodeGenerateHint, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.outline, + ), + ), + ], + ), + ), + ); + } + + Widget _activationInfoRow(String label, String value) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.outline, + ), + ), + const SizedBox(height: 2), + Text( + value.isEmpty ? '—' : value, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } + + Future _pickRegion() async { + final result = await RegionPicker.show( + context: context, + initialProvinceCode: _provinceCode, + initialCityCode: _cityCode, + initialDistrictCode: _districtCode, + ); + if (result != null && mounted) { + setState(() { + _provinceCode = result.provinceCode; + _cityCode = result.cityCode; + _districtCode = result.districtCode; + _regionLabel = result.label; + }); + } + } + + Future _save() async { + final t = AppLocalizations.of(context); + if (!_formKey.currentState!.validate()) return; + + // 校验必选项:所在地区、所属组织。 + if (_provinceCode == null || _provinceCode!.isEmpty) { + _showSaveResultDialog(isSuccess: false, title: t.regionRequired); + return; + } + if (_cityCode == null || _cityCode!.isEmpty) { + _showSaveResultDialog(isSuccess: false, title: t.regionRequired); + return; + } + if (_parentId == null || _parentId!.isEmpty) { + _showSaveResultDialog(isSuccess: false, title: t.parentOrgRequired); + return; + } + + setState(() => _saving = true); + try { + // 首次尝试:扁平字段 + storeProfile 双保险,兼容后端 saveOrUpdate 与 Web 端习惯。 + await _submitSavePayload(useStringTypes: true); + } catch (e) { + debugPrint('[StoreForm] primary save failed: $e'); + if (!mounted) { + setState(() => _saving = false); + return; + } + // 部分旧版后端只接受整数类型的 type/status,做一次兼容重试。 + try { + await _submitSavePayload(useStringTypes: false); + } catch (fallbackError) { + debugPrint('[StoreForm] fallback save failed: $fallbackError'); + if (!mounted) return; + _showSaveResultDialog( + isSuccess: false, + title: '${t.saveFailed}:${formatApiError(fallbackError)}', + ); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + /// 构造并提交门店保存 payload。 + /// + /// [useStringTypes] 为 true 时 type='STORE'/status='ACTIVE',与 Web 端一致; + /// 为 false 时 type=3/status=1,兼容只接受整数枚举的旧版后端。 + Future _submitSavePayload({required bool useStringTypes}) async { + final orgId = widget.store?.orgId; + final addressDetail = _addressDetailController.text.trim(); + final provinceName = + (_provinceCode?.isNotEmpty ?? false) ? RegionData.instance.nameOf(_provinceCode!) : ''; + final cityName = + (_cityCode?.isNotEmpty ?? false) ? RegionData.instance.nameOf(_cityCode!) : ''; + final districtName = + (_districtCode?.isNotEmpty ?? false) ? RegionData.instance.nameOf(_districtCode!) : ''; + final terminalCount = int.tryParse(_terminalCountController.text.trim()) ?? 0; + final storeName = _nameController.text.trim(); + final phone = _phoneController.text.trim(); + + final fullAddress = [ + if (provinceName.isNotEmpty) provinceName, + if (cityName.isNotEmpty) cityName, + if (districtName.isNotEmpty) districtName, + if (addressDetail.isNotEmpty) addressDetail, + ].join(''); + + // 门店保存 payload:新增走 POST /sys/org/save(必填 province,不带主键), + // 编辑走 POST /sys/org/update(只认 id)。字段同时包含扁平结构与 Web 端 + // storeProfile 嵌套,避免字段缺失;路由由 OrgService.saveStore 自动分流。 + final data = { + if (orgId != null && orgId.isNotEmpty) 'id': orgId, + if (orgId != null && orgId.isNotEmpty) 'orgId': orgId, + 'parentId': _parentId, + 'name': storeName, + 'type': useStringTypes ? 'STORE' : 3, + 'orderNo': 0, + 'status': useStringTypes ? 'ACTIVE' : 1, + 'contactPhone': phone, + 'address': fullAddress.isNotEmpty ? fullAddress : addressDetail, + 'addressDetail': addressDetail, + if (_provinceCode != null && _provinceCode!.isNotEmpty) 'provinceCode': _provinceCode, + if (_cityCode != null && _cityCode!.isNotEmpty) 'cityCode': _cityCode, + if (_districtCode != null && _districtCode!.isNotEmpty) 'districtCode': _districtCode, + if (provinceName.isNotEmpty) 'province': provinceName, + if (cityName.isNotEmpty) 'city': cityName, + if (districtName.isNotEmpty) 'district': districtName, + if (_themeId != null && _themeId!.isNotEmpty) 'themeId': _themeId, + if (terminalCount > 0) 'groupLimit': terminalCount, + 'storeProfile': { + 'storeName': storeName, + 'phone': phone, + 'address': addressDetail, + if (provinceName.isNotEmpty) 'province': provinceName, + if (cityName.isNotEmpty) 'city': cityName, + if (districtName.isNotEmpty) 'district': districtName, + }, + }; + + debugPrint('[StoreForm] save payload: ${jsonEncode(data)}'); + + final newOrgId = await widget.orgService.saveStore(data); + // 保存门店主题皮肤到本地缓存(Web 端主题单独接口,移动端先本地占位)。 + if (_themeId != null && _themeId!.isNotEmpty) { + final themeOrgId = (newOrgId != null && newOrgId.isNotEmpty) + ? newOrgId + : widget.store?.orgId; + if (themeOrgId != null && themeOrgId.isNotEmpty) { + try { + await widget.orgService.setStoreTheme(themeOrgId, _themeId!); + } catch (_) { + // 主题缓存失败不影响门店保存结果。 + } + } + } + // 保存门店省市区归属到后端(与主题一致:独立接口上报,本地兜底已由 saveStore 完成)。 + // 仅当用户选了省份才上报;orgId 取新建返回的 id,编辑时回退到原门店 orgId。 + if (_provinceCode != null && _provinceCode!.isNotEmpty) { + final regionOrgId = (newOrgId != null && newOrgId.isNotEmpty) + ? newOrgId + : widget.store?.orgId; + if (regionOrgId != null && regionOrgId.isNotEmpty) { + try { + await widget.orgService.setRegion( + regionOrgId, + _provinceCode!, + _cityCode ?? '', + _districtCode ?? '', + _addressDetailController.text.trim(), + ); + } catch (e) { + // 省市区上报失败不影响门店主体保存结果,仅记录日志。 + debugPrint('[StoreForm] setRegion failed: $e'); + } + } + } + // 新增门店且填写了终端数时,保存成功后再自动生成激活码。 + // 激活码生成失败仅记录错误并提示,不影响门店本身已保存的结果。 + String? activationError; + if (newOrgId != null && newOrgId.isNotEmpty && terminalCount > 0) { + try { + await DeviceService().generateActivationCode(newOrgId, terminalCount); + } catch (e) { + activationError = formatApiError(e); + } + } + if (!mounted) return; + final t = AppLocalizations.of(context); + _showSaveResultDialog( + isSuccess: true, + title: activationError != null + ? t.storeSavedActivationFailed(activationError!) + : t.saved, + ); + widget.onSaved(); + } + + /// 门店保存结果居中弹窗(成功/失败均展示在页面中间)。 + void _showSaveResultDialog({ + required bool isSuccess, + required String title, + }) { + final t = AppLocalizations.of(context); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + icon: Icon( + isSuccess ? Icons.check_circle_outline : Icons.error_outline, + color: isSuccess ? Colors.green : Colors.red, + size: 42, + ), + title: Center(child: Text(title, textAlign: TextAlign.center)), + actionsAlignment: MainAxisAlignment.center, + actions: [ + FilledButton( + onPressed: () => Navigator.pop(ctx), + child: Text(t.confirm), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final isEdit = widget.store != null; + final t = AppLocalizations.of(context); + // 仅渲染表单内容;滚动与吸底保存栏由 showAppBottomSheet 统一承载。 + return Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + isEdit ? t.editStore : t.addStore, + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 20), + TextFormField( + controller: _nameController, + decoration: InputDecoration( + labelText: t.storeName, + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.store_outlined), + ), + validator: (v) => + v == null || v.trim().isEmpty ? t.storeNameRequired : null, + ), + const SizedBox(height: 12), + // 省市区联动选择(替代旧纯文本地址输入框)。 + InkWell( + onTap: _pickRegion, + child: InputDecorator( + decoration: InputDecoration( + labelText: t.region, + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.location_on_outlined), + ), + child: Row( + children: [ + Expanded( + child: Text( + _regionLabel.isEmpty ? t.regionHint : _regionLabel, + style: TextStyle( + color: _regionLabel.isEmpty + ? Theme.of(context).hintColor + : null, + ), + ), + ), + const Icon(Icons.arrow_drop_down), + ], + ), + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: _addressDetailController, + decoration: InputDecoration( + labelText: t.addressDetail, + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.home_outlined), + hintText: t.addressDetailHint, + ), + ), + const SizedBox(height: 12), + // 主题皮肤选择:编辑门店显示真实界面配置(分组+主题),新增门店保留模板选择。 + Text( + isEdit ? t.interfaceConfig : t.themeSkin, + style: Theme.of(context) + .textTheme + .titleSmall + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + if (isEdit) + _buildThemeGroupsSection() + else if (_loadingTemplates) + const SizedBox( + height: 80, + child: Center(child: CircularProgressIndicator()), + ) + else + StoreThemeGrid( + templates: _templates, + selectedId: _themeId, + onSelected: (id) => setState(() => _themeId = id), + ), + if (!isEdit && _themeId != null) ...[ + const SizedBox(height: 12), + StoreThemePreview( + template: _templates.cast().firstWhere( + (t) => t?.id == _themeId, + orElse: () => null, + ), + storeName: _nameController.text.trim().isEmpty + ? t.sampleStore + : _nameController.text.trim(), + ), + ], + const SizedBox(height: 12), + TextFormField( + controller: _terminalCountController, + decoration: InputDecoration( + labelText: t.terminalCountLabel, + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.devices_outlined), + hintText: t.terminalCountHint, + ), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 12), + if (isEdit) + _buildActivationCodeSection(t) + else + InputDecorator( + decoration: InputDecoration( + labelText: t.activationCode, + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.vpn_key_outlined), + ), + child: Text( + t.activationCodeWillAutoGenerate, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: _phoneController, + decoration: InputDecoration( + labelText: t.contactPhone, + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.phone_outlined), + ), + keyboardType: TextInputType.phone, + ), + const SizedBox(height: 12), + if (_loadingOrgs) + const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: Center(child: CircularProgressIndicator()), + ) + else + OrgPickerFormField( + value: _parentId, + labelText: t.parentOrg, + options: _toPickerOptions(), + searchHint: t.searchOrgHint, + emptyHint: t.noMatchingOrg, + // 修复:门店编辑/新增时不自动弹出组织选择器,仅用户主动点击时才展开。 + autoOpen: false, + onChanged: (v) => setState(() => _parentId = v), + validator: (v) { + if (v == null || v.isEmpty) { + return t.parentOrgRequired; + } + return null; + }, + ), + const SizedBox(height: 24), + ], + ), + ); + } +} diff --git a/lib/services/api_client.dart b/lib/services/api_client.dart new file mode 100644 index 0000000..92e07b9 --- /dev/null +++ b/lib/services/api_client.dart @@ -0,0 +1,667 @@ +import 'package:dio/dio.dart'; + +import '../utils/constants.dart'; +import '../utils/credential_storage.dart'; +import '../config/app_config.dart'; + +/// 内存态令牌存储。 +/// +/// token / refreshToken 仅保存在内存中(不引入 shared_preferences, +/// 避免鸿蒙端插件兼容问题),应用重启后需重新登录。 +class TokenStore { + TokenStore._(); + + /// 全局单例。 + static final TokenStore instance = TokenStore._(); + + /// 访问令牌。 + String? token; + + /// 刷新令牌。 + String? refreshToken; + + /// 保存登录 / 刷新后的令牌对。 + void save({required String token, String? refreshToken}) { + this.token = token; + if (refreshToken != null && refreshToken.isNotEmpty) { + this.refreshToken = refreshToken; + } + } + + /// 清空令牌(登出或会话失效时调用)。 + void clear() { + token = null; + refreshToken = null; + } + + /// 是否持有访问令牌。 + bool get hasToken => token != null && token!.isNotEmpty; +} + +/// 业务异常:后端统一响应 `{code, msg, data}` 中 code 非成功值时抛出。 +class ApiException implements Exception { + const ApiException(this.code, this.message); + + /// 业务错误码。 + final int code; + + /// 错误信息。 + final String message; + + @override + String toString() => message; +} + +/// 把 DioException / 业务异常转成用户可读的简短文本。 +/// +/// 优先展示后端返回的 msg,其次网络状态,最后兜底提示。 +String formatApiError(Object? error, {String fallback = '请求失败,请稍后重试'}) { + if (error is ApiException && error.message.isNotEmpty) { + return error.message; + } + if (error is DioException) { + final inner = error.error; + if (inner is ApiException && inner.message.isNotEmpty) { + return inner.message; + } + final response = error.response; + if (response != null && response.statusCode != null) { + return '服务器响应异常:HTTP ${response.statusCode}'; + } + switch (error.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.receiveTimeout: + case DioExceptionType.sendTimeout: + return '连接超时,请检查网络'; + case DioExceptionType.connectionError: + return '网络连接失败,请检查网络'; + case DioExceptionType.cancel: + return '请求已取消'; + case DioExceptionType.badCertificate: + return '服务器证书校验失败'; + default: + break; + } + } + final text = error?.toString(); + if (text != null && text.isNotEmpty && text != 'null') { + return text; + } + return fallback; +} + +/// 判断异常是否表示登录态失效(token 过期 / 401)。 +/// +/// 后端常以 HTTP 200 + 业务码 500 返回「token失效,请重新登录」而非 HTTP 401, +/// 拦截器已统一把这类场景包装为 [ApiException](401, '登录已过期,请重新登录')。 +/// +/// UI 层捕获到此类异常时,应跳过普通错误提示、改由路由重定向引导重新登录, +/// 避免把「登录已过期」之类的底层文案直接暴露给用户(如「查询失败:登录已过期」)。 +bool isUnauthorizedError(Object? error) { + ApiException? apiEx; + if (error is ApiException) { + apiEx = error; + } else if (error is DioException && error.error is ApiException) { + apiEx = error.error as ApiException; + } + if (apiEx == null) return false; + return apiEx.code == 401 || apiEx.message.contains('登录已过期'); +} + +/// HTTP 客户端(Dio 单例)。 +/// +/// - [AppConstants.useMock] = false:挂载 [ApiInterceptor],对接真实「爱视后台」, +/// 自动附加 Bearer 令牌与防缓存时间戳、解包 `{code,msg,data}`、 +/// 401 时用 refreshToken 刷新并重试一次; +/// - [AppConstants.useMock] = true:挂载 [MockInterceptor] 本地返回示例数据。 +class ApiClient { + ApiClient._() { + _dio = _buildDio(); + } + + /// 构建 Dio 实例(拦截器配置集中于此,[reconfigure] 复用)。 + /// + /// baseUrl 取自 [AppConfig],支持运行时切换环境后端地址。 + Dio _buildDio() { + final dio = Dio( + BaseOptions( + baseUrl: AppConfig.apiFullBaseUrl, + connectTimeout: AppConstants.apiTimeout, + receiveTimeout: AppConstants.apiTimeout, + headers: {'Content-Type': 'application/json; charset=utf-8'}, + ), + ); + if (AppConstants.useMock) { + // Mock 在前:直接本地 resolve,后续 ApiInterceptor 仍负责解包信封。 + dio.interceptors.add(MockInterceptor()); + } + dio.interceptors.add(ApiInterceptor(dio)); + return dio; + } + + /// 运行时切换后端地址后重建 Dio 单例(保留拦截器配置)。 + /// + /// 允许重复调用,用于环境切换、语言切换后刷新网络层等场景。 + /// 返回重建结果与可能的错误信息,避免直接抛异常导致 UI 崩溃。 + ({bool success, String? error}) reconfigure() { + try { + _dio = _buildDio(); + return (success: true, error: null); + } catch (e) { + return (success: false, error: e.toString()); + } + } + + /// 全局单例。 + static final ApiClient instance = ApiClient._(); + + /// Dio 实例。使用 [late](非 [late final])以支持 [reconfigure] 重复重建。 + late Dio _dio; + + /// 会话失效回调(401 刷新失败后触发),由认证层注册以清空登录态。 + void Function()? onUnauthorized; + + /// 获取 Dio 实例。 + Dio get dio => _dio; +} + +/// 真实接口拦截器:鉴权头、防缓存时间戳、信封解包、401 刷新重试。 +class ApiInterceptor extends Interceptor { + ApiInterceptor(this._dio); + + final Dio _dio; + + /// 刷新接口路径(避免 401 处理时对自身重试)。 + static const String _refreshPath = '/sys/refresh'; + + /// 标记已重试过的请求,防止循环刷新。 + static const String _retriedFlag = '__retried_after_refresh'; + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + // 鉴权:Bearer token(不使用 Cookie)。 + final token = TokenStore.instance.token; + if (token != null && token.isNotEmpty) { + options.headers['Authorization'] = 'Bearer $token'; + } + // 防缓存:仅 GET 在 query 上附加毫秒时间戳 t。 + // 注意:POST/PUT 写入请求不缓存,且绝不能把 t 注入业务 body—— + // 后端(如 CodeIgniter)按全字段 insert 时会因未知列 `t` 报 500, + // 导致「列表能读、保存失败」。故写入请求保持 body 纯净。 + if (options.method == 'GET') { + options.queryParameters['t'] = + DateTime.now().millisecondsSinceEpoch.toString(); + } + handler.next(options); + } + + @override + Future onResponse( + Response response, + ResponseInterceptorHandler handler, + ) async { + final body = response.data; + // 非信封格式(如二进制流)直接放行。 + if (body is! Map || !body.containsKey('code')) { + handler.next(response); + return; + } + + final code = _asInt(body['code']); + final msg = body['msg']?.toString() ?? '请求失败'; + + // 成功码:0 / 200,解包 data。 + if (code == 0 || code == 200) { + response.data = body['data']; + handler.next(response); + return; + } + + // 业务 401 或后端以普通业务码返回 token 失效:尝试刷新令牌并重试一次。 + final isTokenExpired = _isSessionExpiredMessage(msg); + if ((code == 401 || isTokenExpired) && + response.requestOptions.path != _refreshPath && + response.requestOptions.extra[_retriedFlag] != true) { + final retried = await _refreshAndRetry(response.requestOptions, handler); + if (retried) { + return; + } + // 刷新失败:已触发 _notifyUnauthorized() 清空登录态并跳转登录页, + // 将错误信息统一为更友好的文案,避免把原始 DioException 抛给 UI。 + handler.reject( + DioException( + requestOptions: response.requestOptions, + response: response, + error: ApiException(401, '登录已过期,请重新登录'), + ), + ); + return; + } + + handler.reject( + DioException( + requestOptions: response.requestOptions, + response: response, + error: ApiException(code, msg), + ), + ); + } + + @override + Future onError( + DioException err, + ErrorInterceptorHandler handler, + ) async { + // HTTP 401:同样尝试刷新令牌并重试一次。 + if (err.response?.statusCode == 401 && + err.requestOptions.path != _refreshPath && + err.requestOptions.extra[_retriedFlag] != true) { + final retried = await _refreshAndRetry(err.requestOptions, handler); + if (retried) { + return; + } + // _refreshAndRetry 失败路径内部已调用 _notifyUnauthorized,此处不重复调用。 + handler.next(err); + return; + } + // 已解包出的业务异常直接透传,其余包装网络错误信息。 + if (err.error is ApiException) { + handler.next(err); + return; + } + handler.next(err); + } + + /// 刷新令牌并重试原请求;成功返回 true。 + Future _refreshAndRetry( + RequestOptions options, + dynamic handler, + ) async { + final refreshed = await _tryRefreshToken(); + if (!refreshed) { + _notifyUnauthorized(); + return false; + } + try { + options.extra[_retriedFlag] = true; + // 重新附加新 token。 + final token = TokenStore.instance.token; + if (token != null) { + options.headers['Authorization'] = 'Bearer $token'; + } + final retryResponse = await _dio.fetch(options); + if (handler is ResponseInterceptorHandler) { + handler.resolve(retryResponse); + } else if (handler is ErrorInterceptorHandler) { + handler.resolve(retryResponse); + } + return true; + } catch (_) { + _notifyUnauthorized(); + return false; + } + } + + /// 进行中的刷新请求:并发 401 只发起一次 /sys/refresh,其余共享结果。 + Future? _refreshing; + + /// 用 refreshToken 换新 token(带并发锁)。 + Future _tryRefreshToken() { + return _refreshing ??= _doRefreshToken().whenComplete(() { + _refreshing = null; + }); + } + + /// 实际刷新逻辑。使用独立 Dio 实例,避免进入拦截器循环。 + Future _doRefreshToken() async { + final refreshToken = TokenStore.instance.refreshToken; + if (refreshToken == null || refreshToken.isEmpty) { + return false; + } + try { + final rawDio = Dio( + BaseOptions( + baseUrl: AppConfig.apiFullBaseUrl, + connectTimeout: AppConstants.apiTimeout, + receiveTimeout: AppConstants.apiTimeout, + headers: {'Content-Type': 'application/json; charset=utf-8'}, + ), + ); + final response = await rawDio.post>( + _refreshPath, + data: { + 'refreshToken': refreshToken, + 't': DateTime.now().millisecondsSinceEpoch.toString(), + }, + ); + final body = response.data; + if (body == null) { + return false; + } + final code = _asInt(body['code']); + if (code != 0 && code != 200) { + return false; + } + final data = body['data']; + if (data is Map && data['token'] != null) { + final newToken = data['token'].toString(); + final newRefreshToken = data['refreshToken']?.toString(); + TokenStore.instance.save( + token: newToken, + refreshToken: newRefreshToken, + ); + // 刷新后的新令牌必须同步持久化,否则切换语言/进程重启后 + // 会恢复到旧令牌,导致「token 失效」报错。 + await CredentialStorage.instance.saveSession( + token: newToken, + refreshToken: newRefreshToken, + ); + return true; + } + return false; + } catch (_) { + return false; + } + } + + /// 会话失效:清空令牌并通知认证层(跳转登录页)。 + void _notifyUnauthorized() { + TokenStore.instance.clear(); + ApiClient.instance.onUnauthorized?.call(); + } + + /// 判断后端返回的 msg 是否为 token/会话失效类提示。 + /// + /// 爱视后台有时以 HTTP 200 + 业务码 500 返回「token失效,请重新登录」, + /// 而非 HTTP 401;此处统一识别并走刷新/登出流程。 + static bool _isSessionExpiredMessage(String msg) { + final lower = msg.toLowerCase(); + if (!lower.contains('token') && !lower.contains('登录')) return false; + return lower.contains('失效') || + lower.contains('过期') || + lower.contains('无效') || + lower.contains('请重新登录') || + lower.contains('未登录'); + } + + static int _asInt(dynamic value) { + if (value is int) { + return value; + } + return int.tryParse(value?.toString() ?? '') ?? -1; + } +} + +/// Mock 拦截器(仅 [AppConstants.useMock] = true 时挂载)。 +/// +/// 返回与真实后端一致的 `{code, msg, data}` 信封,由 [ApiInterceptor] 统一解包。 +class MockInterceptor extends Interceptor { + @override + void onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) { + // 模拟网络延迟。 + Future.delayed(const Duration(milliseconds: 300), () { + handler.resolve( + Response( + requestOptions: options, + statusCode: 200, + data: _mockResponse(options), + ), + ); + }); + } + + Map _ok(Object? data) { + return {'code': 0, 'msg': 'success', 'data': data}; + } + + Map _mockResponse(RequestOptions options) { + final path = options.path; + + // 登录:POST /sys/login + if (path == '/sys/login') { + final body = options.data; + if (body is Map && + body['username'] == AppConstants.mockAccount && + body['password'] == AppConstants.mockPassword) { + return _ok({ + 'token': 'mock-token-20240520', + 'expire': 7200, + 'refreshToken': 'mock-refresh-token', + 'refreshExpire': 604800, + 'orgId': 1, + 'orgType': 1, + 'superAdmin': true, + 'roleNames': ['运维管理员'], + }); + } + return {'code': 500, 'msg': '账号或密码错误', 'data': null}; + } + + // 登出:POST /sys/logout + if (path == '/sys/logout') { + return _ok(null); + } + + // 验证码图片:GET /captcha.jpg?uuid=xxx + // mock 模式下返回一个 1x1 透明 PNG 占位图。 + if (path == '/captcha.jpg') { + return {'code': 0, 'msg': 'success', 'data': _transparentPng}; + } + + // 当前用户:GET /sys/user/info + if (path == '/sys/user/info') { + return _ok({ + 'username': AppConstants.mockAccount, + 'realName': '运维管理员', + 'roleNames': ['运维管理员'], + }); + } + + // 设备列表:GET /device/info/list + if (path == '/device/info/list') { + return _ok({'list': _mockDevices, 'totalCount': _mockDevices.length}); + } + + // 设备详情:GET /device/info/info/{id} + final deviceDetailMatch = + RegExp(r'^/device/info/info/([A-Za-z0-9\-]+)$').firstMatch(path); + if (deviceDetailMatch != null) { + final id = deviceDetailMatch.group(1); + for (final device in _mockDevices) { + if (device['id'] == id) { + return _ok(device); + } + } + return {'code': 404, 'msg': '未找到设备:$id', 'data': null}; + } + + // 设备指令:POST /device/command/sendByMac | sendByOrg + if (path == '/device/command/sendByMac' || + path == '/device/command/sendByOrg') { + return _ok(null); + } + + // 告警列表(仅 mock 模式使用;真实模式由设备列表推导):GET /alerts + if (path == '/alerts') { + return _ok(_mockAlerts); + } + + return {'code': 404, 'msg': '接口不存在:$path', 'data': null}; + } + + /// mock 验证码占位图:1x1 透明 PNG(Base64)。 + static const String _transparentPng = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='; + + /// 示例设备数据(真实 DeviceInfo 结构 + 演示用扩展字段: + /// ip / location / os / uptimeSeconds / metrics)。 + static final List> _mockDevices = [ + { + 'id': 'dev-001', + 'mac': 'AA:BB:CC:00:00:11', + 'name': '核心交换机-SW01', + 'deviceSn': 'SN-SW01-2024', + 'deviceUid': 'UID-0001', + 'status': 'online', + 'groupId': 'g-core', + 'lastSyncTime': '2024-05-20 09:30:00', + 'createTime': '2024-01-15 10:00:00', + 'ip': '10.0.1.11', + 'location': '华南机房-A区', + 'os': 'Huawei VRP V8', + 'uptimeSeconds': 1861272, + 'metrics': { + 'cpuUsage': 0.32, + 'memoryUsage': 0.41, + 'diskUsage': 0.55, + 'networkRxKbps': 1280.5, + 'networkTxKbps': 986.2, + }, + }, + { + 'id': 'dev-002', + 'mac': 'AA:BB:CC:00:00:21', + 'name': '应用服务器-APP01', + 'deviceSn': 'SN-APP01-2024', + 'deviceUid': 'UID-0002', + 'status': 'warning', + 'groupId': 'g-app', + 'lastSyncTime': '2024-05-20 09:28:40', + 'createTime': '2024-01-16 14:20:00', + 'ip': '10.0.2.21', + 'location': '华南机房-B区', + 'os': 'CentOS Stream 9', + 'uptimeSeconds': 952133, + 'metrics': { + 'cpuUsage': 0.87, + 'memoryUsage': 0.92, + 'diskUsage': 0.63, + 'networkRxKbps': 4520.8, + 'networkTxKbps': 3890.1, + }, + }, + { + 'id': 'dev-003', + 'mac': 'AA:BB:CC:00:00:31', + 'name': '数据库服务器-DB01', + 'deviceSn': 'SN-DB01-2024', + 'deviceUid': 'UID-0003', + 'status': 'online', + 'groupId': 'g-db', + 'lastSyncTime': '2024-05-20 09:29:55', + 'createTime': '2024-01-18 08:00:00', + 'ip': '10.0.2.31', + 'location': '华南机房-B区', + 'os': 'openEuler 22.03 LTS', + 'uptimeSeconds': 3654820, + 'metrics': { + 'cpuUsage': 0.45, + 'memoryUsage': 0.68, + 'diskUsage': 0.78, + 'networkRxKbps': 2310.4, + 'networkTxKbps': 1980.6, + }, + }, + { + 'id': 'dev-004', + 'mac': 'AA:BB:CC:00:00:42', + 'name': '存储节点-NAS02', + 'deviceSn': 'SN-NAS02-2024', + 'deviceUid': 'UID-0004', + 'status': 'offline', + 'groupId': 'g-store', + 'lastSyncTime': '2024-05-20 07:58:00', + 'createTime': '2024-02-01 09:00:00', + 'ip': '10.0.3.42', + 'location': '华北机房-C区', + 'os': 'TrueNAS SCALE 23.10', + 'uptimeSeconds': 0, + 'metrics': { + 'cpuUsage': 0.0, + 'memoryUsage': 0.0, + 'diskUsage': 0.0, + 'networkRxKbps': 0.0, + 'networkTxKbps': 0.0, + }, + }, + { + 'id': 'dev-005', + 'mac': 'AA:BB:CC:00:00:107', + 'name': '边缘网关-EDGE07', + 'deviceSn': 'SN-EDGE07-2024', + 'deviceUid': 'UID-0005', + 'status': 'online', + 'groupId': 'g-edge', + 'lastSyncTime': '2024-05-20 09:31:10', + 'createTime': '2024-03-05 16:40:00', + 'ip': '172.16.8.107', + 'location': '华东边缘节点', + 'os': 'OpenHarmony 4.1', + 'uptimeSeconds': 612345, + 'metrics': { + 'cpuUsage': 0.21, + 'memoryUsage': 0.35, + 'diskUsage': 0.48, + 'networkRxKbps': 320.7, + 'networkTxKbps': 288.3, + }, + }, + ]; + + /// 示例告警数据(仅 mock 模式使用)。 + static final List> _mockAlerts = [ + { + 'id': 'alert-001', + 'deviceId': 'dev-002', + 'deviceName': '应用服务器-APP01', + 'title': '内存使用率过高', + 'message': '内存使用率达到 92%,超过阈值 85%,请尽快扩容或排查内存泄漏。', + 'level': 'critical', + 'occurredAt': '2024-05-20T09:12:30.000', + 'acknowledged': false, + }, + { + 'id': 'alert-002', + 'deviceId': 'dev-002', + 'deviceName': '应用服务器-APP01', + 'title': 'CPU 使用率过高', + 'message': 'CPU 使用率达到 87%,超过阈值 80%。', + 'level': 'warning', + 'occurredAt': '2024-05-20T08:45:10.000', + 'acknowledged': true, + }, + { + 'id': 'alert-003', + 'deviceId': 'dev-004', + 'deviceName': '存储节点-NAS02', + 'title': '设备离线', + 'message': '存储节点连续 10 分钟无心跳,请检查电源与网络连接。', + 'level': 'critical', + 'occurredAt': '2024-05-20T07:58:00.000', + 'acknowledged': false, + }, + { + 'id': 'alert-004', + 'deviceId': 'dev-003', + 'deviceName': '数据库服务器-DB01', + 'title': '磁盘空间不足', + 'message': '磁盘使用率达到 78%,接近阈值 80%,建议清理归档日志。', + 'level': 'warning', + 'occurredAt': '2024-05-19T22:30:45.000', + 'acknowledged': true, + }, + { + 'id': 'alert-005', + 'deviceId': 'dev-001', + 'deviceName': '核心交换机-SW01', + 'title': '端口流量波动', + 'message': '上联端口 GE0/0/1 出现短时流量尖峰,已自动恢复。', + 'level': 'info', + 'occurredAt': '2024-05-19T16:05:22.000', + 'acknowledged': true, + }, + ]; +} diff --git a/lib/services/app_service.dart b/lib/services/app_service.dart new file mode 100644 index 0000000..5098d60 --- /dev/null +++ b/lib/services/app_service.dart @@ -0,0 +1,212 @@ +import 'package:dio/dio.dart'; + +import '../models/app_version.dart'; +import '../models/tv_app.dart'; +import 'api_client.dart'; +import '../utils/session_org_scope.dart'; + +/// 应用管理服务:APP 版本管理与 TV 应用管理。 +/// +/// 对接真实「爱视后台」: +/// - APP 版本:/api/device/orgGroupAppVersion/* +/// - TV 应用:/api/theme/tvapp/* +class AppService { + AppService({Dio? dio}) : _dio = dio ?? ApiClient.instance.dio; + + final Dio _dio; + + // ================ APP 版本管理 ================ + + /// 获取 APP 版本列表(分页,可按组织/分组筛选)。 + /// + /// GET /device/orgGroupAppVersion/list?page=&limit=&orgId=&groupId= + Future> getAppVersionList({ + int page = 1, + int limit = 20, + String? orgId, + String? groupId, + }) async { + final effectiveOrgId = orgId ?? + (SessionOrgScope.instance.enabled + ? SessionOrgScope.instance.currentOrgId + : null); + final response = await _dio.get( + '/device/orgGroupAppVersion/list', + queryParameters: { + 'page': page, + 'limit': limit, + if (effectiveOrgId != null) 'orgId': effectiveOrgId, + if (groupId != null) 'groupId': groupId, + }, + ); + final list = _extractList(response.data); + return list + .whereType>() + .map(AppVersion.fromJson) + .toList(); + } + + /// 按 orgId 查询该组织直接配置的版本(单体)。 + /// + /// 对齐 Web 端 `searchAppVersion`:每个 scope=ORG 的组织至多一条版本配置, + /// 有则返回当前版本(进入编辑流程)、无则返回 null(走新增流程)。 + /// + /// GET /device/orgGroupAppVersion/search?orgId= + /// + /// 注意:拦截器已解包 `{code,msg,data}` 信封,`response.data` 即内层 data; + /// 兼容 data 为 `{appVersion: {...}}`、版本对象直接返回、或 null(无配置)三种形态。 + Future?> searchAppVersion({ + required String orgId, + String? groupId, + }) async { + if (orgId.isEmpty && groupId == null) { + throw const ApiException(400, 'orgId 与 groupId 需二选一'); + } + final response = await _dio.get( + '/device/orgGroupAppVersion/search', + queryParameters: { + if (orgId.isNotEmpty) 'orgId': orgId, + if (groupId != null) 'groupId': groupId, + }, + ); + final data = response.data; + if (data == null) return null; + if (data is Map) { + final appVersion = data['appVersion']; + if (appVersion is Map) { + return Map.from(appVersion); + } + // 直接是版本对象(含 id 或 versionName)。 + if (data.containsKey('id') || data.containsKey('versionName')) { + return data; + } + } + return null; + } + + /// 获取 APP 版本详情。 + /// + /// GET /device/orgGroupAppVersion/info/{id} + Future getAppVersionInfo(String id) async { + final response = + await _dio.get('/device/orgGroupAppVersion/info/$id'); + final data = response.data; + if (data is! Map) { + throw ApiException(500, 'APP 版本信息加载失败:$id'); + } + return AppVersion.fromJson(data); + } + + /// 新增 APP 版本。 + /// + /// POST /device/orgGroupAppVersion/save,body 为版本数据。 + Future saveAppVersion(Map data) async { + await _dio.post( + '/device/orgGroupAppVersion/save', + data: data, + ); + } + + /// 修改 APP 版本。 + /// + /// POST /device/orgGroupAppVersion/update,body 为版本数据(含 id)。 + Future updateAppVersion(Map data) async { + await _dio.post( + '/device/orgGroupAppVersion/update', + data: data, + ); + } + + /// 批量删除 APP 版本。 + /// + /// POST /device/orgGroupAppVersion/delete,body 为 `{ids: [...]}`。 + Future deleteAppVersion(List ids) async { + await _dio.post( + '/device/orgGroupAppVersion/delete', + data: {'ids': ids}, + ); + } + + // ================ TV 应用管理 ================ + + /// 获取 TV 应用列表(分页,可按名称/组织/分组筛选)。 + /// + /// GET /theme/tvapp/list?page=&limit=&appName=&orgId=&groupId= + Future> getTvAppList({ + int page = 1, + int limit = 20, + String? appName, + String? orgId, + String? groupId, + }) async { + final effectiveOrgId = orgId ?? + (SessionOrgScope.instance.enabled + ? SessionOrgScope.instance.currentOrgId + : null); + final response = await _dio.get( + '/theme/tvapp/list', + queryParameters: { + 'page': page, + 'limit': limit, + if (appName != null) 'appName': appName, + if (effectiveOrgId != null) 'orgId': effectiveOrgId, + if (groupId != null) 'groupId': groupId, + }, + ); + final list = _extractList(response.data); + return list + .whereType>() + .map(TvApp.fromJson) + .toList(); + } + + /// 获取 TV 应用详情。 + /// + /// GET /theme/tvapp/info/{id} + Future getTvAppInfo(String id) async { + final response = await _dio.get('/theme/tvapp/info/$id'); + final data = response.data; + if (data is! Map) { + throw ApiException(500, 'TV 应用信息加载失败:$id'); + } + return TvApp.fromJson(data); + } + + /// 新增或修改 TV 应用。 + /// + /// POST /theme/tvapp/save,body 为应用数据。 + Future saveTvApp(Map data) async { + await _dio.post('/theme/tvapp/save', data: data); + } + + /// 批量删除 TV 应用。 + /// + /// POST /theme/tvapp/delete,body 为 `{ids: [...]}`。 + Future deleteTvApp(List ids) async { + await _dio.post( + '/theme/tvapp/delete', + data: {'ids': ids}, + ); + } + + // ================ 工具方法 ================ + + /// 兼容列表响应的三种形态: + /// `{list: [...], totalCount}` / `{page: {list, totalCount}}` / 直接数组。 + static List _extractList(dynamic data) { + if (data is List) { + return data; + } + if (data is Map) { + final list = data['list']; + if (list is List) { + return list; + } + final page = data['page']; + if (page is Map && page['list'] is List) { + return page['list'] as List; + } + } + return const []; + } +} diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart new file mode 100644 index 0000000..33f70ab --- /dev/null +++ b/lib/services/auth_service.dart @@ -0,0 +1,264 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; + +import '../models/user.dart'; +import '../utils/constants.dart'; +import '../utils/uuid.dart'; +import '../config/app_config.dart'; +import 'api_client.dart'; + +/// 认证服务:对接真实「爱视后台」/api/sys/* 接口。 +/// +/// mock 模式下([MockInterceptor] 挂载)请求路径与真实一致, +/// 由拦截器本地返回示例数据,service 层代码两种模式完全统一。 +class AuthService { + AuthService({Dio? dio, Dio? rawDio}) + : _dio = dio ?? ApiClient.instance.dio, + _rawDio = rawDio ?? _createRawDio(); + + final Dio _dio; + final Dio _rawDio; + + /// 用于拉取验证码图片的独立 Dio 实例(不经过 ApiInterceptor 信封解包)。 + static Dio _createRawDio() { + return Dio( + BaseOptions( + baseUrl: AppConfig.apiFullBaseUrl, + connectTimeout: AppConstants.apiTimeout, + receiveTimeout: AppConstants.apiTimeout, + responseType: ResponseType.bytes, + ), + ); + } + + /// 获取验证码图片字节与对应 UUID。 + /// + /// 接口:GET /captcha.jpg?uuid=xxx,返回图片二进制。 + /// web 端生成 UUID 规则:[generateUuid]。 + Future fetchCaptcha() async { + final uuid = generateUuid(); + + // mock 模式直接返回占位图,不发起真实请求。 + if (AppConstants.useMock) { + return Captcha(uuid: uuid, imageBytes: _mockCaptchaBytes); + } + + final response = await _rawDio.get>( + '/captcha.jpg', + queryParameters: {'uuid': uuid}, + ); + final bytes = response.data; + if (bytes == null || bytes.isEmpty) { + throw const AuthException('验证码加载失败'); + } + return Captcha(uuid: uuid, imageBytes: bytes); + } + + /// 登录。 + /// + /// 请求:POST /sys/login。 + /// 当 [AppConfig.enableCaptcha] 为真时,body 携带 `{username, password, + /// captcha, uuid}`;否则仅发送 `{username, password}`,由后端会话策略决定 + /// 是否仍需校验验证码(前端已不再展示/收集验证码)。 + /// 成功后令牌写入 [TokenStore](内存态),失败抛出 [AuthException]。 + Future login({ + required String account, + required String password, + String captcha = '', + String uuid = '', + }) async { + try { + // 仅当全局启用验证码时才把 captcha/uuid 填入请求体, + // 关闭后登录界面不展示验证码,请求体也不携带这两个字段。 + final body = { + 'username': account, + 'password': password, + }; + if (AppConfig.enableCaptcha) { + body['captcha'] = captcha; + body['uuid'] = uuid; + } + // 响应已被 ApiInterceptor 解包,data 即业务数据。 + final response = await _dio.post( + '/sys/login', + data: body, + ); + final data = response.data; + if (data is! Map || data['token'] == null) { + throw const AuthException('登录响应格式异常'); + } + + final token = data['token'].toString(); + TokenStore.instance.save( + token: token, + refreshToken: data['refreshToken']?.toString(), + ); + + // 尝试获取当前用户信息以补充显示名称、组织与角色 ID;失败不影响登录。 + final profile = await _tryFetchUserInfo(fallbackName: account); + + // 登录响应中直接取 orgId/roleId;若不存在则使用 /sys/user/info 结果。 + final loginOrgId = data['orgId']?.toString(); + final loginRoleId = data['roleId']?.toString() ?? + (data['roles'] is List && (data['roles'] as List).isNotEmpty + ? (data['roles'] as List).first['roleId']?.toString() + : null); + final loginOrgType = data['orgType']; + final loginSuperAdmin = data['superAdmin']; + + return User( + id: loginOrgId ?? account, + account: account, + displayName: profile.displayName, + role: _joinRoleNames(data['roleNames']) ?? profile.role ?? '', + token: token, + orgId: loginOrgId ?? profile.orgId ?? '', + roleId: loginRoleId ?? profile.roleId ?? '', + orgType: loginOrgType is int + ? loginOrgType + : int.tryParse(loginOrgType?.toString() ?? '') ?? + profile.orgType, + superAdmin: loginSuperAdmin is bool + ? loginSuperAdmin + : profile.superAdmin, + ); + } on AuthException { + rethrow; + } on DioException catch (e) { + throw AuthException(_errorMessage(e, fallback: '账号或密码错误')); + } + } + + /// 登出:通知后端(失败忽略),并清空内存令牌。 + Future logout() async { + try { + await _dio.post('/sys/logout'); + } catch (_) { + // 登出接口失败不阻塞本地登出。 + } finally { + TokenStore.instance.clear(); + } + } + + /// 拉取用户资料(GET /sys/user/info);失败时返回兜底值。 + Future<_UserProfile> _tryFetchUserInfo({required String fallbackName}) async { + try { + final response = await _dio.get('/sys/user/info'); + final data = response.data; + if (data is Map) { + final roleId = data['roleId']?.toString() ?? + _extractFirstRoleId(data['roles'] ?? data['role']); + final orgId = data['orgId']?.toString() ?? + _extractNestedId(data['org'] ?? data['dept']); + final rawOrgType = data['orgType']; + final rawSuperAdmin = data['superAdmin']; + return _UserProfile( + displayName: (data['realName'] ?? + data['nickname'] ?? + data['username'] ?? + fallbackName) + .toString(), + role: _joinRoleNames(data['roleNames']), + orgId: orgId, + roleId: roleId, + orgType: rawOrgType is int + ? rawOrgType + : int.tryParse(rawOrgType?.toString() ?? '') ?? 0, + superAdmin: rawSuperAdmin is bool ? rawSuperAdmin : false, + ); + } + } catch (_) { + // 用户信息接口异常时使用兜底资料。 + } + return _UserProfile(displayName: fallbackName, role: null); + } + + /// 从角色对象或列表中提取第一个 roleId。 + static String? _extractFirstRoleId(dynamic value) { + if (value is List && value.isNotEmpty) { + return _extractFirstRoleId(value.first); + } + if (value is Map) { + return value['roleId']?.toString() ?? value['id']?.toString(); + } + return null; + } + + /// 从组织/部门对象中提取 ID。 + static String? _extractNestedId(dynamic value) { + if (value is Map) { + return value['orgId']?.toString() ?? + value['id']?.toString() ?? + value['deptId']?.toString(); + } + return null; + } + + /// roleNames 列表拼接为展示字符串。 + static String? _joinRoleNames(dynamic roleNames) { + if (roleNames is List && roleNames.isNotEmpty) { + return roleNames.map((e) => e.toString()).join('/'); + } + return null; + } + + /// 从 Dio 异常中提取业务错误信息。 + static String _errorMessage(DioException e, {required String fallback}) { + final error = e.error; + if (error is ApiException && error.message.isNotEmpty) { + return error.message; + } + if (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.receiveTimeout || + e.type == DioExceptionType.connectionError) { + return '网络异常,请检查网络后重试'; + } + return fallback; + } + + /// mock 模式下的占位验证码图片:1x1 透明 PNG。 + static final List _mockCaptchaBytes = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==', + ); +} + +/// 用户资料中间结构(service 内部使用)。 +class _UserProfile { + const _UserProfile({ + required this.displayName, + this.role, + this.orgId, + this.roleId, + this.orgType = 0, + this.superAdmin = false, + }); + + final String displayName; + final String? role; + final String? orgId; + final String? roleId; + final int orgType; + final bool superAdmin; +} + +/// 验证码数据。 +class Captcha { + const Captcha({required this.uuid, required this.imageBytes}); + + /// 与该验证码图片绑定的 UUID,登录时必须原样回传。 + final String uuid; + + /// 验证码 PNG/JPEG 图片字节。 + final List imageBytes; +} + +/// 认证异常。 +class AuthException implements Exception { + const AuthException(this.message); + + final String message; + + @override + String toString() => message; +} diff --git a/lib/services/cos/cos_client.dart b/lib/services/cos/cos_client.dart new file mode 100644 index 0000000..27c6f7f --- /dev/null +++ b/lib/services/cos/cos_client.dart @@ -0,0 +1,355 @@ +// 腾讯云 COS 直传客户端(纯 Dart 实现,对齐 Web 端 cos-js-sdk-v5 的 STS 临时密钥签名)。 +// +// 来源:flutter_cos_client (MIT) 的本地化副本。 +// 之所以 vendoring 而非直接依赖 pub 包: +// - 原 pub 包 (0.0.1) 在生成签名时硬编码 `expires = now + 3600`, +// 当后端下发的 STS 临时密钥有效期短于 1 小时时,COS 会以 403 拒绝 +// (q-sign-time 末尾超出凭证 ExpiredTime)。此处改为尊重凭证自身的 ExpiredTime。 +// - 移除对未维护 0.0.1 包的依赖,便于长期维护。 +// - 去掉 mime 依赖,用内置扩展名→Content-Type 映射。 + +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:dio/dio.dart'; + +/// 临时云 API 凭据。 +class CosCredential { + /// 临时密钥 Id。 + final String tmpSecretId; + + /// 临时密钥 Key。 + final String tmpSecretKey; + + /// 请求时需要用的 token 字符串。 + final String sessionToken; + + CosCredential({ + required this.tmpSecretId, + required this.tmpSecretKey, + required this.sessionToken, + }); +} + +/// COS 临时凭据数据。 +class CosCredentialData { + /// 密钥的起始时间,UNIX 时间戳(秒)。 + final int startTime; + + /// 密钥的失效时间,UNIX 时间戳(秒)。 + final int expiredTime; + + /// 临时云 API 凭据。 + final CosCredential credentials; + + /// 请求 ID。 + final String requestId; + + CosCredentialData({ + required this.startTime, + required this.expiredTime, + required this.credentials, + required this.requestId, + }); + + /// 检查凭据是否已过期(提前 5 分钟判断为过期)。 + bool get isExpired { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + return now >= (expiredTime - 300); + } +} + +/// 临时秘钥提供者。 +class TemporaryCredentialProvider { + /// 获取临时凭据的异步函数。 + final Future Function() fetchCredentials; + + /// 缓存的临时凭据。 + CosCredentialData? _cachedCredentials; + + TemporaryCredentialProvider({required this.fetchCredentials}); + + /// 获取有效的临时凭据(缓存过期则重新获取)。 + Future getCredentials() async { + if (_cachedCredentials == null || _cachedCredentials!.isExpired) { + _cachedCredentials = await fetchCredentials(); + } + return _cachedCredentials!; + } + + /// 清除缓存的凭据。 + void clearCache() { + _cachedCredentials = null; + } +} + +/// 腾讯云 COS 客户端。 +class CosClient { + /// 地域,例如:ap-beijing。 + final String region; + + /// 存储桶名称,格式:bucketname-appid。 + final String bucket; + + /// 临时秘钥提供者。 + final TemporaryCredentialProvider? temporaryCredentialProvider; + + late final Dio _dio; + + /// 构造函数(使用临时密钥提供者)。 + CosClient.withTemporaryCredential({ + required this.temporaryCredentialProvider, + required this.region, + required this.bucket, + BaseOptions? dioOptions, + }) { + _dio = Dio(dioOptions ?? BaseOptions()); + } + + /// 获取存储桶的 Host。 + String get _host => '$bucket.cos.$region.myqcloud.com'; + + /// 上传对象(字节数据)。 + Future putObject({ + required String objectKey, + required List fileBytes, + String? fileName, + String? contentType, + void Function(int count, int total)? onProgress, + }) async { + // 确保 objectKey 以 / 开头。 + if (!objectKey.startsWith('/')) { + objectKey = '/$objectKey'; + } + + final fileLength = fileBytes.length; + + // 计算 Content-MD5。 + final md5Digest = md5.convert(fileBytes); + final contentMd5 = base64.encode(md5Digest.bytes); + + // 确定 Content-Type。 + contentType ??= _getContentType(fileName ?? ''); + + // 获取密钥(临时)。 + String currentSecretId; + String currentSecretKey; + String? sessionToken; + int signExpires; + + if (temporaryCredentialProvider != null) { + final credentials = await temporaryCredentialProvider!.getCredentials(); + currentSecretId = credentials.credentials.tmpSecretId; + currentSecretKey = credentials.credentials.tmpSecretKey; + sessionToken = credentials.credentials.sessionToken; + // 关键修复:签名有效期不得超过 STS 凭证自身的 ExpiredTime, + // 否则 COS 会以 403 拒绝(q-sign-time 末尾超出凭证有效期)。 + signExpires = credentials.expiredTime; + } else { + throw ArgumentError('CosClient 仅支持临时密钥(STS)方式'); + } + + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + // 构建请求头(临时密钥需包含 x-cos-security-token)。 + final headers = { + 'content-length': fileLength.toString(), + 'content-md5': contentMd5, + 'content-type': contentType, + 'host': _host, + }; + if (sessionToken != null) { + headers['x-cos-security-token'] = sessionToken; + } + + // 生成签名。 + final authorization = _generateAuthorization( + method: 'put', + uri: objectKey, + headers: headers, + queryParams: {}, + now: now, + expires: signExpires, + secretId: currentSecretId, + secretKey: currentSecretKey, + ); + + // 构建请求 URL。 + final url = 'https://$_host$objectKey'; + + // 构建实际请求头。 + final requestHeaders = { + 'Authorization': authorization, + 'Content-Type': contentType, + 'Content-Length': fileLength.toString(), + 'Content-MD5': contentMd5, + 'Host': _host, + }; + if (sessionToken != null) { + requestHeaders['x-cos-security-token'] = sessionToken; + } + + try { + final response = await _dio.put( + url, + data: Stream.fromIterable(fileBytes.map((e) => [e])), + options: Options( + headers: requestHeaders, + contentType: contentType, + responseType: ResponseType.plain, + validateStatus: (status) => status != null && status < 500, + ), + onSendProgress: onProgress, + ); + + if (response.statusCode == 200) { + return CosUploadResult( + success: true, + statusCode: response.statusCode!, + etag: response.headers.value('etag'), + message: 'Upload successful', + url: url, + ); + } + return CosUploadResult( + success: false, + statusCode: response.statusCode!, + message: 'Upload failed: ${response.statusMessage}, ${response.data}', + ); + } catch (e) { + return CosUploadResult( + success: false, + statusCode: -1, + message: 'Upload error: $e', + ); + } + } + + /// 从文件上传对象(便捷方法)。 + Future putObjectFromFile({ + required String objectKey, + required File file, + String? contentType, + void Function(int count, int total)? onProgress, + }) async { + final fileBytes = await file.readAsBytes(); + return putObject( + objectKey: objectKey, + fileBytes: fileBytes, + fileName: file.path, + contentType: contentType, + onProgress: onProgress, + ); + } + + /// 生成 Authorization 签名(腾讯云 q-sign-algorithm=sha1)。 + String _generateAuthorization({ + required String method, + required String uri, + required Map headers, + required Map queryParams, + required int now, + required int expires, + required String secretId, + required String secretKey, + }) { + final keyTime = '$now;$expires'; + final signKey = _hmacSha1(secretKey, keyTime); + final urlParamList = _generateParamList(queryParams); + final httpParameters = _generateHttpParameters(queryParams); + final headerList = _generateParamList(headers); + final httpHeaders = _generateHttpParameters(headers); + + final httpString = + '${method.toLowerCase()}\n$uri\n$httpParameters\n$httpHeaders\n'; + final httpStringSha1 = sha1.convert(utf8.encode(httpString)).toString(); + final stringToSign = 'sha1\n$keyTime\n$httpStringSha1\n'; + final signature = _hmacSha1(signKey, stringToSign); + + return 'q-sign-algorithm=sha1' + '&q-ak=$secretId' + '&q-sign-time=$keyTime' + '&q-key-time=$keyTime' + '&q-header-list=$headerList' + '&q-url-param-list=$urlParamList' + '&q-signature=$signature'; + } + + /// HMAC-SHA1 加密。 + String _hmacSha1(String key, String data) { + final hmac = Hmac(sha1, utf8.encode(key)); + final digest = hmac.convert(utf8.encode(data)); + return digest.toString(); + } + + /// 生成参数列表(key 排序后用 ; 分隔)。 + String _generateParamList(Map params) { + if (params.isEmpty) return ''; + final keys = params.keys.toList()..sort(); + return keys.map((k) => k.toLowerCase()).join(';'); + } + + /// 生成 HTTP 参数字符串(key=value 格式,用 & 分隔,需 URL 编码)。 + String _generateHttpParameters(Map params) { + if (params.isEmpty) return ''; + final keys = params.keys.toList()..sort(); + return keys + .map((k) => + '${Uri.encodeComponent(k.toLowerCase())}=${Uri.encodeComponent(params[k]!)}') + .join('&'); + } + + /// 根据文件扩展名获取 Content-Type(替代 mime 依赖)。 + String _getContentType(String filePath) { + final lower = filePath.toLowerCase(); + final dot = lower.lastIndexOf('.'); + final ext = dot >= 0 ? lower.substring(dot + 1) : ''; + const map = { + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'png': 'image/png', + 'gif': 'image/gif', + 'webp': 'image/webp', + 'bmp': 'image/bmp', + 'mp4': 'video/mp4', + 'webm': 'video/webm', + 'mov': 'video/quicktime', + 'm4v': 'video/x-m4v', + 'pdf': 'application/pdf', + }; + return map[ext] ?? 'application/octet-stream'; + } +} + +/// COS 上传结果。 +class CosUploadResult { + /// 是否成功。 + final bool success; + + /// HTTP 状态码。 + final int statusCode; + + /// ETag(文件的唯一标识)。 + final String? etag; + + /// 消息。 + final String? message; + + /// 对象的 COS 访问地址。 + final String? url; + + CosUploadResult({ + required this.success, + required this.statusCode, + this.etag, + this.message, + this.url, + }); + + @override + String toString() { + return 'CosUploadResult{success: $success, statusCode: $statusCode, ' + 'etag: $etag, message: $message, url: $url}'; + } +} diff --git a/lib/services/device_service.dart b/lib/services/device_service.dart new file mode 100644 index 0000000..a1cfb92 --- /dev/null +++ b/lib/services/device_service.dart @@ -0,0 +1,612 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; + +import '../models/alert.dart'; +import '../models/device.dart'; +import '../models/org_node.dart'; +import '../utils/constants.dart'; +import '../utils/session_org_scope.dart'; +import '../utils/store_local_cache.dart'; +import 'org_service.dart'; +import 'api_client.dart'; + +/// 设备与告警数据服务:对接真实「爱视后台」/api/device/* 接口。 +/// +/// - 设备列表:GET /device/info/list(响应三种形态均兼容,见 [_extractList]); +/// - 设备详情:GET /device/info/info/{id}; +/// - 设备指令:POST /device/command/sendByMac | sendByOrg(service 已实现, +/// 下发 UI 属下一轮迭代); +/// - 告警:真实后端无 alerts 端点,real 模式下由设备列表推导 +/// (offline→critical、warning→warning);mock 模式使用示例告警。 +/// - 设备分组:GET/POST /device/group/*; +/// - 激活码:POST/GET /device/activationCode/*; +/// - 门店配置:GET/POST /device/storeConfig/*。 +class DeviceService { + DeviceService({Dio? dio}) : _dio = dio ?? ApiClient.instance.dio; + + final Dio _dio; + + /// 获取设备总数量(后端分页响应中的 totalCount)。 + /// + /// GET /device/info/list?page=1&limit=1 + Future fetchDeviceTotalCount() async { + final response = await _dio.get( + '/device/info/list', + queryParameters: {'page': 1, 'limit': 1}, + ); + final data = response.data; + if (data is Map) { + final total = data['totalCount']; + if (total is int) return total; + if (total is String) return int.tryParse(total) ?? 0; + // 部分后端把 total 放到 page.totalCount。 + final page = data['page']; + if (page is Map) { + final pageTotal = page['totalCount']; + if (pageTotal is int) return pageTotal; + if (pageTotal is String) return int.tryParse(pageTotal) ?? 0; + } + } + return 0; + } + + /// 按状态统计设备数量(O(1):limit=1 仅取 totalCount,不下拉全量)。 + /// + /// [status] 取值:'online' / 'offline' / 'warning';传 null 表示全部。 + /// GET /device/info/list?page=1&limit=1&status=xxx + Future fetchDeviceCountByStatus({String? status}) async { + final query = {'page': 1, 'limit': 1}; + if (status != null) query['status'] = status; + final response = await _dio.get( + '/device/info/list', + queryParameters: query, + ); + final data = response.data; + if (data is Map) { + final total = data['totalCount']; + if (total is int) return total; + if (total is String) return int.tryParse(total) ?? 0; + final page = data['page']; + if (page is Map) { + final pageTotal = page['totalCount']; + if (pageTotal is int) return pageTotal; + if (pageTotal is String) return int.tryParse(pageTotal) ?? 0; + } + } + return 0; + } + + /// 获取设备列表。 + Future> fetchDevices({ + int page = 1, + int limit = AppConstants.deviceListPageSize, + String? status, + String? mac, + String? deviceSn, + String? deviceUid, + String? groupId, + String? storeOrgId, + }) async { + final response = await _dio.get( + '/device/info/list', + queryParameters: { + 'page': page, + 'limit': limit, + if (status != null) 'status': status, + if (mac != null) 'mac': mac, + if (deviceSn != null) 'deviceSn': deviceSn, + if (deviceUid != null) 'deviceUid': deviceUid, + if (groupId != null) 'groupId': groupId, + if (storeOrgId != null) 'storeOrgId': storeOrgId, + }, + ); + final list = _extractList(response.data); + return list + .whereType>() + .map(Device.fromJson) + .toList(); + } + + /// 按多个门店(组织节点)批量拉取并合并设备列表。 + /// + /// 后端 `/device/info/list` 仅接受单个 `storeOrgId`,故对每个门店 orgId 并发 + /// 请求 [fetchDevices],再把结果合并、按 [Device.id] 去重(同一设备不会因归属 + /// 多个选中组织而重复出现)。 + /// + /// [storeOrgIds] 为空时直接走 [fetchDevices](等效于「全部门店」),保持原行为。 + Future> fetchDevicesByOrgSet( + Set storeOrgIds, { + String? status, + String? mac, + String? deviceSn, + String? deviceUid, + String? groupId, + }) async { + if (storeOrgIds.isEmpty) { + return fetchDevices( + status: status, + mac: mac, + deviceSn: deviceSn, + deviceUid: deviceUid, + groupId: groupId, + ); + } + final lists = await Future.wait( + storeOrgIds.map( + (id) => _fetchDevicesForStore( + id, + status: status, + mac: mac, + deviceSn: deviceSn, + deviceUid: deviceUid, + groupId: groupId, + ), + ), + ); + final map = {}; + for (final d in lists.expand((x) => x)) { + map[d.id] = d; + } + return map.values.toList(); + } + + /// 按单个门店 orgId 拉取设备列表(构造查询参数 + 解析)。 + /// + /// 抽取自 [fetchDevices] 的单店请求逻辑,供 [fetchDevicesByOrgSet] 复用, + /// 避免多门店合并时重复拼装 queryParameters。 + Future> _fetchDevicesForStore( + String storeOrgId, { + String? status, + String? mac, + String? deviceSn, + String? deviceUid, + String? groupId, + }) async { + final response = await _dio.get( + '/device/info/list', + queryParameters: { + 'page': 1, + 'limit': AppConstants.deviceListPageSize, + if (status != null) 'status': status, + if (mac != null) 'mac': mac, + if (deviceSn != null) 'deviceSn': deviceSn, + if (deviceUid != null) 'deviceUid': deviceUid, + if (groupId != null) 'groupId': groupId, + 'storeOrgId': storeOrgId, + }, + ); + final list = _extractList(response.data); + return list + .whereType>() + .map(Device.fromJson) + .toList(); + } + + /// 按 ID 获取设备详情;不存在时抛出 [DeviceNotFoundException]。 + /// + /// 注意:详情接口 /device/info/info/{id} 返回的 `status` / `lastSyncTime` + /// 可能滞后于列表接口。Web 端设备列表走 /device/info/list 并显示最新在线状态, + /// 因此本方法在拿到详情后,再用 [Device.mac] 到列表接口取一次最新状态并合并, + /// 使手机端详情页与 Web 端列表/心跳保持一致。 + Future fetchDeviceById(String id) async { + try { + final response = + await _dio.get('/device/info/info/$id'); + final data = response.data; + if (data is! Map) { + throw DeviceNotFoundException('未找到设备:$id'); + } + final detail = Device.fromJson(data); + return _mergeWithFreshListStatus(detail); + } on DioException catch (e) { + final error = e.error; + if (error is ApiException) { + throw DeviceNotFoundException('${error.message}($id)'); + } + throw DeviceNotFoundException('设备详情加载失败:$id'); + } + } + + /// 用 [detail.mac] 从列表接口拉取最新状态并合并。 + /// + /// 列表接口 /device/info/list 与 Web 端同源,状态/心跳刷新更及时; + /// 合并时以列表的 [status] 与 [lastSyncTime] 覆盖详情,其余字段保留详情。 + Future _mergeWithFreshListStatus(Device detail) async { + if (detail.mac.isEmpty) return detail; + try { + final freshList = await fetchDevices(mac: detail.mac, limit: 1); + if (freshList.isEmpty) return detail; + final fresh = freshList.first; + return detail.copyWith( + status: fresh.status, + lastSyncTime: fresh.lastSyncTime ?? detail.lastSyncTime, + ); + } on Object { + // 列表接口失败时不阻断详情展示,保持原样。 + return detail; + } + } + + /// 更新设备信息(修改分组、房号等)。 + /// + /// POST /device/info/update,body 至少含 `id` 与需修改字段。 + Future updateDevice(Map data) async { + await _dio.post('/device/info/update', data: data); + } + + /// 新增设备。 + /// + /// POST /device/info/save,必填字段参考 Web 端:groupId、mac。 + Future saveDeviceInfo(Map data) async { + await _dio.post('/device/info/save', data: data); + } + + /// 删除单个设备。 + /// + /// 后端 `POST /device/info/delete` 接收的是**裸 JSON 数组** `[id]`,与 Web 端 + /// 设备列表批量删除保持一致(Web 调用 `Ao(t)` → `$.adornData(t,!1)` → + /// `JSON.stringify([id,...])`)。 + /// 旧实现误传 `{'ids': [id]}`(对象),后端无法解析请求体 → 返回「未知异常」。 + /// id 在可转为数字时按数字发送(复刻 Web 端 `Yo()` 的 `Number(id)` 行为), + /// 否则保留原字符串,保证数字/字符串两种主键都能正确命中。 + Future deleteDevice(String id) async { + final idValue = int.tryParse(id) ?? id; + await _dio.post('/device/info/delete', data: [idValue]); + } + + /// 按 MAC 查询设备(最多返回 1 条,用于判断设备是否已存在)。 + Future fetchDeviceByMac(String mac) async { + if (mac.isEmpty) return null; + final devices = await fetchDevices(mac: mac, limit: 1); + return devices.isNotEmpty ? devices.first : null; + } + + /// 扫码激活/绑定设备:把设备真正写入后端。 + /// + /// 逻辑: + /// 1. 先按 MAC 查询设备是否已存在; + /// 2. 存在则走 `/device/info/update` 更新归属(门店/分组/房号); + /// 3. 不存在则走 `/device/info/save` 新增设备。 + /// + /// 这样手机端扫码激活后,Web 端设备列表能立即看到该设备。 + Future activateDeviceByScan({ + required String mac, + required String storeId, + String? groupId, + String? roomNo, + }) async { + final existing = await fetchDeviceByMac(mac); + if (existing != null && existing.id.isNotEmpty) { + final data = { + 'id': existing.id, + 'mac': mac, + }; + if (groupId != null && groupId.isNotEmpty) data['groupId'] = groupId; + if (roomNo != null && roomNo.isNotEmpty) data['roomId'] = roomNo; + await updateDevice(data); + return; + } + + final data = { + 'mac': mac, + // 门店在后端设备表中通常映射为 orgId / storeOrgId;同时携带两个别名, + // 兼容不同后端版本。 + 'orgId': storeId, + 'storeOrgId': storeId, + }; + if (groupId != null && groupId.isNotEmpty) data['groupId'] = groupId; + if (roomNo != null && roomNo.isNotEmpty) data['roomId'] = roomNo; + await saveDeviceInfo(data); + } + + /// 按 MAC 下发设备指令。 + /// + /// [command] 取值见 [DeviceCommand];[data] 为指令参数(可为空)。 + Future sendCommandByMac({ + required String mac, + required String command, + Map? data, + }) async { + await _dio.post( + '/device/command/sendByMac', + data: {'mac': mac, 'command': command, 'data': data ?? {}}, + ); + } + + /// 按组织下发设备指令(批量)。 + Future sendCommandByOrg({ + required String orgId, + required String command, + Map? data, + }) async { + await _dio.post( + '/device/command/sendByOrg', + data: { + 'orgId': orgId, + 'command': command, + 'data': data ?? {}, + }, + ); + } + + /// 获取告警列表。 + /// + /// 真实后端无 alerts 端点:real 模式下由设备列表推导—— + /// 仅为异常设备生成告警(offline→critical、warning→warning); + /// 在线设备的级别映射为 info(见 [alertLevelFor]),但不产生告警条目。 + /// mock 模式下返回内置示例告警。 + Future> fetchAlerts() async { + if (AppConstants.useMock) { + final response = await _dio.get('/alerts'); + final data = response.data; + if (data is List) { + return data + .whereType>() + .map(Alert.fromJson) + .toList(); + } + return []; + } + + final devices = await fetchDevices(limit: AppConstants.deviceListPageSize); + // 受限账号:仅保留其组织子树内门店所属设备的告警,避免泄露跨组织设备告警。 + final scope = SessionOrgScope.instance; + List scopedDevices = devices; + if (scope.enabled) { + final ids = await OrgService().scopedOrgIdSet(); + if (ids.isNotEmpty) { + scopedDevices = devices.where((d) => ids.contains(d.storeId)).toList(); + } + } + final alerts = []; + for (final device in scopedDevices) { + if (device.status == DeviceStatus.online) { + continue; + } + alerts.add(_deriveAlert(device)); + } + // 按级别严重程度排序:critical 在前。 + alerts.sort((a, b) => a.level.index.compareTo(b.level.index)); + return alerts; + } + + /// 设备状态 → 告警级别映射(offline→critical、warning→warning、online→info)。 + static AlertLevel alertLevelFor(DeviceStatus status) { + switch (status) { + case DeviceStatus.offline: + return AlertLevel.critical; + case DeviceStatus.warning: + return AlertLevel.warning; + case DeviceStatus.online: + return AlertLevel.info; + } + } + + /// 由异常设备推导告警条目。 + static Alert _deriveAlert(Device device) { + final offline = device.status == DeviceStatus.offline; + return Alert( + id: 'derived-${device.id}', + deviceId: device.id, + deviceName: device.name, + title: offline ? '设备离线' : '设备指标异常', + message: offline + ? '设备 ${device.mac} 最近无心跳上报,请检查电源与网络连接。' + : '设备 ${device.mac} 上报状态为「告警」,请登录后台查看详情。', + level: alertLevelFor(device.status), + occurredAt: device.lastSyncTime ?? DateTime.now(), + acknowledged: false, + ); + } + + // ================ 设备分组管理 ================ + + /// 获取设备分组列表(分页,按门店筛选)。 + /// + /// GET /device/group/list?page=&limit=&orgId=&storeId= + /// + /// 注意:Web 端该接口使用 `orgId` 作为门店过滤参数,移动端同时携带 + /// `orgId` 与 `storeId`,避免后端因参数名差异而忽略门店过滤,导致 + /// 返回全部门店分组。 + Future> getDeviceGroupList({ + int page = 1, + int limit = 20, + required String storeId, + }) async { + final response = await _dio.get( + '/device/group/list', + queryParameters: { + 'page': page, + 'limit': limit, + 'orgId': storeId, + 'storeId': storeId, + }, + ); + final list = _extractList(response.data); + return list + .whereType>() + .map(OrgNode.fromJson) + .toList(); + } + + /// 获取设备分组详情。 + /// + /// GET /device/group/info/{id} + Future getDeviceGroupInfo(String id) async { + final response = await _dio.get('/device/group/info/$id'); + final data = response.data; + if (data is! Map) { + throw DeviceNotFoundException('设备分组信息加载失败:$id'); + } + return OrgNode.fromJson(data); + } + + /// 新增设备分组。 + /// + /// POST /device/group/save,body 为分组数据。 + Future saveDeviceGroup(Map data) async { + await _dio.post('/device/group/save', data: data); + } + + /// 修改设备分组。 + /// + /// POST /device/group/update,body 为分组数据(含 id)。 + Future updateDeviceGroup(Map data) async { + await _dio.post('/device/group/update', data: data); + } + + /// 批量删除设备分组。 + /// + /// POST /device/group/delete,body 为 `{ids: [...]}`。 + Future deleteDeviceGroups(List ids) async { + await _dio.post( + '/device/group/delete', + data: {'ids': ids}, + ); + } + + // ================ 激活码管理 ================ + + /// 生成门店激活码。 + /// + /// POST /device/activationCode/generate,body 为 `{orgId, maxActivateCount}`。 + Future generateActivationCode( + String orgId, + int maxActivateCount, + ) async { + await _dio.post( + '/device/activationCode/generate', + data: { + 'orgId': orgId, + 'maxActivateCount': maxActivateCount, + }, + ); + } + + /// 获取激活码信息。 + /// + /// GET /device/activationCode/info/{orgId} + Future> getActivationCodeInfo(String orgId) async { + final response = + await _dio.get('/device/activationCode/info/$orgId'); + final data = response.data; + if (data is! Map) { + throw DeviceNotFoundException('激活码信息加载失败:$orgId'); + } + return data; + } + + /// 重置激活码。 + /// + /// POST /device/activationCode/reset/{orgId} + Future resetActivationCode(String orgId) async { + await _dio.post('/device/activationCode/reset/$orgId'); + } + + // ================ 门店配置 ================ + + /// 获取门店配置。 + /// + /// GET /device/storeConfig/info/{orgId}(与 Web 后台门店配置弹窗同源, + /// 见 screen-front src/api/device.ts `getStoreConfig`)。 + Future> getStoreConfig(String orgId) async { + final response = + await _dio.get('/device/storeConfig/info/$orgId'); + final data = response.data; + if (data is! Map) { + throw DeviceNotFoundException('门店配置加载失败:$orgId'); + } + return data; + } + + /// 新增或修改门店配置。 + /// + /// POST /device/storeConfig/createOrUpdate(与 Web 后台 `createOrUpdateStoreConfig` + /// 严格一致;早期误写 /saveOrUpdate 会导致后端路由不匹配、保存失效)。 + Future createOrUpdateStoreConfig(Map data) async { + await _dio.post( + '/device/storeConfig/createOrUpdate', + data: data, + ); + } + + /// 重置门店配置密码。 + /// + /// POST /device/storeConfig/resetPassword/{orgId} + Future resetStoreConfigPassword(String orgId) async { + await _dio.post( + '/device/storeConfig/resetPassword/$orgId', + ); + } + + // ================ 扫码绑定(前端占位) ================ + + /// 扫码绑定设备到门店/分组。 + /// + /// 当前后端无扫码绑定接口,前端占位:本地记录绑定关系并写回设备归属, + /// 不发起真实请求。 + /// + /// [scanPayload] 为扫码解析出的设备标识(mac/sn/二维码内容); + /// [storeId]/[storeName] 为目标门店;[groupId] 为分组(可空)。 + /// + /// TODO(后端): POST /device/bind {scanPayload, storeId, groupId}, + /// 由后台按 scanPayload 定位设备并写归属。 + Future bindDeviceByScan( + String scanPayload, + String storeId, + String storeName, + String? groupId, + ) async { + // 占位:把绑定关系写入本地缓存(key = deviceId,此处以 scanPayload 作为标识)。 + await StoreLocalCache.instance.saveDeviceBinding( + scanPayload, + storeId, + storeName, + groupId, + ); + // 占位:本地 updateDevice 写 groupId/storeName,便于详情页回显。 + try { + await updateDevice({ + 'id': storeId, + 'groupId': groupId ?? '', + 'storeName': storeName, + }); + } on Object { + // 真实后台可能没有该设备/接口,占位阶段忽略失败。 + } + // TODO(后端): POST /device/bind {scanPayload, storeId, groupId}。 + debugPrint('[DeviceService.bindDeviceByScan] 占位未上报: ' + 'payload=$scanPayload storeId=$storeId'); + } + + // ================ 工具方法 ================ + + /// 兼容设备列表响应的三种形态: + /// `{list: [...], totalCount}` / `{page: {list, totalCount}}` / 直接数组。 + static List _extractList(dynamic data) { + if (data is List) { + return data; + } + if (data is Map) { + final list = data['list']; + if (list is List) { + return list; + } + final page = data['page']; + if (page is Map && page['list'] is List) { + return page['list'] as List; + } + } + return const []; + } +} + +/// 设备不存在异常。 +class DeviceNotFoundException implements Exception { + const DeviceNotFoundException(this.message); + + final String message; + + @override + String toString() => message; +} diff --git a/lib/services/intro_service.dart b/lib/services/intro_service.dart new file mode 100644 index 0000000..64bbd2e --- /dev/null +++ b/lib/services/intro_service.dart @@ -0,0 +1,259 @@ +import 'package:dio/dio.dart'; + +import '../models/intro_category.dart'; +import 'api_client.dart'; +import '../utils/session_org_scope.dart'; + +/// 介绍页管理服务:分类、子分类、详情。 +/// +/// 对接真实「爱视后台」/api/company/* 接口。 +/// 响应已由 [ApiInterceptor] 解包 `{code,msg,data}` 信封,直接消费 data。 +class IntroService { + IntroService({Dio? dio}) : _dio = dio ?? ApiClient.instance.dio; + + final Dio _dio; + + // ================ 分类管理 ================ + + /// 获取介绍页分类列表(分页)。 + /// + /// GET /company/introCategory/list?page=&limit=&orgId= + Future> getIntroCategoryList({ + int page = 1, + int limit = 20, + dynamic orgId, + }) async { + // 受限账号(SessionOrgScope 启用)未显式传入 orgId 时,自动带上当前组织, + // 避免列表接口忽略组织过滤而返回全量跨组织分类。 + final effectiveOrgId = orgId ?? + (SessionOrgScope.instance.enabled + ? SessionOrgScope.instance.currentOrgId + : null); + final params = {'page': page, 'limit': limit}; + if (effectiveOrgId != null && effectiveOrgId.toString().isNotEmpty) { + params['orgId'] = effectiveOrgId; + } + final response = await _dio.get( + '/company/introCategory/list', + queryParameters: params, + ); + final list = _extractList(response.data); + return list + .whereType>() + .map(IntroCategory.fromJson) + .toList(); + } + + /// 获取全部分类(不分页,用于下拉选择)。 + /// + /// GET /company/introCategory/all + Future> getIntroCategoryAll() async { + final response = await _dio.get('/company/introCategory/all'); + final list = _extractList(response.data); + return list + .whereType>() + .map(IntroCategory.fromJson) + .toList(); + } + + /// 获取分类详情。 + /// + /// GET /company/introCategory/info/{id} + Future getIntroCategoryInfo(String id) async { + final response = + await _dio.get('/company/introCategory/info/$id'); + final data = response.data; + if (data is! Map) { + throw ApiException(500, '分类信息加载失败:$id'); + } + return IntroCategory.fromJson(data); + } + + /// 新增或修改分类。 + /// + /// 有 id 则 POST /company/introCategory/update,否则 POST /company/introCategory/save。 + Future saveIntroCategory(Map data) async { + final hasId = data['id'] != null && + data['id'].toString().isNotEmpty && + data['id'].toString() != '0'; + final url = hasId + ? '/company/introCategory/update' + : '/company/introCategory/save'; + await _dio.post(url, data: data); + } + + /// 批量删除分类。 + /// + /// POST /company/introCategory/delete,body 为 `{ids: [...]}`。 + Future deleteIntroCategory(List ids) async { + await _dio.post( + '/company/introCategory/delete', + data: {'ids': ids}, + ); + } + + // ================ 子分类管理 ================ + + /// 获取子分类列表(分页)。 + /// + /// GET /company/introSubcategory/list?page=&limit= + Future> getIntroSubcategoryList({ + int page = 1, + int limit = 20, + }) async { + final response = await _dio.get( + '/company/introSubcategory/list', + queryParameters: {'page': page, 'limit': limit}, + ); + final list = _extractList(response.data); + return list + .whereType>() + .map(IntroSubcategory.fromJson) + .toList(); + } + + /// 按分类 ID 获取子分类列表。 + /// + /// GET /company/introSubcategory/listByCategory/{categoryId} + Future> getIntroSubcategoryListByCategory( + String categoryId, + ) async { + final response = await _dio.get( + '/company/introSubcategory/listByCategory/$categoryId', + ); + final list = _extractList(response.data); + return list + .whereType>() + .map(IntroSubcategory.fromJson) + .toList(); + } + + /// 获取子分类详情。 + /// + /// GET /company/introSubcategory/info/{id} + Future getIntroSubcategoryInfo(String id) async { + final response = + await _dio.get('/company/introSubcategory/info/$id'); + final data = response.data; + if (data is! Map) { + throw ApiException(500, '子分类信息加载失败:$id'); + } + return IntroSubcategory.fromJson(data); + } + + /// 新增或修改子分类。 + /// + /// 有 id 则 POST /company/introSubcategory/update,否则 POST /company/introSubcategory/save。 + Future saveIntroSubcategory(Map data) async { + final hasId = data['id'] != null && + data['id'].toString().isNotEmpty && + data['id'].toString() != '0'; + final url = hasId + ? '/company/introSubcategory/update' + : '/company/introSubcategory/save'; + await _dio.post(url, data: data); + } + + /// 批量删除子分类。 + /// + /// POST /company/introSubcategory/delete,body 为 `{ids: [...]}`。 + Future deleteIntroSubcategory(List ids) async { + await _dio.post( + '/company/introSubcategory/delete', + data: {'ids': ids}, + ); + } + + // ================ 详情管理 ================ + + /// 获取介绍页详情列表(分页)。 + /// + /// GET /company/introDetail/list?page=&limit= + Future> getIntroDetailList({ + int page = 1, + int limit = 20, + }) async { + final response = await _dio.get( + '/company/introDetail/list', + queryParameters: {'page': page, 'limit': limit}, + ); + final list = _extractList(response.data); + return list + .whereType>() + .map(IntroDetail.fromJson) + .toList(); + } + + /// 按子分类 ID 获取详情列表。 + /// + /// GET /company/introDetail/listBySubcategory/{subcategoryId} + Future> getIntroDetailListBySubcategory( + String subcategoryId, + ) async { + final response = await _dio.get( + '/company/introDetail/listBySubcategory/$subcategoryId', + ); + final list = _extractList(response.data); + return list + .whereType>() + .map(IntroDetail.fromJson) + .toList(); + } + + /// 获取详情信息。 + /// + /// GET /company/introDetail/info/{id} + Future getIntroDetailInfo(String id) async { + final response = + await _dio.get('/company/introDetail/info/$id'); + final data = response.data; + if (data is! Map) { + throw ApiException(500, '详情信息加载失败:$id'); + } + return IntroDetail.fromJson(data); + } + + /// 新增或修改详情。 + /// + /// 有 id 则 POST /company/introDetail/update,否则 POST /company/introDetail/save。 + Future saveIntroDetail(Map data) async { + final hasId = data['id'] != null && + data['id'].toString().isNotEmpty && + data['id'].toString() != '0'; + final url = hasId + ? '/company/introDetail/update' + : '/company/introDetail/save'; + await _dio.post(url, data: data); + } + + /// 批量删除详情。 + /// + /// POST /company/introDetail/delete,body 为 `{ids: [...]}`。 + Future deleteIntroDetail(List ids) async { + await _dio.post( + '/company/introDetail/delete', + data: {'ids': ids}, + ); + } + + // ================ 工具方法 ================ + + /// 兼容列表响应的三种形态: + /// `{list: [...], totalCount}` / `{page: {list, totalCount}}` / 直接数组。 + static List _extractList(dynamic data) { + if (data is List) { + return data; + } + if (data is Map) { + final list = data['list']; + if (list is List) { + return list; + } + final page = data['page']; + if (page is Map && page['list'] is List) { + return page['list'] as List; + } + } + return const []; + } +} diff --git a/lib/services/marquee_service.dart b/lib/services/marquee_service.dart new file mode 100644 index 0000000..0280638 --- /dev/null +++ b/lib/services/marquee_service.dart @@ -0,0 +1,278 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:dio/dio.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/marquee_message.dart'; +import '../models/store_info.dart'; +import 'api_client.dart'; +import 'marquee_web_source.dart'; +import 'org_service.dart'; + +/// 滚动字幕管理服务。 +/// +/// 列表/新增/编辑/删除均优先对接 Web 后台 `/theme/scrollingText/*` 接口, +/// 同时保留本地 `SharedPreferences` 缓存作为离线兜底。 +class MarqueeService { + MarqueeService({ + Dio? dio, + OrgService? orgService, + MarqueeWebSource? webSource, + }) : _dio = dio ?? ApiClient.instance.dio, + _orgService = orgService ?? OrgService(), + _webSource = webSource ?? MarqueeWebSource(dio: ApiClient.instance.dio); + + final Dio _dio; + final OrgService _orgService; + final MarqueeWebSource _webSource; + + static const _storageKey = 'cloudhome_ops_marquee_messages'; + + Future get _prefs => SharedPreferences.getInstance(); + + /// 获取滚动字幕列表。 + /// + /// 优先从 Web 端 `/theme/scrollingText/list` 获取;若失败则回退本地缓存。 + Future> getMarqueeList({ + String? storeId, + String? keyword, + String method = 'GET', + String path = MarqueeWebSource.defaultPath, + Map? queryParameters, + Map? body, + }) async { + final webResult = await _webSource.fetchMarquees( + method: method, + path: path, + queryParameters: queryParameters, + body: body, + storeId: storeId, + keyword: keyword, + ); + + if (webResult.isSuccess) { + await _saveAll(webResult.data); + return _applyFiltersAndSort(webResult.data, storeId: storeId, keyword: keyword); + } + + final localList = await _loadAll(); + return _applyFiltersAndSort(localList, storeId: storeId, keyword: keyword); + } + + List _applyFiltersAndSort( + List list, { + String? storeId, + String? keyword, + }) { + var result = list; + if (storeId != null && storeId.isNotEmpty) { + result = result.where((m) => m.storeId == storeId).toList(); + } + if (keyword != null && keyword.isNotEmpty) { + final lower = keyword.toLowerCase(); + result = result.where((m) { + return m.content.toLowerCase().contains(lower) || + m.storeName.toLowerCase().contains(lower); + }).toList(); + } + result.sort((a, b) { + // 启用的排在前面,其次按更新时间倒序。 + final statusCompare = b.status.compareTo(a.status); + if (statusCompare != 0) return statusCompare; + final bTime = b.updateTime; + final aTime = a.updateTime; + if (bTime == null || aTime == null) return 0; + return bTime.compareTo(aTime); + }); + return result; + } + + /// 新增或修改滚动字幕。 + /// + /// 编辑(data 含 id)调用 `/theme/scrollingText/update`, + /// 新增调用 `/theme/scrollingText/save`。 + /// 成功后刷新本地缓存。 + Future saveMarquee(Map data) async { + final id = data['id']?.toString(); + final payload = _buildWebPayload(data); + + try { + if (id != null && id.isNotEmpty) { + await _dio.post('/theme/scrollingText/update', data: payload); + } else { + await _dio.post('/theme/scrollingText/save', data: payload); + } + } on DioException { + // 写操作失败时仅更新本地缓存,保证移动端仍可展示。 + } + + final list = await _loadAll(); + final now = DateTime.now(); + final message = MarqueeMessage( + id: id ?? _generateId(), + storeId: data['storeId']?.toString() ?? '', + storeName: data['storeName']?.toString() ?? '', + content: data['content']?.toString() ?? '', + status: data['status'] is int ? data['status'] as int : 0, + speed: int.tryParse(data['speed']?.toString() ?? '50') ?? 50, + fontColor: data['fontColor']?.toString(), + backgroundColor: data['backgroundColor']?.toString(), + fontSize: int.tryParse(data['fontSize']?.toString() ?? ''), + level: int.tryParse(data['level']?.toString() ?? ''), + startTime: _tryParseDateTime(data['startTime']), + endTime: _tryParseDateTime(data['endTime']), + createTime: now, + updateTime: now, + ); + + final index = list.indexWhere((m) => m.id == message.id); + if (index >= 0) { + list[index] = message; + } else { + list.add(message); + } + await _saveAll(list); + } + + /// 删除滚动字幕。 + /// + /// 优先调用 Web 端 `/theme/scrollingText/delete`,失败也移除本地缓存。 + Future deleteMarquee(String id) async { + final numericId = int.tryParse(id); + if (numericId != null) { + try { + await _dio.post( + '/theme/scrollingText/delete', + data: {'ids': [numericId]}, + ); + } on DioException { + // 网络失败时继续移除本地记录。 + } + } + + final list = await _loadAll(); + list.removeWhere((m) => m.id == id); + await _saveAll(list); + } + + /// 启用/禁用单条滚动字幕。 + /// + /// 直接调用 Web 端 `/theme/scrollingText/update` 修改 status。 + Future setMarqueeStatus(String id, {required int status}) async { + final list = await _loadAll(); + final index = list.indexWhere((m) => m.id == id); + if (index < 0) return; + + final item = list[index]; + final numericId = int.tryParse(id); + if (numericId != null) { + try { + await _dio.post( + '/theme/scrollingText/update', + data: item.copyWith(status: status).toWebJson(), + ); + } on DioException { + // 接口失败时仅更新本地状态。 + } + } + + list[index] = item.copyWith( + status: status, + updateTime: DateTime.now(), + ); + await _saveAll(list); + } + + /// 获取当前用户可见的门店列表(用于新增/编辑时选择目标门店)。 + Future> getStores() async { + return _orgService.getMyStores(); + } + + /// 构造 Web 后台可接收的请求体。 + Map _buildWebPayload(Map data) { + final storeId = data['storeId']?.toString() ?? ''; + final storeName = data['storeName']?.toString() ?? ''; + final content = data['content']?.toString() ?? ''; + final speed = int.tryParse(data['speed']?.toString() ?? '50') ?? 50; + final fontSize = int.tryParse(data['fontSize']?.toString() ?? ''); + final level = int.tryParse(data['level']?.toString() ?? ''); + final status = data['status'] is int ? data['status'] as int : 0; + final fontColor = data['fontColor']?.toString(); + final backgroundColor = data['backgroundColor']?.toString(); + final startTime = _tryParseDateTime(data['startTime']); + final endTime = _tryParseDateTime(data['endTime']); + + final payload = { + 'orgId': int.tryParse(storeId) ?? storeId, + 'orgName': storeName, + 'content': content, + 'scrollSpeed': speed, + 'status': status, + if (fontSize != null) 'fontSize': fontSize, + if (level != null) 'level': level, + if (fontColor != null && fontColor.isNotEmpty) 'fontColor': fontColor, + if (backgroundColor != null && backgroundColor.isNotEmpty) + 'bgColor': backgroundColor, + if (startTime != null) + 'startTime': _formatTime(startTime), + if (endTime != null) + 'endTime': _formatTime(endTime), + }; + + final id = data['id']?.toString(); + if (id != null && id.isNotEmpty) { + payload['id'] = int.tryParse(id) ?? id; + } + + return payload; + } + + Future> _loadAll() async { + final prefs = await _prefs; + final raw = prefs.getString(_storageKey); + if (raw == null || raw.isEmpty) return []; + try { + final list = jsonDecode(raw) as List; + return list + .whereType>() + .map(MarqueeMessage.fromJson) + .toList(); + } catch (_) { + return []; + } + } + + Future _saveAll(List list) async { + final prefs = await _prefs; + final raw = jsonEncode(list.map((m) => m.toJson()).toList()); + await prefs.setString(_storageKey, raw); + } + + String _generateId() { + final now = DateTime.now().millisecondsSinceEpoch; + final rand = Random().nextInt(10000).toString().padLeft(4, '0'); + return '${now}_$rand'; + } + + DateTime? _tryParseDateTime(dynamic value) { + if (value == null) return null; + if (value is DateTime) return value; + final text = value.toString().trim(); + if (text.isEmpty) return null; + + // 兼容 Web 端纯时间字符串 `HH:mm:ss`。 + 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')); + } + + String _formatTime(DateTime value) { + return '${value.hour.toString().padLeft(2, '0')}:' + '${value.minute.toString().padLeft(2, '0')}:' + '${value.second.toString().padLeft(2, '0')}'; + } +} diff --git a/lib/services/marquee_web_source.dart b/lib/services/marquee_web_source.dart new file mode 100644 index 0000000..2ef2617 --- /dev/null +++ b/lib/services/marquee_web_source.dart @@ -0,0 +1,343 @@ +import 'package:dio/dio.dart'; + +import '../models/marquee_message.dart'; +import '../utils/constants.dart'; +import '../config/app_config.dart'; +import 'api_client.dart'; + +/// Web 端滚动字幕数据源。 +/// +/// 专门负责从 Web 后台 `/theme/scrollingText/*` 接口请求、接收并解析滚动字幕数据。 +/// 支持通过参数配置请求地址(URL / path)与请求方式(GET / POST), +/// 内置网络异常、超时、HTTP 错误、解析失败等错误处理, +/// 将原始响应封装为结构化的 [MarqueeFetchResult]。 +class MarqueeWebSource { + /// [baseUrl] 可为空;为空时使用 [AppConfig] 当前环境的默认地址。 + /// [dio] 可注入,便于测试或复用已挂载拦截器的实例。 + MarqueeWebSource({ + Dio? dio, + String? baseUrl, + this.timeout = AppConstants.apiTimeout, + }) : _dio = dio ?? _createDio(baseUrl: baseUrl, timeout: timeout); + + final Dio _dio; + + /// 请求超时时间。 + final Duration timeout; + + /// 默认列表接口路径。与 Web 后台滚动字幕管理页保持一致。 + static const String defaultPath = '/theme/scrollingText/list'; + + /// 默认请求方式。 + static const String defaultMethod = 'GET'; + + static Dio _createDio({String? baseUrl, required Duration timeout}) { + return Dio( + BaseOptions( + baseUrl: baseUrl ?? AppConfig.apiFullBaseUrl, + connectTimeout: timeout, + receiveTimeout: timeout, + sendTimeout: timeout, + headers: {'Content-Type': 'application/json; charset=utf-8'}, + responseType: ResponseType.json, + ), + ); + } + + /// 从 Web 端获取滚动字幕列表。 + /// + /// [method] 支持 'GET' / 'POST' / 'PUT' 等;默认 'GET'。 + /// [path] 支持绝对 URL(以 http:// 或 https:// 开头)或相对路径。 + /// [queryParameters] 用于 GET / POST 的查询参数(如 page、limit、orgId)。 + /// [body] 用于 POST / PUT 的请求体。 + /// [storeId] 与 [keyword] 为业务常用过滤条件,会自动合并到 queryParameters。 + /// [page] / [limit] 默认 1 / 100。 + /// + /// 返回 [MarqueeFetchResult],内部包含解析后的 [MarqueeMessage] 列表、总数、错误码等。 + Future fetchMarquees({ + String method = defaultMethod, + String path = defaultPath, + Map? queryParameters, + Map? body, + String? storeId, + String? keyword, + int page = 1, + int limit = 100, + }) async { + final params = { + 'page': page, + 'limit': limit, + if (storeId != null && storeId.isNotEmpty) 'orgId': storeId, + if (keyword != null && keyword.isNotEmpty) 'content': keyword, + ...?queryParameters, + }; + + final options = Options(method: method.toUpperCase()); + + try { + final upperMethod = method.toUpperCase(); + final Response response; + + if (upperMethod == 'GET') { + response = await _dio.get( + path, + queryParameters: params, + options: options, + ); + } else { + response = await _dio.request( + path, + queryParameters: params, + data: body, + options: options, + ); + } + + return _parseResponse(response.data); + } on DioException catch (e) { + return MarqueeFetchResult.failure( + error: _formatDioError(e), + errorCode: _errorCodeFromDio(e), + ); + } on FormatException catch (e) { + return MarqueeFetchResult.failure( + error: '数据解析失败:${e.message}', + errorCode: MarqueeErrorCode.parseFailed, + ); + } on Exception catch (e) { + return MarqueeFetchResult.failure( + error: '请求异常:$e', + errorCode: MarqueeErrorCode.unknown, + ); + } + } + + /// 通用单条数据请求封装(用于新增/编辑/删除后回显或详情查询)。 + Future fetchMarqueeDetail({ + String method = defaultMethod, + required String path, + Map? queryParameters, + Map? body, + }) async { + return fetchMarquees( + method: method, + path: path, + queryParameters: queryParameters, + body: body, + page: 1, + limit: 1, + ); + } + + /// 解析 Web 端响应为结构化结果。 + /// + /// 兼容三种常见响应形态: + /// 1. 后端信封 `{code:0, msg:'ok', data:{list:[...], totalCount:5}}` + /// 2. 后端信封 `{code:0, msg:'ok', data:[...]}` + /// 3. 原生数组 `[...]` + /// 4. 解包后的分页对象 `{list:[...], totalCount:5}` + MarqueeFetchResult _parseResponse(dynamic raw) { + if (raw == null) { + return MarqueeFetchResult.success( + data: const [], + total: 0, + ); + } + + Map? envelope; + dynamic payload; + + if (raw is Map) { + envelope = raw; + payload = raw['data'] ?? raw; + } else { + payload = raw; + } + + // 处理业务错误码(信封中存在 code 且非成功)。 + if (envelope != null && envelope.containsKey('code')) { + final code = envelope['code']; + final success = code == 0 || code == 200 || code == '0' || code == '200'; + if (!success) { + final msg = envelope['msg']?.toString() ?? + envelope['message']?.toString() ?? + '请求失败(业务码:$code)'; + return MarqueeFetchResult.failure( + error: msg, + errorCode: MarqueeErrorCode.businessError, + businessCode: code?.toString(), + ); + } + } + + // 解析列表与总数。 + List listRaw; + int? total; + if (payload is Map) { + if (payload['list'] is List) { + listRaw = payload['list'] as List; + final t = payload['totalCount'] ?? payload['total']; + if (t is int) total = t; + if (t is String) total = int.tryParse(t); + } else if (payload['rows'] is List) { + listRaw = payload['rows'] as List; + final t = payload['totalCount'] ?? payload['total']; + if (t is int) total = t; + if (t is String) total = int.tryParse(t); + } else { + // 兜底:整个 data 就是单条对象。 + listRaw = [payload]; + } + } else if (payload is List) { + listRaw = payload; + } else { + return MarqueeFetchResult.failure( + error: '返回数据格式不正确', + errorCode: MarqueeErrorCode.parseFailed, + ); + } + + final List data = []; + for (final item in listRaw) { + if (item is! Map) { + continue; + } + try { + data.add(MarqueeMessage.fromJson(item)); + } on FormatException catch (e) { + return MarqueeFetchResult.failure( + error: '单条字幕解析失败:${e.message}', + errorCode: MarqueeErrorCode.parseFailed, + ); + } on Exception catch (e) { + return MarqueeFetchResult.failure( + error: '单条字幕解析异常:$e', + errorCode: MarqueeErrorCode.parseFailed, + ); + } + } + + return MarqueeFetchResult.success( + data: data, + total: total ?? data.length, + ); + } + + String _formatDioError(DioException e) { + final inner = e.error; + if (inner is ApiException && inner.message.isNotEmpty) { + return inner.message; + } + final response = e.response; + if (response != null && response.statusCode != null) { + return '服务器响应异常:HTTP ${response.statusCode}'; + } + switch (e.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.receiveTimeout: + case DioExceptionType.sendTimeout: + return '连接超时,请检查网络'; + case DioExceptionType.connectionError: + return '网络连接失败,请检查网络'; + case DioExceptionType.cancel: + return '请求已取消'; + case DioExceptionType.badCertificate: + return '服务器证书校验失败'; + case DioExceptionType.badResponse: + return '服务器返回异常响应'; + case DioExceptionType.unknown: + default: + final text = e.message; + if (text != null && text.isNotEmpty) return text; + return '网络请求失败'; + } + } + + MarqueeErrorCode _errorCodeFromDio(DioException e) { + switch (e.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.receiveTimeout: + case DioExceptionType.sendTimeout: + return MarqueeErrorCode.timeout; + case DioExceptionType.connectionError: + return MarqueeErrorCode.networkError; + case DioExceptionType.badCertificate: + return MarqueeErrorCode.certificateError; + case DioExceptionType.badResponse: + return MarqueeErrorCode.httpError; + case DioExceptionType.cancel: + return MarqueeErrorCode.cancelled; + default: + return MarqueeErrorCode.unknown; + } + } +} + +/// Web 端滚动字幕请求结果。 +class MarqueeFetchResult { + const MarqueeFetchResult({ + required this.data, + this.total = 0, + this.hasMore = false, + this.error, + this.errorCode, + this.businessCode, + }); + + factory MarqueeFetchResult.success({ + required List data, + int total = 0, + bool hasMore = false, + }) { + return MarqueeFetchResult( + data: data, + total: total, + hasMore: hasMore, + ); + } + + factory MarqueeFetchResult.failure({ + required String error, + MarqueeErrorCode errorCode = MarqueeErrorCode.unknown, + String? businessCode, + }) { + return MarqueeFetchResult( + data: const [], + error: error, + errorCode: errorCode, + businessCode: businessCode, + ); + } + + /// 解析后的字幕列表。 + final List data; + + /// 服务端总数(用于分页)。 + final int total; + + /// 是否还有下一页。 + final bool hasMore; + + /// 错误描述(成功时为 null)。 + final String? error; + + /// 错误码(分类)。 + final MarqueeErrorCode? errorCode; + + /// 后端业务错误码(非 HTTP 状态码)。 + final String? businessCode; + + bool get isSuccess => error == null && errorCode == null; +} + +/// Web 端滚动字幕错误分类。 +enum MarqueeErrorCode { + networkError, + timeout, + httpError, + businessError, + parseFailed, + certificateError, + cancelled, + unknown, +} diff --git a/lib/services/media_service.dart b/lib/services/media_service.dart new file mode 100644 index 0000000..18ae95f --- /dev/null +++ b/lib/services/media_service.dart @@ -0,0 +1,146 @@ +import 'package:dio/dio.dart'; + +import '../models/media_asset.dart'; +import 'api_client.dart'; + +/// 媒体资源管理服务:媒体资产与排行榜。 +/// +/// 对接真实「爱视后台」: +/// - 媒体资产:/api/sys/mediaAsset/* +/// - 媒体排行榜:/api/sys/mediaAssetRank/* +class MediaService { + MediaService({Dio? dio}) : _dio = dio ?? ApiClient.instance.dio; + + final Dio _dio; + + // ================ 媒体资产管理 ================ + + /// 获取媒体资产列表(分页)。 + /// + /// GET /sys/mediaAsset/list?page=&limit= + Future> getMediaAssetList({ + int page = 1, + int limit = 20, + }) async { + final response = await _dio.get( + '/sys/mediaAsset/list', + queryParameters: {'page': page, 'limit': limit}, + ); + final list = _extractList(response.data); + return list + .whereType>() + .map(MediaAsset.fromJson) + .toList(); + } + + /// 获取媒体资产详情。 + /// + /// GET /sys/mediaAsset/info/{id} + Future getMediaAssetInfo(String id) async { + final response = await _dio.get('/sys/mediaAsset/info/$id'); + final data = response.data; + if (data is! Map) { + throw ApiException(500, '媒体资产信息加载失败:$id'); + } + return MediaAsset.fromJson(data); + } + + /// 新增或修改媒体资产。 + /// + /// POST /sys/mediaAsset/save,body 为媒体数据。 + Future saveMediaAsset(Map data) async { + await _dio.post('/sys/mediaAsset/save', data: data); + } + + /// 批量删除媒体资产。 + /// + /// POST /sys/mediaAsset/delete,body 为 `{ids: [...]}`。 + Future deleteMediaAsset(List ids) async { + await _dio.post( + '/sys/mediaAsset/delete', + data: {'ids': ids}, + ); + } + + // ================ 媒体排行榜 ================ + + /// 获取媒体排行榜 Top 10。 + /// + /// GET /sys/mediaAssetRank/top10 + Future> getMediaAssetRankTop10() async { + final response = await _dio.get('/sys/mediaAssetRank/top10'); + final list = _extractList(response.data); + return list + .whereType>() + .map(MediaRankItem.fromJson) + .toList(); + } + + /// 设置排行榜(批量设置,覆盖原有列表)。 + /// + /// POST /sys/mediaAssetRank/set,body 为 `{codeList: [...]}`。 + Future setMediaAssetRank(List codeList) async { + await _dio.post( + '/sys/mediaAssetRank/set', + data: {'codeList': codeList}, + ); + } + + /// 从排行榜移除指定媒体。 + /// + /// POST /sys/mediaAssetRank/remove,body 为 `{code: ...}`。 + Future removeMediaAssetRank(String code) async { + await _dio.post( + '/sys/mediaAssetRank/remove', + data: {'code': code}, + ); + } + + /// 将指定媒体上移一位。 + /// + /// POST /sys/mediaAssetRank/moveUp,body 为 `{code: ...}`。 + Future moveUpMediaAssetRank(String code) async { + await _dio.post( + '/sys/mediaAssetRank/moveUp', + data: {'code': code}, + ); + } + + /// 将指定媒体下移一位。 + /// + /// POST /sys/mediaAssetRank/moveDown,body 为 `{code: ...}`。 + Future moveDownMediaAssetRank(String code) async { + await _dio.post( + '/sys/mediaAssetRank/moveDown', + data: {'code': code}, + ); + } + + /// 清空排行榜。 + /// + /// POST /sys/mediaAssetRank/clear + Future clearMediaAssetRank() async { + await _dio.post('/sys/mediaAssetRank/clear'); + } + + // ================ 工具方法 ================ + + /// 兼容列表响应的三种形态: + /// `{list: [...], totalCount}` / `{page: {list, totalCount}}` / 直接数组。 + static List _extractList(dynamic data) { + if (data is List) { + return data; + } + if (data is Map) { + final list = data['list']; + if (list is List) { + return list; + } + final page = data['page']; + if (page is Map && page['list'] is List) { + return page['list'] as List; + } + } + return const []; + } +} diff --git a/lib/services/org_service.dart b/lib/services/org_service.dart new file mode 100644 index 0000000..fb8e7e8 --- /dev/null +++ b/lib/services/org_service.dart @@ -0,0 +1,747 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:image_picker/image_picker.dart'; + +import '../models/org_node.dart'; +import '../models/store_config.dart'; +import '../models/store_info.dart'; +import '../utils/session_org_scope.dart'; +import '../utils/store_local_cache.dart'; +import 'api_client.dart'; +import 'upload_service.dart'; + +/// 组织与门店管理服务:组织树、门店、设备分组查询。 +/// +/// 对接真实「爱视后台」/api/sys/org/* 与 /api/sys/store/* 接口。 +/// 响应已由 [ApiInterceptor] 解包 `{code,msg,data}` 信封,直接消费 data。 +class OrgService { + OrgService({Dio? dio}) : _dio = dio ?? ApiClient.instance.dio; + + final Dio _dio; + + // ================ 组织管理 ================ + + /// 获取组织树(全量树形结构,当前用户可见范围)。 + /// + /// GET /sys/org/tree + /// + /// 注:后端该接口在部分部署下会返回**全量**组织树,不做数据权限收敛, + /// 因此这里在客户端按 [SessionOrgScope] 把树裁剪到「当前登录用户所属组织 + /// 子树」(含自身与全部后代)。[SessionOrgScope] 未启用(管理员 / orgId 为空) + /// 时原样返回,行为与后端保持一致。 + Future> getOrgTree() async { + final response = await _dio.get('/sys/org/tree'); + final list = _extractList(response.data); + final tree = list + .whereType>() + .map(OrgNode.fromJson) + .toList(); + return _scopeTree(tree); + } + + /// 获取组织列表(分页)。 + /// + /// GET /sys/org/list?page=&limit= + Future> getOrgList({ + int page = 1, + int limit = 20, + }) async { + final response = await _dio.get( + '/sys/org/list', + queryParameters: {'page': page, 'limit': limit}, + ); + final list = _extractList(response.data); + return list + .whereType>() + .map(OrgNode.fromJson) + .toList(); + } + + /// 获取组织扁平列表(不分页,用于下拉选择)。 + /// + /// GET /sys/org/flatList + /// + /// 命中 [SessionOrgScope] 时,仅保留当前用户组织子树内的节点, + /// 避免下拉选项出现其他组织的越权数据。 + Future> getOrgFlatList() async { + final response = await _dio.get('/sys/org/flatList'); + final list = _extractList(response.data); + final all = list + .whereType>() + .map(OrgNode.fromJson) + .toList(); + final scope = SessionOrgScope.instance; + if (!scope.enabled) return all; + final ids = await scopedOrgIdSet(); + if (ids.isEmpty) return all; + return all.where((n) => ids.contains(n.id)).toList(); + } + + /// 获取组织下拉选项(同扁平列表,语义区分)。 + /// + /// GET /sys/org/select + /// + /// 同样受 [SessionOrgScope] 收敛,仅返回当前用户组织子树内的节点。 + Future> getOrgSelect() async { + final response = await _dio.get('/sys/org/select'); + final list = _extractList(response.data); + final all = list + .whereType>() + .map(OrgNode.fromJson) + .toList(); + final scope = SessionOrgScope.instance; + if (!scope.enabled) return all; + final ids = await scopedOrgIdSet(); + if (ids.isEmpty) return all; + return all.where((n) => ids.contains(n.id)).toList(); + } + + /// 获取组织详情。 + /// + /// GET /sys/org/info/{id} + Future getOrgInfo(String id) async { + final response = await _dio.get('/sys/org/info/$id'); + final data = response.data; + if (data is! Map) { + throw ApiException(500, '组织信息加载失败:$id'); + } + return OrgNode.fromJson(data); + } + + /// 获取渠道列表(type=1 的组织节点)。 + /// + /// GET /sys/org/channels + Future> getOrgChannels() async { + final response = await _dio.get('/sys/org/channels'); + final list = _extractList(response.data); + return list + .whereType>() + .map(OrgNode.fromJson) + .toList(); + } + + /// 获取渠道下的代理商树(渠道 → 代理商 → 门店)。 + /// + /// GET /sys/org/channelAgentTree?channelId={channelId} + /// + /// 注:与 Web 后台 [org.ts] 的 [getChannelAgentTree] 严格一致——channelId 走 + /// **查询参数**而非路径段(早期误写成 /channelAgentTree/{id} 会导致后端取不到 + /// 渠道、代理树加载为空)。 + Future> getChannelAgentTree(String channelId) async { + final response = await _dio.get( + '/sys/org/channelAgentTree', + queryParameters: {'channelId': channelId}, + ); + final list = _extractList(response.data); + return list + .whereType>() + .map(OrgNode.fromJson) + .toList(); + } + + /// 获取「渠道商管理」统一树(渠道 → 代理/子代理)。 + /// + /// 后端无单一渠道商全量树接口,此处组合两个既有接口: + /// 1. GET /sys/org/channels 拉顶层渠道列表; + /// 2. 对每个渠道 GET /sys/org/channelAgentTree?channelId=xxx 拉其下代理/子代理树。 + /// + /// **降级策略**:若 /sys/org/channels 失败(部分部署下该端点可能返回服务端异常 + /// 或权限错误),自动回退到 GET /sys/org/tree(全量组织树,已在门店列表中验证可用), + /// 从中提取 type=1(渠道)与 type=2(代理)节点及其子树,保证页面不白屏。 + /// + /// 单渠道 agentTree 请求失败不影响整体(该渠道降级为无子节点),其余渠道正常展示。 + /// 结果按渠道 orderNo 升序、渠道内子节点递归按 orderNo 升序,便于稳定展示。 + Future> getChannelTree() async { + // 主路径:/sys/org/channels + /sys/org/channelAgentTree + try { + final channels = await getOrgChannels(); + final tree = []; + for (final channel in channels) { + List agents = const []; + try { + agents = await getChannelAgentTree(channel.id); + } catch (_) { + // 单渠道拉取失败:降级为无子节点,继续后续渠道。 + } + tree.add(channel.copyWith(children: _sortTree(agents))); + } + tree.sort(_byOrderNo); + if (tree.isNotEmpty) return tree; + } catch (e) { + debugPrint('[OrgService.getChannelTree] /sys/org/channels 主路径失败: $e'); + } + + // 降级路径:从全量组织树递归提取渠道/代理节点。 + try { + final orgTree = await getOrgTree(); + final fallback = _collectChannelNodes(orgTree); + if (fallback.isNotEmpty) { + debugPrint( + '[OrgService.getChannelTree] 已从 /sys/org/tree 降级加载 ' + '${fallback.length} 个渠道/代理节点', + ); + return _sortTree(fallback); + } + } catch (e) { + debugPrint('[OrgService.getChannelTree] 降级路径也失败: $e'); + } + + // 双路径均失败:抛出原始异常让 UI 展示错误。 + throw ApiException(500, '无法加载渠道数据,请检查网络或联系管理员'); + } + + /// 从组织树中**递归**收集渠道(type=1)/代理(type=2) 节点,保留它们之间的层级关系。 + /// + /// 平台(type=0)/门店(type=3)/分组(type=4) 等非渠道节点作为「过路节点」被跳过 + /// (不单独展示),但其下方若含有渠道/代理则继续下钻——确保即使组织树的 + /// 顶层是平台节点(type=0),其下的渠道与代理仍能被正确提取。 + /// + /// 例:平台 → 渠道A → 代理B → 门店C → 代理D + /// 提取结果:[渠道A(子=[代理B(子=[代理D])])] + static List _collectChannelNodes(List nodes) { + final result = []; + for (final node in nodes) { + if (node.type == 1 || node.type == 2) { + // 渠道/代理:保留自身,递归过滤子孙仅保留渠道/代理支系。 + result.add(node.copyWith(children: _collectChannelNodes(node.children))); + } else { + // 非渠道节点:跳过自身,但透传其下方可能含渠道/代理的分支。 + if (node.children.isNotEmpty) { + result.addAll(_collectChannelNodes(node.children)); + } + } + } + return result; + } + + /// 递归按 orderNo 升序排序树(节点及各级子节点)。 + static List _sortTree(List nodes) { + final sorted = [...nodes]..sort(_byOrderNo); + return sorted + .map((n) => n.copyWith(children: _sortTree(n.children))) + .toList(); + } + + /// 按 orderNo 升序的比较器(orderNo 相同再按 id 兜底,保证稳定)。 + static int _byOrderNo(OrgNode a, OrgNode b) { + if (a.orderNo != b.orderNo) return a.orderNo.compareTo(b.orderNo); + return a.id.compareTo(b.id); + } + + /// 新增门店 / 组织。 + /// + /// POST /sys/org/save,body **不带 id/orgId**(带 id 会走 insert 分支, + /// 报「数据库中已存在该记录」;只带 orgId 会被忽略而误建新记录)。 + /// 门店为 type='STORE',且**必填 province**,否则报「门店省份不能为空」。 + /// 返回后端 data 中的 orgId(新增门店时可用于后续创建激活码等)。 + Future createOrg(Map data) async { + final payload = Map.from(data); + // 新增必须剥离主键,防止后端误判为更新或重复插入。 + payload.remove('id'); + payload.remove('orgId'); + final response = await _dio.post('/sys/org/save', data: payload); + final result = response.data; + if (result is Map) { + return result['orgId']?.toString() ?? result['id']?.toString(); + } + if (result is String) return result; + return null; + } + + /// 修改门店 / 组织。 + /// + /// POST /sys/org/update,后端**只认 id**(传 orgId 会报「组织不存在」), + /// 因此强制以 id 为主键并剥离 orgId,避免编辑被误判。 + /// 返回后端 data 中的 orgId。 + Future updateOrg(Map data) async { + final payload = Map.from(data); + // 后端 update 仅使用 id;orgId 会被忽略甚至触发「组织不存在」,必须剥离。 + final id = (data['id'] ?? data['orgId'])?.toString() ?? ''; + payload.remove('orgId'); + payload['id'] = id; + final response = await _dio.post('/sys/org/update', data: payload); + final result = response.data; + if (result is Map) { + return result['orgId']?.toString() ?? result['id']?.toString(); + } + if (result is String) return result; + return null; + } + + /// 新增或修改组织(兼容旧调用方,如组织树页面)。 + /// + /// 注意:后端**未注册** `/sys/org/saveOrUpdate` 路由,调用必返回 + /// 「未知异常,请联系管理员」。故按 body 是否携带 id/orgId 自动分流到 + /// [createOrg](新增)或 [updateOrg](编辑)。 + Future saveOrUpdateOrg(Map data) async { + final isEdit = (data['id'] != null && data['id'].toString().isNotEmpty) || + (data['orgId'] != null && data['orgId'].toString().isNotEmpty); + return isEdit ? updateOrg(data) : createOrg(data); + } + + /// 删除组织(含子节点校验)。 + /// + /// POST /sys/org/delete/{id} + Future deleteOrg(String id) async { + await _dio.post('/sys/org/delete/$id'); + } + + // ================ 门店管理 ================ + + /// 获取当前登录用户可见的门店列表。 + /// + /// 排序 / 展示需与 Web 后台「门店管理」保持一致:Web 后台门店管理读取的是 + /// 组织树(门店专用接口 /sys/store/* 在该账号下多返回 500,不可用),故 + /// 第 1 步直接读组织树并递归收集 type=3 门店,同时透传节点的 [OrgNode.createdAt] + /// 作为 [StoreInfo.createTime] 供「建店时间倒序」使用;门店专用接口仅作兜底。 + /// + /// 回退链(任一成功且非空即用): + /// 1. 组织树 GET /sys/org/tree,递归收集 type=3 门店(透传 createdAt); + /// 2. 门店专用 GET /sys/store/myStores; + /// 3. 登录组织门店 GET /sys/store/listByLoginOrg。 + Future> getMyStores() async { + // 第 1 步:组织树(与 Web 后台门店管理同源,且透传 createdAt)。 + try { + final tree = await getOrgTree(); + final stores = _collectStores(tree); + if (stores.isNotEmpty) return stores; + } catch (_) { + // 继续回退。 + } + // 第 2 步:myStores。 + try { + final response = await _dio.get('/sys/store/myStores'); + final list = _extractList(response.data); + final stores = list + .whereType>() + .map(StoreInfo.fromJson) + .toList(); + if (stores.isNotEmpty) return stores; + } catch (_) { + // 继续回退。 + } + // 第 3 步:按登录组织查门店。 + try { + final stores = await getStoreListByLoginOrg(); + if (stores.isNotEmpty) return stores; + } catch (_) { + // 全部失败,返回空。 + } + return const []; + } + + /// 统计门店数量(type=3)。 + /// + /// 复用 [getMyStores] 的回退链,确保门店总数与门店列表一致, + /// 避免组织树异常时直接返回 0 导致「门店总数=0」的问题。 + Future fetchStoreCount() async { + try { + return (await getMyStores()).length; + } catch (_) { + return 0; + } + } + + /// 递归把组织树扁平化,并过滤出门店(type=3 或 type='STORE')。 + /// + /// 新接口 /sys/org/tree 返回的 type 为字符串(CHANNEL/SERVICE_PROVIDER/STORE/GROUP), + /// 经 [OrgNode.fromJson] 已映射为 int,但此处兼容判断字符串更保险。 + /// + /// 组织树节点通常不携带 [parentId],而是通过 [children] 嵌套表达层级; + /// 因此递归时把当前节点 [id] 作为子节点的 [parentId] 传入,确保门店的 + /// [StoreInfo.parentId] 能正确指向其在树中的直接父组织(渠道/代理商)。 + static List _collectStores(List nodes, {String parentId = ''}) { + final stores = []; + for (final node in nodes) { + final isStore = node.type == 3 || node.type.toString().toUpperCase() == 'STORE'; + if (isStore) { + final province = node.province.trim(); + final city = node.city.trim(); + final address = node.address.trim(); + final fullAddress = []; + if (province.isNotEmpty) fullAddress.add(province); + if (city.isNotEmpty) fullAddress.add(city); + if (address.isNotEmpty) fullAddress.add(address); + // 优先使用节点自身携带的 parentId;树接口未返回时,使用递归上下文中的父节点 ID。 + final effectiveParentId = node.parentId.isNotEmpty ? node.parentId : parentId; + stores.add( + StoreInfo( + orgId: node.id, + parentId: effectiveParentId.isEmpty ? null : effectiveParentId, + name: node.name, + address: fullAddress.join(''), + contactPhone: node.contactPhone, + activationCode: node.activationCode, + status: 0, + // 透传组织树节点的到期时间(部分部署的 /sys/org/tree 会返回 + // expireTime/expire_time;缺失则为 null,列表项将据此显示「无到期时间」)。 + expireTime: node.expireTime, + createTime: node.createdAt, + deviceLimit: node.groupLimit, + // 新接口 /sys/org/tree 返回的 themeId(如 35/36/37), + // 转为字符串供前端 StoreThemePresets 匹配或回显。 + themeId: node.themeId?.toString(), + ), + ); + } + if (node.children.isNotEmpty) { + stores.addAll(_collectStores(node.children, parentId: node.id)); + } + } + return stores; + } + + /// 按组织 ID 获取门店列表。 + /// + /// GET /sys/store/byOrgId/{orgId} + Future> getStoresByOrgId(String orgId) async { + final response = + await _dio.get('/sys/store/byOrgId/$orgId'); + final list = _extractList(response.data); + return list + .whereType>() + .map(StoreInfo.fromJson) + .toList(); + } + + /// 按门店 ID 获取设备分组列表。 + /// + /// GET /sys/store/deviceGroups/{storeId} + Future> getDeviceGroupsByStoreId(String storeId) async { + final response = + await _dio.get('/sys/store/deviceGroups/$storeId'); + final list = _extractList(response.data); + return list + .whereType>() + .map(OrgNode.fromJson) + .toList(); + } + + /// 门店搜索(分页,支持多条件筛选)。 + /// + /// GET /sys/store/search?page=&limit=&orgId=&name=&status= + /// + /// 未显式传入 [orgId] 时,自动带上 [SessionOrgScope] 的当前用户组织, + /// 防止未指定组织时越权查询到其他组织的门店([SessionOrgScope] 未启用时 + /// 不附加,保留管理员全量查询能力)。 + Future> searchStore({ + int page = 1, + int limit = 20, + String? orgId, + String? name, + int? status, + }) async { + final effectiveOrgId = orgId ?? + (SessionOrgScope.instance.enabled + ? SessionOrgScope.instance.currentOrgId + : null); + final response = await _dio.get( + '/sys/store/search', + queryParameters: { + 'page': page, + 'limit': limit, + if (effectiveOrgId != null) 'orgId': effectiveOrgId, + if (name != null) 'name': name, + if (status != null) 'status': status, + }, + ); + final list = _extractList(response.data); + return list + .whereType>() + .map(StoreInfo.fromJson) + .toList(); + } + + /// 获取当前登录组织下的门店列表(可按名称模糊查询)。 + /// + /// GET /sys/store/listByLoginOrg?name= + Future> getStoreListByLoginOrg({String? name}) async { + final response = await _dio.get( + '/sys/store/listByLoginOrg', + queryParameters: { + if (name != null) 'name': name, + }, + ); + final list = _extractList(response.data); + return list + .whereType>() + .map(StoreInfo.fromJson) + .toList(); + } + + /// 查询门店配置(到期时间 / 禁用状态等)。 + /// + /// GET /device/storeConfig/info/{orgId} + /// + /// 与 Web 后台「门店配置」弹窗同源(screen-front `getStoreConfig`)。 + /// 组织树与通用门店列表接口通常不返回 `expireTime`,门店的到期时间必须由此 + /// 端点补全——列表/详情均用它做权威兜底。接口异常或无数据返回 [StoreConfig.empty]。 + Future getStoreConfig(String orgId) async { + try { + final response = + await _dio.get('/device/storeConfig/info/$orgId'); + final data = response.data; + if (data is! Map) return StoreConfig.empty(); + return StoreConfig.fromJson(data); + } catch (_) { + return StoreConfig.empty(); + } + } + + // ================ 门店扩展字段(前端占位) ================ + + /// 新增或更新门店(含主题/品牌/省市区等扩展字段)。 + /// + /// 门店本质上是 type='STORE' 的组织节点。按是否携带 id/orgId 分流: + /// - 新增 → [createOrg](POST /sys/org/save,不带主键,必填 province); + /// - 编辑 → [updateOrg](POST /sys/org/update,只认 id)。 + /// 返回后端生成的 orgId(新增门店时可用于创建激活码)。 + Future saveStore(Map data) async { + // 真实上报后端(新增/编辑分流到正确路由)。 + final isEdit = (data['id'] != null && data['id'].toString().isNotEmpty) || + (data['orgId'] != null && data['orgId'].toString().isNotEmpty); + final result = isEdit ? updateOrg(data) : createOrg(data); + // 扩展字段写本地缓存:用真实 orgId 回填,避免新建时因 orgId 为空而丢失 + // 主题/品牌/省市区等建店配置(Web 端同源,移动端本地兜底回显)。 + final realOrgId = (result ?? data['orgId'] ?? data['id'])?.toString() ?? ''; + if (realOrgId.isNotEmpty) { + final ext = Map.from(data)..['orgId'] = realOrgId; + await _persistStoreExtension(ext); + } + return result; + } + + /// 更新门店(含扩展字段)。 + /// + /// 固定走 [updateOrg](POST /sys/org/update,只认 id)。 + Future updateStore(Map data) async { + final result = updateOrg(data); + final realOrgId = (result ?? data['orgId'] ?? data['id'])?.toString() ?? ''; + if (realOrgId.isNotEmpty) { + final ext = Map.from(data)..['orgId'] = realOrgId; + await _persistStoreExtension(ext); + } + return result; + } + + /// 设置门店主题皮肤。 + /// + /// 本地缓存兜底(离线回显)+ 上报后端 `POST /sys/store/setTheme`。 + /// 上报失败时异常向外传播,由调用方决定提示策略(门店列表页会静默忽略, + /// 不影响门店本身已保存的结果)。 + Future setStoreTheme(String orgId, String themeId) async { + // 本地缓存兜底:即使上报失败,本机回显仍可用。 + await StoreLocalCache.instance.saveTheme(orgId, themeId); + await _dio.post( + '/sys/store/setTheme', + data: {'orgId': orgId, 'themeId': themeId}, + ); + } + + /// 设置门店启动图/Logo。 + /// + /// [splashUrl]/[logoUrl] 可以是: + /// - 远程 URL(http/https 开头)→ 直接上报; + /// - 本地文件路径 → 先经 [UploadService.uploadImage] 上传拿 CDN URL 再上报。 + /// + /// 本地缓存兜底(离线回显)+ 上报后端 `POST /sys/store/setBranding`。 + Future setStoreBranding( + String orgId, + String splashUrl, + String logoUrl, + ) async { + // 本地缓存兜底。 + await StoreLocalCache.instance.saveBranding(orgId, splashUrl, logoUrl); + + final upload = UploadService(); + Future resolve(String raw) async { + if (raw.isEmpty) return raw; + // 已是远程 URL 直接复用;否则当作本地路径上传拿 CDN URL。 + if (raw.startsWith('http://') || raw.startsWith('https://')) return raw; + return upload.uploadImage(XFile(raw), mediaType: 'image'); + } + + final splash = await resolve(splashUrl); + final logo = await resolve(logoUrl); + await _dio.post( + '/sys/store/setBranding', + data: {'orgId': orgId, 'splashUrl': splash, 'logoUrl': logo}, + ); + } + + /// 设置门店省市区归属与详细地址。 + /// + /// 本地缓存兜底(离线回显)+ 上报后端 `POST /sys/store/setRegion`。 + Future setRegion( + String orgId, + String provinceCode, + String cityCode, + String districtCode, + String addressDetail, + ) async { + await StoreLocalCache.instance.saveRegion( + orgId, + provinceCode, + cityCode, + districtCode, + addressDetail, + ); + await _dio.post( + '/sys/store/setRegion', + data: { + 'orgId': orgId, + 'provinceCode': provinceCode, + 'cityCode': cityCode, + 'districtCode': districtCode, + 'addressDetail': addressDetail, + }, + ); + } + + /// 把门店扩展字段写入本地缓存([data] 缺省字段忽略)。 + Future _persistStoreExtension(Map data) async { + final orgId = (data['orgId'] ?? data['id'])?.toString() ?? ''; + if (orgId.isEmpty) return; + final themeId = data['themeId']?.toString(); + if (themeId != null && themeId.isNotEmpty) { + await StoreLocalCache.instance.saveTheme(orgId, themeId); + } + final splashUrl = data['splashUrl']?.toString() ?? ''; + final logoUrl = data['logoUrl']?.toString() ?? ''; + if (splashUrl.isNotEmpty || logoUrl.isNotEmpty) { + await StoreLocalCache.instance.saveBranding(orgId, splashUrl, logoUrl); + } + final provinceCode = data['provinceCode']?.toString() ?? ''; + final cityCode = data['cityCode']?.toString() ?? ''; + final districtCode = data['districtCode']?.toString() ?? ''; + final addressDetail = data['addressDetail']?.toString() ?? ''; + if (provinceCode.isNotEmpty || + cityCode.isNotEmpty || + districtCode.isNotEmpty || + addressDetail.isNotEmpty) { + await StoreLocalCache.instance.saveRegion( + orgId, + provinceCode, + cityCode, + districtCode, + addressDetail, + ); + } + } + + // ================ 工具方法 ================ + + /// 在组织树中按节点 ID 深度优先查找节点。 + static OrgNode? _findNode(List nodes, String target) { + for (final n in nodes) { + if (n.id == target) return n; + final found = _findNode(n.children, target); + if (found != null) return found; + } + return null; + } + + /// 收集某节点(含自身)的全部后代 ID(深度优先,含 root 本身)。 + /// + /// 用于组织树筛选:选中某个渠道 / 代理商 / 分组后,命中其下所有层级的门店。 + /// 找不到 [rootId] 对应节点时返回空集合。 + /// + /// [nodes] 为已按 [SessionOrgScope] 收敛的组织树([getOrgTree] 返回值)。 + static Set collectDescendantIds(List nodes, String rootId) { + final root = _findNode(nodes, rootId); + final ids = {}; + void walk(List ns) { + for (final n in ns) { + ids.add(n.id); + walk(n.children); + } + } + + if (root != null) walk([root]); + return ids; + } + + /// 收集某节点(含自身)的全部后代中「门店(type=3)」的 ID。 + /// + /// 与 [collectDescendantIds] 的区别:仅收集门店节点,过滤掉渠道/代理/分组等 + /// 非门店节点,便于直接作为 `/device/info/list` 的 `storeOrgId` 入参集合使用。 + /// 找不到 [rootId] 对应节点时返回空集合。 + static Set collectDescendantStoreIds( + List nodes, + String rootId, + ) { + final root = _findNode(nodes, rootId); + final ids = {}; + void walk(List ns) { + for (final n in ns) { + if (n.type == 3) ids.add(n.id); + walk(n.children); + } + } + + if (root != null) walk([root]); + return ids; + } + + /// 将组织树收敛到「当前登录用户所属组织子树」(含自身与全部后代)。 + /// + /// - 未启用 [SessionOrgScope](管理员 / orgId 为空):原样返回。 + /// - 已启用但树中找不到对应节点:兜底原样返回,避免误清空。 + /// + /// 这样即便后端 `/sys/org/tree` 返回全量树,非管理员用户也只能看到 + /// 自己组织及下属门店的数据。 + static List _scopeTree(List tree) { + final scope = SessionOrgScope.instance; + if (!scope.enabled) return tree; + final root = _findNode(tree, scope.currentOrgId); + // 兜底:找不到当前组织节点时不过滤(避免误清空导致白屏), + // 但打印告警以便排查「用户 orgId 与组织树节点不匹配」的数据异常。 + if (root == null) { + debugPrint( + '[OrgService._scopeTree] 未在组织树中找到当前组织节点 ' + '(orgId=${scope.currentOrgId}),放弃客户端收敛', + ); + return tree; + } + return [root]; + } + + /// 收集「当前已收敛组织树」包含的全部节点 ID(用于裁剪扁平/下拉列表与用户列表)。 + /// + /// 仅在 [SessionOrgScope] 启用时返回非空集合([getOrgTree] 已按范围收敛); + /// 未启用时返回空集合,调用方据此跳过过滤、保持原样。 + Future> scopedOrgIdSet() async { + final scope = SessionOrgScope.instance; + if (!scope.enabled) return const {}; + final tree = await getOrgTree(); + final ids = {}; + void collect(List nodes) { + for (final n in nodes) { + ids.add(n.id); + collect(n.children); + } + } + + collect(tree); + return ids; + } + + /// 兼容列表响应的三种形态: + /// `{list: [...], totalCount}` / `{page: {list, totalCount}}` / 直接数组。 + static List _extractList(dynamic data) { + if (data is List) { + return data; + } + if (data is Map) { + final list = data['list']; + if (list is List) { + return list; + } + final page = data['page']; + if (page is Map && page['list'] is List) { + return page['list'] as List; + } + } + return const []; + } +} diff --git a/lib/services/sys_service.dart b/lib/services/sys_service.dart new file mode 100644 index 0000000..d978413 --- /dev/null +++ b/lib/services/sys_service.dart @@ -0,0 +1,444 @@ +import 'package:dio/dio.dart'; + +import '../models/schedule.dart'; +import '../models/sys_config.dart'; +import '../models/sys_log.dart'; +import '../models/sys_menu.dart'; +import '../models/sys_role.dart'; +import '../models/sys_user.dart'; +import 'api_client.dart'; + +/// 系统管理服务:用户、角色、菜单、参数配置、日志、定时任务、密码修改。 +/// +/// 对接真实「爱视后台」/api/sys/* 接口。 +/// 响应已由 [ApiInterceptor] 解包 `{code,msg,data}` 信封,直接消费 data。 +class SystemService { + SystemService({Dio? dio}) : _dio = dio ?? ApiClient.instance.dio; + + final Dio _dio; + + // ================ 用户管理 ================ + + /// 获取用户列表。 + /// + /// GET /sys/user/list?page=&limit=&username= + Future> getUserList({ + int page = 1, + int limit = 20, + String? username, + String? orgId, + }) async { + final response = await _dio.get( + '/sys/user/list', + queryParameters: { + 'page': page, + 'limit': limit, + if (username != null) 'username': username, + if (orgId != null) 'orgId': orgId, + }, + ); + final list = _extractList(response.data); + return list + .whereType>() + .map(SysUser.fromJson) + .toList(); + } + + /// 获取用户详情。 + /// + /// GET /sys/user/info/{id} + Future getUserInfo(String id) async { + final response = await _dio.get('/sys/user/info/$id'); + final data = response.data; + if (data is! Map) { + throw ApiException(500, '用户信息加载失败:$id'); + } + return SysUser.fromJson(data); + } + + /// 新增或修改用户。 + /// + /// 与 Web 端一致:携带 id 走 /sys/user/update(修改,密码留空表示不修改), + /// 否则走 /sys/user/save(新增,密码必填)。 + Future saveUser(Map data) async { + final hasId = data['id'] != null && data['id'].toString().isNotEmpty; + final url = hasId ? '/sys/user/update' : '/sys/user/save'; + await _dio.post(url, data: data); + } + + /// 批量删除用户。 + /// + /// POST /sys/user/delete,body 为 `{ids: [...]}`。 + Future deleteUser(List ids) async { + await _dio.post( + '/sys/user/delete', + data: {'ids': ids}, + ); + } + + // ================ 角色管理 ================ + + /// 获取角色列表(分页)。 + /// + /// GET /sys/role/list?page=&limit= + Future> getRoleList({ + int page = 1, + int limit = 20, + }) async { + final response = await _dio.get( + '/sys/role/list', + queryParameters: {'page': page, 'limit': limit}, + ); + final list = _extractList(response.data); + return list + .whereType>() + .map(SysRole.fromJson) + .toList(); + } + + /// 获取角色下拉选项(不分页,用于用户表单选择角色)。 + /// + /// GET /sys/role/select + Future> getRoleSelect() async { + final response = await _dio.get('/sys/role/select'); + final list = _extractList(response.data); + return list + .whereType>() + .map(SysRole.fromJson) + .toList(); + } + + /// 获取角色详情。 + /// + /// GET /sys/role/info/{id} + Future getRoleInfo(String id) async { + final response = await _dio.get('/sys/role/info/$id'); + final data = response.data; + if (data is! Map) { + throw ApiException(500, '角色信息加载失败:$id'); + } + return SysRole.fromJson(data); + } + + /// 新增或修改角色。 + /// + /// POST /sys/role/save,body 为角色数据(可含 menuIdList)。 + Future saveRole(Map data) async { + await _dio.post('/sys/role/save', data: data); + } + + /// 批量删除角色。 + /// + /// POST /sys/role/delete,body 为 `{ids: [...]}`。 + Future deleteRole(List ids) async { + await _dio.post( + '/sys/role/delete', + data: {'ids': ids}, + ); + } + + // ================ 菜单管理 ================ + + /// 获取当前用户可见的导航菜单树。 + /// + /// GET /sys/menu/nav + Future> getMenuNav() async { + final response = await _dio.get('/sys/menu/nav'); + final list = _extractList(response.data); + return list + .whereType>() + .map(SysMenu.fromJson) + .toList(); + } + + /// 获取菜单列表(分页或全量,后端可能返回平铺列表)。 + /// + /// GET /sys/menu/list + Future> getMenuList() async { + final response = await _dio.get('/sys/menu/list'); + final list = _extractList(response.data); + return list + .whereType>() + .map(SysMenu.fromJson) + .toList(); + } + + /// 获取菜单下拉选项(用于角色权限树选择)。 + /// + /// GET /sys/menu/select + Future> getMenuSelect() async { + final response = await _dio.get('/sys/menu/select'); + final list = _extractList(response.data); + return list + .whereType>() + .map(SysMenu.fromJson) + .toList(); + } + + /// 获取菜单详情。 + /// + /// GET /sys/menu/info/{id} + Future getMenuInfo(String id) async { + final response = await _dio.get('/sys/menu/info/$id'); + final data = response.data; + if (data is! Map) { + throw ApiException(500, '菜单信息加载失败:$id'); + } + return SysMenu.fromJson(data); + } + + /// 新增或修改菜单。 + /// + /// POST /sys/menu/save,body 为菜单数据。 + Future saveMenu(Map data) async { + await _dio.post('/sys/menu/save', data: data); + } + + /// 删除菜单(单个,含子菜单校验)。 + /// + /// POST /sys/menu/delete/{id} + Future deleteMenu(String id) async { + await _dio.post('/sys/menu/delete/$id'); + } + + // ================ 参数配置 ================ + + /// 获取参数配置列表(分页)。 + /// + /// GET /sys/config/list?page=&limit= + Future> getConfigList({ + int page = 1, + int limit = 20, + }) async { + final response = await _dio.get( + '/sys/config/list', + queryParameters: {'page': page, 'limit': limit}, + ); + final list = _extractList(response.data); + return list + .whereType>() + .map(SysConfig.fromJson) + .toList(); + } + + /// 获取参数配置详情。 + /// + /// GET /sys/config/info/{id} + Future getConfigInfo(String id) async { + final response = await _dio.get('/sys/config/info/$id'); + final data = response.data; + if (data is! Map) { + throw ApiException(500, '参数配置加载失败:$id'); + } + return SysConfig.fromJson(data); + } + + /// 新增或修改参数配置。 + /// + /// POST /sys/config/save,body 为配置数据。 + Future saveConfig(Map data) async { + await _dio.post('/sys/config/save', data: data); + } + + /// 批量删除参数配置。 + /// + /// POST /sys/config/delete,body 为 `{ids: [...]}`。 + Future deleteConfig(List ids) async { + await _dio.post( + '/sys/config/delete', + data: {'ids': ids}, + ); + } + + // ================ 日志管理 ================ + + /// 获取操作日志列表(分页)。 + /// + /// GET /sys/log/list?page=&limit= + Future> getLogList({ + int page = 1, + int limit = 20, + }) async { + final response = await _dio.get( + '/sys/log/list', + queryParameters: {'page': page, 'limit': limit}, + ); + final list = _extractList(response.data); + return list + .whereType>() + .map(SysLog.fromJson) + .toList(); + } + + /// 获取审计日志列表(分页)。 + /// + /// GET /sys/auditLog/list?page=&limit= + Future> getAuditLogList({ + int page = 1, + int limit = 20, + }) async { + final response = await _dio.get( + '/sys/auditLog/list', + queryParameters: {'page': page, 'limit': limit}, + ); + final list = _extractList(response.data); + return list + .whereType>() + .map(SysLog.fromJson) + .toList(); + } + + /// 获取 API 调用日志列表(分页)。 + /// + /// GET /sys/scheduleLog/list?page=&limit= + /// 注意:接口路径为 scheduleLog,用于定时任务调用日志查询。 + Future> getApiJstCallLogList({ + int page = 1, + int limit = 20, + }) async { + final response = await _dio.get( + '/sys/scheduleLog/list', + queryParameters: {'page': page, 'limit': limit}, + ); + final list = _extractList(response.data); + return list + .whereType>() + .map(SysLog.fromJson) + .toList(); + } + + // ================ 定时任务 ================ + + /// 获取定时任务列表(分页)。 + /// + /// GET /sys/schedule/list?page=&limit= + Future> getScheduleList({ + int page = 1, + int limit = 20, + }) async { + final response = await _dio.get( + '/sys/schedule/list', + queryParameters: {'page': page, 'limit': limit}, + ); + final list = _extractList(response.data); + return list + .whereType>() + .map(Schedule.fromJson) + .toList(); + } + + /// 获取定时任务详情。 + /// + /// GET /sys/schedule/info/{id} + Future getScheduleInfo(String id) async { + final response = await _dio.get('/sys/schedule/info/$id'); + final data = response.data; + if (data is! Map) { + throw ApiException(500, '定时任务信息加载失败:$id'); + } + return Schedule.fromJson(data); + } + + /// 新增或修改定时任务。 + /// + /// POST /sys/schedule/save,body 为任务数据。 + Future saveSchedule(Map data) async { + await _dio.post('/sys/schedule/save', data: data); + } + + /// 批量删除定时任务。 + /// + /// POST /sys/schedule/delete,body 为 `{ids: [...]}`。 + Future deleteSchedule(List ids) async { + await _dio.post( + '/sys/schedule/delete', + data: {'ids': ids}, + ); + } + + /// 暂停定时任务。 + /// + /// POST /sys/schedule/pause,body 为 `{jobId: ...}`。 + Future pauseSchedule(String jobId) async { + await _dio.post( + '/sys/schedule/pause', + data: {'jobId': jobId}, + ); + } + + /// 恢复定时任务。 + /// + /// POST /sys/schedule/resume,body 为 `{jobId: ...}`。 + Future resumeSchedule(String jobId) async { + await _dio.post( + '/sys/schedule/resume', + data: {'jobId': jobId}, + ); + } + + /// 立即执行一次定时任务。 + /// + /// POST /sys/schedule/run,body 为 `{jobId: ...}`。 + Future runSchedule(String jobId) async { + await _dio.post( + '/sys/schedule/run', + data: {'jobId': jobId}, + ); + } + + /// 获取定时任务日志列表(分页)。 + /// + /// GET /sys/scheduleLog/list?page=&limit= + /// + /// 与 [getApiJstCallLogList] 相同路径,保留独立方法以区分语义。 + Future> getScheduleLogList({ + int page = 1, + int limit = 20, + }) async { + final response = await _dio.get( + '/sys/scheduleLog/list', + queryParameters: {'page': page, 'limit': limit}, + ); + final list = _extractList(response.data); + return list + .whereType>() + .map(SysLog.fromJson) + .toList(); + } + + // ================ 密码修改 ================ + + /// 修改当前用户密码。 + /// + /// POST /sys/user/password,body 为 `{password, newPassword}`。 + Future updatePassword( + String oldPassword, + String newPassword, + ) async { + await _dio.post( + '/sys/user/password', + data: {'password': oldPassword, 'newPassword': newPassword}, + ); + } + + // ================ 工具方法 ================ + + /// 兼容列表响应的三种形态: + /// `{list: [...], totalCount}` / `{page: {list, totalCount}}` / 直接数组。 + static List _extractList(dynamic data) { + if (data is List) { + return data; + } + if (data is Map) { + final list = data['list']; + if (list is List) { + return list; + } + final page = data['page']; + if (page is Map && page['list'] is List) { + return page['list'] as List; + } + } + return const []; + } +} diff --git a/lib/services/theme_service.dart b/lib/services/theme_service.dart new file mode 100644 index 0000000..af60281 --- /dev/null +++ b/lib/services/theme_service.dart @@ -0,0 +1,72 @@ +import 'package:dio/dio.dart'; + +import '../models/theme_group.dart'; +import '../models/theme_template.dart'; +import 'api_client.dart'; + +/// 主题模板服务:对接 web 后台 /theme/themetemplate/* 与 /theme/page/* 接口。 +class ThemeService { + ThemeService({Dio? dio}) : _dio = dio ?? ApiClient.instance.dio; + + final Dio _dio; + + /// 获取全部启用的主题模板。 + /// + /// GET /theme/themetemplate/all?status=ENABLED + Future> getThemeTemplateAll() async { + final response = await _dio.get( + '/theme/themetemplate/all', + queryParameters: const {'status': 'ENABLED'}, + ); + final list = _extractList(response.data); + return list + .whereType>() + .map(ThemeTemplate.fromJson) + .where((t) => t.id.isNotEmpty && t.isEnabled) + .toList(); + } + + /// 查询门店下各设备分组的主题预览列表(界面配置)。 + /// + /// GET /theme/page/org/{orgId}/photos + Future> getStoreThemeGroups(String orgId) async { + final response = await _dio.get( + '/theme/page/org/$orgId/photos', + ); + final data = response.data; + if (data is List) { + return data + .whereType>() + .map(ThemeGroup.fromJson) + .where((g) => g.groupId > 0) + .toList(); + } + if (data is Map) { + final code = data['code']; + final isSuccess = code == null || code == 200 || code == 0; + if (!isSuccess) return []; + final list = data['data'] ?? data['list']; + if (list is List) { + return list + .whereType>() + .map(ThemeGroup.fromJson) + .where((g) => g.groupId > 0) + .toList(); + } + } + return []; + } + + /// 兼容列表响应的三种形态: + /// `{list: [...]}` / `{data: [...]}` / 直接数组。 + static List _extractList(dynamic data) { + if (data is List) return data; + if (data is Map) { + final list = data['list']; + if (list is List) return list; + final innerData = data['data']; + if (innerData is List) return innerData; + } + return const []; + } +} diff --git a/lib/services/update_service.dart b/lib/services/update_service.dart new file mode 100644 index 0000000..9e67ced --- /dev/null +++ b/lib/services/update_service.dart @@ -0,0 +1,127 @@ +import 'package:flutter/foundation.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import '../models/app_version.dart'; +import '../utils/session_org_scope.dart'; +import 'api_client.dart'; + +/// 版本更新检测结果。 +/// +/// [hasUpdate] 是否检测到更高版本;[latest] 服务端最新可用版本(无更新时为 null)。 +class UpdateCheckResult { + const UpdateCheckResult({required this.hasUpdate, this.latest}); + + final bool hasUpdate; + final AppVersion? latest; +} + +/// 版本更新服务:检测当前 App 是否有新版本可更新。 +/// +/// 对齐 Web 端 `searchAppVersion`:按当前登录用户所属组织查询该组织配置的 +/// APP 版本(`GET /device/orgGroupAppVersion/search?orgId=...`),再与本地 +/// versionCode 比较。 +/// +/// 注意:这里不再调用 `/device/orgGroupAppVersion/list`。`/list` 是管理后台 +/// 全量枚举接口,普通用户常因权限不足收到「未知异常,请联系管理员」; +/// `/search` 才是「查询某组织当前应使用版本」的语义入口。 +class UpdateService { + UpdateService({required this.packageName, ApiClient? client}) + : _client = client ?? ApiClient.instance; + + /// 当前 App 包名(运行时由 package_info_plus 取得,如 com.tongshi.cloudhome)。 + final String packageName; + + final ApiClient _client; + + /// 查询当前用户所属组织配置的版本,并判定是否有更新。 + /// + /// - 优先使用传入的 [orgId],未传入时回退到 [SessionOrgScope.instance.currentOrgId]; + /// - 若最终没有可用 orgId(管理员/未识别),直接返回「无更新」,避免向后端 + /// 发起缺少必要参数的请求; + /// - 调用 `/device/orgGroupAppVersion/search?orgId=xxx` 获取该组织配置的版本; + /// - 兼容返回体为 `null` / `{appVersion: {...}}` / 直接版本对象三种形态; + /// - 仅当返回版本 `status==0` 且 versionCode 大于当前版本时才认为有更新; + /// - 若后端返回了 packageName 且与当前 App 包名不一致,也认为无更新,防止 + /// 不同包被错误提示。 + /// + /// 任一异常均向上抛出,由调用方决定是否静默忽略(启动时检测失败不应阻断使用)。 + Future checkForUpdate( + int currentVersionCode, { + String? orgId, + }) async { + final effectiveOrgId = (orgId != null && orgId.isNotEmpty) + ? orgId + : (SessionOrgScope.instance.enabled + ? SessionOrgScope.instance.currentOrgId + : null); + + if (effectiveOrgId == null || effectiveOrgId.isEmpty) { + if (kDebugMode) { + debugPrint('[UpdateService] 缺少 orgId,跳过更新检查'); + } + return const UpdateCheckResult(hasUpdate: false); + } + + if (kDebugMode) { + debugPrint( + '[UpdateService] orgId=$effectiveOrgId currentCode=$currentVersionCode', + ); + } + + final response = await _client.dio.get( + '/device/orgGroupAppVersion/search', + queryParameters: {'orgId': effectiveOrgId}, + ); + + final latest = _parseVersion(response.data); + if (latest == null) { + if (kDebugMode) debugPrint('[UpdateService] 未查询到组织版本配置'); + return const UpdateCheckResult(hasUpdate: false); + } + + if (kDebugMode) { + debugPrint( + '[UpdateService] server versionCode=${latest.versionCode} ' + 'versionName=${latest.versionName} status=${latest.status} ' + 'packageName=${latest.packageName}', + ); + } + + if (latest.status != 0) { + return const UpdateCheckResult(hasUpdate: false); + } + + // 后端配置了 packageName 但与当前 App 不匹配时,不提示更新 + //(常见于同一组织下存在多个不同包的场景)。 + if (latest.packageName.isNotEmpty && latest.packageName != packageName) { + return const UpdateCheckResult(hasUpdate: false); + } + + return UpdateCheckResult( + hasUpdate: latest.versionCode > currentVersionCode, + latest: latest, + ); + } + + /// 解析 `/search` 返回的三种形态: + /// - `null`:该组织没有配置版本; + /// - `{appVersion: {...}}`:Web 端标准形态; + /// - 直接返回版本对象。 + AppVersion? _parseVersion(dynamic data) { + if (data == null) return null; + if (data is! Map) return null; + final appVersion = data['appVersion']; + if (appVersion is Map) { + return AppVersion.fromJson(appVersion); + } + if (data.containsKey('id') || data.containsKey('versionName')) { + return AppVersion.fromJson(data); + } + return null; + } +} + +/// 读取当前 App 的 [PackageInfo](包名 / versionCode / versionName)。 +/// +/// 集中封装以便多处复用,并在 [PackageInfo] 未初始化时提供兜底。 +Future getCurrentPackageInfo() => PackageInfo.fromPlatform(); diff --git a/lib/services/upload_service.dart b/lib/services/upload_service.dart new file mode 100644 index 0000000..c937111 --- /dev/null +++ b/lib/services/upload_service.dart @@ -0,0 +1,383 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:dio/dio.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:image_picker/image_picker.dart'; + +import 'api_client.dart'; +import 'cos/cos_client.dart'; + +/// 公司介绍(小类/详情)图片与视频上传服务。 +/// +/// 对齐 Web 端 `company-intro-subcategory-form.vue` 使用的上传通道: +/// 1. 计算文件 MD5; +/// 2. `GET /theme/resource/check-md5` 判断是否已存在(秒传,跳过 COS); +/// 3. `GET /theme/upload/credential` 获取腾讯云 STS 临时凭证(含 bucket/region/key); +/// 4. 用 STS 临时密钥直传 COS(putObject,原生签名 q-sign-algorithm=sha1); +/// 5. `POST /theme/resource/register` 注册 tv_resource; +/// 6. 解析访问 URL(`theme/` 走 ipscdn CDN)返回。 +/// +/// 复用 [ApiClient.instance.dio],自动携带 Bearer 令牌、解包 `{code,msg,data}` 信封。 +class UploadService { + UploadService({Dio? dio}) : _dio = dio ?? ApiClient.instance.dio; + + final Dio _dio; + + /// 主题上传资源 CDN 根地址(与 Web 端 themeCdnBase 一致)。 + static const String _themeCdnBase = 'https://ipscdn.ainetv.cn'; + + /// 上传单张图片/视频,返回可访问的 CDN URL。 + /// + /// [mediaType] 资源类型:image | video | file,用于后端凭证与落库。 + /// + /// 失败统一抛出 [ApiException],message 为可展示的中文错误。 + Future uploadImage(XFile file, {String mediaType = 'image'}) async { + final ext = _suffixOf(file.path); + final bytes = await file.readAsBytes(); + final fileMd5 = md5.convert(bytes).toString(); // 32 位小写十六进制 + + // 1. 秒传判断:已存在则直接返回 URL,跳过 COS 直传。 + final instant = await _tryInstantUpload(fileMd5); + if (instant != null) return instant; + + // 2. 获取 STS 临时凭证(含 bucket / region / key)。 + final cred = await _fetchStsCredential(fileMd5, ext, mediaType); + if (cred.key.isEmpty || cred.bucket.isEmpty || cred.region.isEmpty) { + throw const ApiException(500, '上传凭证数据不完整(缺少 key/bucket/region)'); + } + + // 3. 用 STS 临时密钥直传 COS。 + final provider = TemporaryCredentialProvider( + fetchCredentials: () async => CosCredentialData( + startTime: cred.startTime, + expiredTime: cred.expiredTime, + credentials: CosCredential( + tmpSecretId: cred.tmpSecretId, + tmpSecretKey: cred.tmpSecretKey, + sessionToken: cred.securityToken, + ), + requestId: '', + ), + ); + final cos = CosClient.withTemporaryCredential( + temporaryCredentialProvider: provider, + region: cred.region, + bucket: cred.bucket, + ); + final result = await cos.putObjectFromFile( + objectKey: cred.key, + file: File(file.path), + ); + if (!result.success) { + throw ApiException(500, '云存储上传失败:${result.message ?? "未知错误"}'); + } + + // 4. 解析可访问 URL(theme/ 走 ipscdn CDN)。 + final url = _resolveUrl(cred.key, cred.url); + + // 5. 注册 tv_resource(落库,供后续秒传与关联)。 + await _registerResource( + fileName: file.name, + fileKey: cred.key, + fileUrl: url, + fileType: mediaType, + fileSize: bytes.length, + suffix: ext, + fileMd5: fileMd5, + ); + + return url; + } + + /// 按原文件名上传 APK 文件(对齐 Web 端 `uploadThemeFileByOriginalName`)。 + /// + /// 流程: + /// 1. `GET /theme/upload/credential?fileName=<原始文件名>&suffix=apk&type=file` 获取 STS 凭证; + /// 2. 用 STS 临时密钥直传 COS(putObject); + /// 3. 返回可访问 URL(不注册 tv_resource,APK 不走 MD5 秒传)。 + /// + /// [onProgress] 上传进度回调(0~100)。 + /// + /// 失败抛出 [ApiException]。 + Future uploadApkByOriginalName( + PlatformFile file, { + void Function(int percent)? onProgress, + }) async { + final fileName = file.name.trim(); + if (fileName.isEmpty) { + throw const ApiException(400, '文件名不能为空'); + } + final ext = _suffixOf(fileName); + final safeExt = (ext == 'apk') ? 'apk' : (ext.isNotEmpty ? ext : 'apk'); + + // 1. 获取 STS 凭证(fileName = 原始文件名,非 MD5)。 + final cred = await _fetchStsCredentialByName( + fileName: fileName, + suffix: safeExt, + type: 'file', + ); + if (cred.key.isEmpty || cred.bucket.isEmpty || cred.region.isEmpty) { + throw const ApiException(500, '上传凭证数据不完整(缺少 key/bucket/region)'); + } + + // 2. 直传 COS。 + final provider = TemporaryCredentialProvider( + fetchCredentials: () async => CosCredentialData( + startTime: cred.startTime, + expiredTime: cred.expiredTime, + credentials: CosCredential( + tmpSecretId: cred.tmpSecretId, + tmpSecretKey: cred.tmpSecretKey, + sessionToken: cred.securityToken, + ), + requestId: '', + ), + ); + final cos = CosClient.withTemporaryCredential( + temporaryCredentialProvider: provider, + region: cred.region, + bucket: cred.bucket, + ); + + // file_picker 的 PlatformFile 可能有 path(Android 通常有)。 + final filePath = file.path; + if (filePath == null || filePath.isEmpty) { + throw const ApiException(400, '无法读取选中文件的路径'); + } + final localFile = File(filePath); + if (!localFile.existsSync()) { + throw ApiException(400, '文件不存在:$filePath'); + } + + final result = await cos.putObjectFromFile( + objectKey: cred.key, + file: localFile, + contentType: 'application/vnd.android.package-archive', + onProgress: (count, total) { + if (total > 0 && onProgress != null) { + onProgress(((count / total) * 100).round()); + } + }, + ); + if (!result.success) { + throw ApiException(500, '云存储上传失败:${result.message ?? "未知错误"}'); + } + + // 3. 解析访问 URL。 + final url = _resolveUrl(cred.key, cred.url); + if (url.isEmpty) { + throw const ApiException(500, '未返回访问地址'); + } + + return url; + } + + /// 打开系统文件选择器,仅允许 .apk 文件。 + /// + /// 返回选中的 [PlatformFile],用户取消返回 null。 + static Future pickApkFile() async { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['apk'], + ); + final files = result?.files; + if (files == null || files.isEmpty) return null; + return files.first; + } + + /// 取文件扩展名(不含点,小写)。 + String _suffixOf(String path) { + final name = path.split(Platform.pathSeparator).last; + final idx = name.lastIndexOf('.'); + return idx > 0 ? name.substring(idx + 1).toLowerCase() : ''; + } + + /// 秒传判断:已存在同 MD5 资源则直接返回其访问 URL,否则返回 null。 + /// + /// 任何异常都不阻塞主流程(当作未命中秒传)。 + Future _tryInstantUpload(String fileMd5) async { + try { + final resp = await _dio.get>( + '/theme/resource/check-md5', + queryParameters: {'fileMd5': fileMd5}, + ); + final data = resp.data; + if (data == null) return null; + + bool uploaded = data['uploaded'] == true; + dynamic resourceRaw = data['resource']; + if (data['data'] is Map) { + final inner = data['data'] as Map; + if (inner['uploaded'] == true) { + uploaded = true; + resourceRaw ??= inner['resource'] ?? inner; + } + } + if (!uploaded) return null; + + final r = resourceRaw is Map + ? Map.from(resourceRaw as Map) + : null; + if (r == null) return null; + final id = r['id'] ?? r['resourceId'] ?? r['resource_id']; + if (id == null || id == 0) return null; + + final key = (r['key'] ?? r['fileKey'] ?? '').toString(); + final rawUrl = (r['url'] ?? r['fileUrl'] ?? '').toString(); + final url = _resolveUrl(key, rawUrl); + return url.isNotEmpty ? url : null; + } catch (_) { + return null; + } + } + + /// 获取 STS 临时凭证(以文件 MD5 作为 fileName,用于主题资源秒传)。 + Future<_StsCred> _fetchStsCredential( + String fileMd5, + String ext, + String mediaType, + ) async { + final resp = await _dio.get>( + '/theme/upload/credential', + queryParameters: { + 'fileName': fileMd5, + if (ext.isNotEmpty) 'suffix': ext, + 'type': mediaType, + }, + ); + final data = resp.data; + if (data == null) { + throw const ApiException(500, '获取上传凭证失败:响应为空'); + } + return _StsCred.fromJson(Map.from(data as Map)); + } + + /// 获取 STS 临时凭证(以原始文件名作为 fileName,用于 APK 等需保留原名的场景)。 + /// + /// 对齐 Web 端 `fetchThemeCredentialByOriginalName`: + /// `GET /theme/upload/credential?fileName=<原始名>&suffix=<扩展名>&type=file` + Future<_StsCred> _fetchStsCredentialByName({ + required String fileName, + required String suffix, + required String type, + }) async { + final resp = await _dio.get>( + '/theme/upload/credential', + queryParameters: { + 'fileName': fileName, + 'suffix': suffix, + 'type': type, + }, + ); + final data = resp.data; + if (data == null) { + throw const ApiException(500, '获取上传凭证失败:响应为空'); + } + return _StsCred.fromJson(Map.from(data as Map)); + } + + /// 注册 tv_resource。 + Future _registerResource({ + required String fileName, + required String fileKey, + required String fileUrl, + required String fileType, + required int fileSize, + required String suffix, + required String fileMd5, + }) async { + try { + await _dio.post>( + '/theme/resource/register', + data: { + 'fileName': fileName, + 'fileKey': fileKey, + 'fileUrl': fileUrl, + 'fileType': fileType, + 'fileSize': fileSize, + 'suffix': suffix, + 'fileMd5': fileMd5, + }, + ); + } catch (e) { + // 直传已成功,但资源记录未落库:影响后续秒传与关联,明确提示。 + throw ApiException(500, '资源注册失败:${formatApiError(e)}'); + } + } + + /// 解析访问 URL:theme/ 路径走 ipscdn CDN,其余沿用后端 url 或历史 CDN。 + static String _resolveUrl(String key, String apiUrl) { + final k = key.replaceFirst(RegExp(r'^/+'), ''); + if (k.startsWith('theme/')) { + return '$_themeCdnBase/$k'; + } + if (apiUrl.startsWith('http')) return apiUrl; + if (k.startsWith('CloudMedia')) return 'https://metarialcdn.ainetv.cn/$k'; + return apiUrl.isNotEmpty ? apiUrl : '$_themeCdnBase/$k'; + } +} + +/// 归一化后的 STS 临时凭证(兼容后端大小写字段名)。 +class _StsCred { + _StsCred({ + required this.tmpSecretId, + required this.tmpSecretKey, + required this.securityToken, + required this.startTime, + required this.expiredTime, + required this.bucket, + required this.region, + required this.key, + required this.url, + }); + + final String tmpSecretId; + final String tmpSecretKey; + final String securityToken; + final int startTime; + final int expiredTime; + final String bucket; + final String region; + final String key; + final String url; + + static String _pick(Map m, List keys) { + for (final k in keys) { + final v = m[k]; + if (v != null && v.toString().trim().isNotEmpty) return v.toString().trim(); + } + return ''; + } + + static int _pickInt(Map m, List keys) { + for (final k in keys) { + final v = m[k]; + if (v != null) { + final n = v is int ? v : int.tryParse(v.toString()); + if (n != null && n > 0) return n; + } + } + return 0; + } + + factory _StsCred.fromJson(Map m) { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + var start = _pickInt(m, ['StartTime', 'startTime']); + if (start == 0) start = now - 60; + var exp = _pickInt(m, ['ExpiredTime', 'expiredTime']); + if (exp == 0) exp = now + 1800; + return _StsCred( + tmpSecretId: _pick(m, ['TmpSecretId', 'tmpSecretId']), + tmpSecretKey: _pick(m, ['TmpSecretKey', 'tmpSecretKey']), + securityToken: _pick(m, ['SecurityToken', 'securityToken', 'sessionToken']), + startTime: start, + expiredTime: exp, + bucket: _pick(m, ['bucket', 'Bucket']), + region: _pick(m, ['region', 'Region']), + key: _pick(m, ['key', 'Key']), + url: _pick(m, ['url']), + ); + } +} diff --git a/lib/utils/app_update.dart b/lib/utils/app_update.dart new file mode 100644 index 0000000..d491529 --- /dev/null +++ b/lib/utils/app_update.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../services/api_client.dart'; +import '../services/update_service.dart'; +import '../utils/i10n.dart'; + +/// 全局「已弹过更新提示」标记,避免会话内重复弹窗。 +bool _promptedThisSession = false; + +/// 全局当前 [PackageInfo] 缓存(首次读取后复用)。 +PackageInfo? _cachedPackageInfo; + +/// 取得当前 App 包信息(带缓存)。 +Future get currentPackageInfo async => + _cachedPackageInfo ??= await PackageInfo.fromPlatform(); + +/// 检测更新并在有新版时弹窗提示。 +/// +/// - [silentIfNoUpdate]=true(如启动时):无更新或检测失败均静默; +/// - [silentIfNoUpdate]=false(如手动「检查更新」):无更新/失败给出明确反馈。 +/// - [orgId] 当前登录用户所属组织 ID;传空时与旧行为一致。 +Future checkAndPromptUpdate( + BuildContext context, { + bool silentIfNoUpdate = true, + String? orgId, +}) async { + final t = AppLocalizations.of(context); + try { + final pkg = await currentPackageInfo; + final service = UpdateService(packageName: pkg.packageName); + final currentCode = int.tryParse(pkg.buildNumber) ?? 0; + final result = await service.checkForUpdate(currentCode, orgId: orgId); + + if (!context.mounted) return; + + if (result.hasUpdate && result.latest != null) { + if (!_promptedThisSession) { + _promptedThisSession = true; + showUpdateDialog(context, result, pkg); + } + return; + } + + if (!silentIfNoUpdate && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.updateNoNew)), + ); + } + } catch (e) { + // 任何异常均被捕获,不抛给调用方;仅非静默模式下给用户一句友好提示。 + if (!silentIfNoUpdate && context.mounted) { + final detail = formatApiError(e, fallback: '网络异常,请稍后重试'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('检测更新失败:$detail')), + ); + } + } +} + +/// 版本更新提示对话框:展示当前 / 最新版本,并提供「立即更新」跳转下载。 +void showUpdateDialog( + BuildContext context, + UpdateCheckResult result, + PackageInfo pkg, +) { + final t = AppLocalizations.of(context); + final latest = result.latest!; + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Row( + children: [ + const Icon(Icons.system_update_alt, color: Colors.green), + const SizedBox(width: 8), + Text(t.updateAvailable), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _InfoRow(label: t.currentVersion, value: '${pkg.version} (${pkg.buildNumber})'), + const SizedBox(height: 6), + _InfoRow( + label: t.latestVersion, + value: '${latest.versionName} (${latest.versionCode})', + ), + if (latest.apkUrl.isNotEmpty) ...[ + const SizedBox(height: 6), + _InfoRow(label: t.downloadUrl, value: latest.apkUrl), + ], + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: Text(t.updateLater), + ), + FilledButton( + onPressed: () async { + Navigator.pop(ctx); + final url = latest.apkUrl; + if (url.isEmpty) return; + final uri = Uri.parse(url); + try { + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } else if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.updateOpenFailed)), + ); + } + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.updateOpenFailed)), + ); + } + } + }, + child: Text(t.updateNow), + ), + ], + ), + ); +} + +/// 对话框内的「标签:值」单行。 +class _InfoRow extends StatelessWidget { + const _InfoRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 84, + child: Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: SelectableText( + value, + style: theme.textTheme.bodyMedium, + ), + ), + ], + ); + } +} diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart new file mode 100644 index 0000000..2c3d279 --- /dev/null +++ b/lib/utils/constants.dart @@ -0,0 +1,30 @@ +/// 全局常量定义。 +class AppConstants { + AppConstants._(); + + /// 应用名称。 + static const String appName = '云定制主页运营助手'; + + /// 是否使用 mock 数据。 + /// + /// - `false`(默认):对接真实「云定制后台」(PHP/CodeIgniter); + /// - `true`:由 [MockInterceptor] 本地返回示例数据,供无后端时演示。 + static const bool useMock = false; + + /// 接口统一前缀。 + /// + /// 后端 origin 与环境相关配置已迁移至 `lib/config/*` + /// ([AppEnvironment] / [AppConfig] / [TestConfig] / [StagingConfig]), + /// 默认锁定测试环境,禁止任何生产切换。 + static const String apiPrefix = '/api'; + + /// mock 登录账号(仅 useMock=true 时演示用)。 + static const String mockAccount = 'admin'; + static const String mockPassword = '123456'; + + /// 请求超时时间。 + static const Duration apiTimeout = Duration(seconds: 10); + + /// 设备列表分页默认每页条数。 + static const int deviceListPageSize = 100; +} diff --git a/lib/utils/credential_storage.dart b/lib/utils/credential_storage.dart new file mode 100644 index 0000000..882098b --- /dev/null +++ b/lib/utils/credential_storage.dart @@ -0,0 +1,134 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +/// 本地凭据持久化(基于 SharedPreferences,跨平台可靠持久化)。 +/// +/// 存储内容: +/// - 账号 / 密码(仅当用户勾选「记住密码」时保存) +/// - token / refreshToken / 用户信息(登录成功后保存,用于免登录恢复) +/// +/// 历史坑:早期版本使用 dart:io 文件存储 + `Directory.current`,但 Android 上 +/// `Directory.current` 指向不可写路径,会静默退化为「仅内存」存储,导致 App 重启后 +/// 凭据与登录态全部丢失(表现即「记住账号密码不生效 / 每次都要重新登录」)。 +/// 现统一改用 SharedPreferences(应用私有、沙箱隔离、必定可写、跨重启持久)。 +class CredentialStorage { + CredentialStorage._(); + + /// 单例。 + static final CredentialStorage instance = CredentialStorage._(); + + /// SharedPreferences 实例;[init] 完成后非 null(除非极端初始化失败)。 + SharedPreferences? _prefs; + + /// 是否已尝试初始化(无论成功失败都置 true,避免重复 await 同一 Future)。 + bool _initialized = false; + + /// 异步预初始化:必须在 runApp 前 await,使下方同步 getter 可读到持久化数据。 + static Future init() async { + await instance._init(); + } + + Future _init() async { + if (_initialized) return; + try { + _prefs = await SharedPreferences.getInstance(); + } catch (_) { + // 极端情况下 SharedPreferences 不可用:退化为内存态,不影响启动与登录。 + _prefs = null; + } finally { + _initialized = true; + } + } + + // ── 账号密码 ────────────────────────────────────────── + + /// 是否记住密码。 + bool get rememberPassword => _prefs?.getBool(_kRemember) ?? false; + + /// 保存的账号(rememberPassword=true 时有值)。 + String? get savedAccount => _prefs?.getString(_kAccount); + + /// 保存的密码(rememberPassword=true 时有值)。 + String? get savedPassword => _prefs?.getString(_kPassword); + + /// 保存账号密码(用户勾选「记住密码」并登录成功后调用)。 + Future saveCredentials({ + required String account, + required String password, + }) async { + final prefs = _prefs; + if (prefs == null) return; + await prefs.setString(_kAccount, account); + await prefs.setString(_kPassword, password); + await prefs.setBool(_kRemember, true); + } + + /// 清除保存的账号密码(用户未勾选「记住密码」时调用)。 + Future clearCredentials() async { + final prefs = _prefs; + if (prefs == null) return; + await prefs.remove(_kAccount); + await prefs.remove(_kPassword); + await prefs.setBool(_kRemember, false); + } + + // ── Token & 用户信息(用于免登录恢复) ────────────────── + + /// 保存的 token。 + String? get token => _prefs?.getString(_kToken); + + /// 保存的 refreshToken。 + String? get refreshToken => _prefs?.getString(_kRefreshToken); + + /// 保存的用户信息 JSON 字符串。 + String? get userJson => _prefs?.getString(_kUserJson); + + /// 保存登录态(token + refreshToken + 用户信息)。 + Future saveSession({ + required String token, + String? refreshToken, + String? userJson, + }) async { + final prefs = _prefs; + if (prefs == null) return; + await prefs.setString(_kToken, token); + if (refreshToken != null && refreshToken.isNotEmpty) { + await prefs.setString(_kRefreshToken, refreshToken); + } + if (userJson != null && userJson.isNotEmpty) { + await prefs.setString(_kUserJson, userJson); + } + } + + /// 清除登录态(登出或会话失效时调用)。 + Future clearSession() async { + final prefs = _prefs; + if (prefs == null) return; + await prefs.remove(_kToken); + await prefs.remove(_kRefreshToken); + await prefs.remove(_kUserJson); + } + + /// 是否有已保存的登录态。 + bool get hasSession => (token ?? '').isNotEmpty; + + /// 从 JSON 恢复用户信息 Map。 + Map? get savedUserMap { + final json = userJson; + if (json == null || json.isEmpty) return null; + try { + return jsonDecode(json) as Map; + } catch (_) { + return null; + } + } + + // key 常量(加前缀避免与 StoreLocalCache 等业务 key 冲突)。 + static const String _kRemember = 'cred_remember'; + static const String _kAccount = 'cred_account'; + static const String _kPassword = 'cred_password'; + static const String _kToken = 'cred_token'; + static const String _kRefreshToken = 'cred_refreshToken'; + static const String _kUserJson = 'cred_userJson'; +} diff --git a/lib/utils/formatters.dart b/lib/utils/formatters.dart new file mode 100644 index 0000000..0b62403 --- /dev/null +++ b/lib/utils/formatters.dart @@ -0,0 +1,59 @@ +import 'package:intl/intl.dart'; + +/// 通用格式化工具。 +class Formatters { + Formatters._(); + + static final DateFormat _dateTimeFormat = DateFormat('yyyy-MM-dd HH:mm:ss'); + static final DateFormat _dateFormat = DateFormat('yyyy-MM-dd'); + + /// 格式化日期时间。 + static String formatDateTime(DateTime value) { + return _dateTimeFormat.format(value); + } + + /// 仅格式化日期(YYYY-MM-DD),用于门店到期时间等只需日期的场景。 + static String formatDate(DateTime value) { + return _dateFormat.format(value); + } + + /// 将百分比值格式化为带一位小数的字符串,如 `72.5%`。 + static String formatPercent(double ratio) { + return '${(ratio * 100).toStringAsFixed(1)}%'; + } + + /// 将字节数格式化为可读单位(KB/MB/GB/TB)。 + static String formatBytes(int bytes) { + if (bytes < 1024) { + return '$bytes B'; + } + const units = ['KB', 'MB', 'GB', 'TB']; + double value = bytes.toDouble(); + int unitIndex = -1; + do { + value /= 1024; + unitIndex++; + } while (value >= 1024 && unitIndex < units.length - 1); + return '${value.toStringAsFixed(1)} ${units[unitIndex]}'; + } + + /// 将秒数格式化为运行时长字符串,如 `3天 5小时 12分`。 + static String formatUptime(int seconds) { + final duration = Duration(seconds: seconds); + final days = duration.inDays; + final hours = duration.inHours % 24; + final minutes = duration.inMinutes % 60; + + final parts = []; + if (days > 0) { + parts.add('$days天'); + } + if (hours > 0) { + parts.add('$hours小时'); + } + if (minutes > 0 || parts.isEmpty) { + parts.add('$minutes分'); + } + return parts.join(' '); + } +} diff --git a/lib/utils/i10n.dart b/lib/utils/i10n.dart new file mode 100644 index 0000000..8cb6686 --- /dev/null +++ b/lib/utils/i10n.dart @@ -0,0 +1,666 @@ +import 'package:flutter/widgets.dart'; + +import '../providers/locale_provider.dart'; + +/// 轻量级国际化助手。 +/// +/// 覆盖底部导航、首页、设备、门店、管理、设置、激活码、用户、公司介绍等 +/// 高频界面文案。后续业务模块继续在此补充。 +/// +/// 用法: +/// ```dart +/// final t = AppLocalizations.of(context); +/// Text(t.home); +/// ``` +class AppLocalizations { + AppLocalizations(this._locale); + + final AppLocale _locale; + + static AppLocalizations of(BuildContext context) { + final locale = Localizations.of(context, AppLocale); + return AppLocalizations(locale ?? AppLocale.zh); + } + + String _pick(String zh, String en) => _locale == AppLocale.en ? en : zh; + + /// 当前是否为英文模式。 + bool get isEnglish => _locale == AppLocale.en; + + // ============ 底部导航 ============ + String get home => _pick('首页', 'Home'); + String get devices => _pick('设备', 'Devices'); + String get stores => _pick('门店', 'Stores'); + String get admin => _pick('管理', 'Admin'); + String get profile => _pick('我的', 'Profile'); + + // ============ 设置 ============ + String get orientationMode => _pick('显示方向', 'Display Orientation'); + String get orientationSystem => _pick('跟随系统', 'System'); + String get orientationPortrait => _pick('竖屏', 'Portrait'); + String get orientationLandscape => _pick('横屏', 'Landscape'); + + // ============ 首页 ============ + /// AppBar / 顶部栏大标题。 + String get appTitle => _pick('云定制主页运营平台', '云定制主页运营平台'); + + /// 首页品牌横幅 / 登录页主标题。 + String get appHeroTitle => _pick('云定制主页运营助手', '云定制主页运营助手'); + + /// 登录页副标题。 + String get appSubtitle => _pick('Mobile Management', 'Mobile Management'); + String get totalDevices => _pick('设备总数', 'Total Devices'); + String get totalStores => _pick('门店总数', 'Total Stores'); + String get onlineDevices => _pick('在线设备', 'Online Devices'); + String get offlineDevices => _pick('离线设备', 'Offline Devices'); + String get quickActions => _pick('快捷操作', 'Quick Actions'); + String get systemManagement => _pick('系统管理', 'System Management'); + String get mediaManagement => _pick('媒资管理', 'Media Management'); + + // ============ 通用 ============ + String get loading => _pick('加载中…', 'Loading…'); + String get retry => _pick('重试', 'Retry'); + String get save => _pick('保存', 'Save'); + String get cancel => _pick('取消', 'Cancel'); + String get confirm => _pick('确认', 'Confirm'); + String get reset => _pick('重置', 'Reset'); + String get edit => _pick('编辑', 'Edit'); + String get delete => _pick('删除', 'Delete'); + String get add => _pick('新增', 'Add'); + String get search => _pick('搜索', 'Search'); + String get back => _pick('返回', 'Back'); + String get yes => _pick('是', 'Yes'); + String get no => _pick('否', 'No'); + String get unknown => _pick('未知', 'Unknown'); + String get none => _pick('无', 'None'); + String get notGrouped => _pick('未分组', 'Not Grouped'); + String get all => _pick('全部', 'All'); + String get noData => _pick('暂无数据', 'No data'); + String get noMoreData => _pick('没有更多数据', 'No more data'); + String get saved => _pick('保存成功', 'Saved'); + String get saveFailed => _pick('保存失败', 'Save failed'); + String get deleted => _pick('删除成功', 'Deleted'); + String get deleteFailed => _pick('删除失败', 'Delete failed'); + String get copied => _pick('已复制', 'Copied'); + String get pleaseSelect => _pick('请选择', 'Please select'); + + // ============ 设置页 ============ + String get settings => _pick('设置', 'Settings'); + String get themeMode => _pick('主题模式', 'Theme'); + String get language => _pick('语言', 'Language'); + String get changePassword => _pick('修改密码', 'Change Password'); + String get about => _pick('关于', 'About'); + String get logout => _pick('退出登录', 'Log Out'); + String get account => _pick('账号', 'Account'); + String get themeDark => _pick('深色', 'Dark'); + String get themeLight => _pick('浅色', 'Light'); + String get themeSystem => _pick('跟随系统', 'System'); + String get passwordDialogTitle => _pick('修改登录密码', 'Change Login Password'); + String get oldPassword => _pick('原密码', 'Old Password'); + String get newPassword => _pick('新密码', 'New Password'); + String get confirmNewPassword => _pick('确认新密码', 'Confirm New Password'); + String get pleaseFillPassword => + _pick('请填写原密码和新密码', 'Please fill in old and new passwords'); + String get passwordsDoNotMatch => + _pick('两次输入的新密码不一致', 'New passwords do not match'); + String get passwordChangeSuccess => + _pick('密码修改成功,请重新登录', 'Password changed, please log in again'); + String passwordChangeFailed(String error) => + _pick('修改失败:$error', 'Change failed: $error'); + + // ============ 管理页菜单 ============ + String get deviceManagement => _pick('设备管理', 'Device Management'); + String get activationCode => _pick('激活码', 'Activation Code'); + String get deviceLimit => _pick('设备上限', 'Device Limit'); + String get units => _pick('台', 'Units'); + String get storeConfig => _pick('门店配置', 'Store Config'); + String get userManagement => _pick('用户管理', 'User Management'); + String get companyIntro => _pick('公司介绍', 'Company Intro'); + + // ============ 设备管理 ============ + String get deviceList => _pick('设备列表', 'Device List'); + String get deviceDetails => _pick('设备详情', 'Device Details'); + String get searchDeviceHint => _pick('搜索设备MAC / 房号', 'Search MAC / room number'); + String get searchModelHint => _pick('型号', 'Model'); + String get scanQueryDevice => _pick('扫码查询设备', 'Scan to Query'); + String get scanActivateDevice => _pick('扫码激活新设备', 'Scan to Activate'); + String get scanBindDevice => _pick('扫码绑定设备', 'Scan to Bind'); + String get scanTitleActivate => _pick('扫码激活新设备', 'Scan to Activate Device'); + String get scanTitleBind => _pick('扫码绑定设备', 'Scan to Bind Device'); + String get scanTitleQuery => _pick('扫码查询设备', 'Scan to Query Device'); + String get scanHintActivate => _pick('将新设备二维码放入框内以激活', + 'Place the new device QR code in the frame to activate'); + String get scanHintBind => _pick('将二维码放入框内以绑定设备', + 'Place the QR code in the frame to bind the device'); + String get scanHintQuery => _pick('将设备二维码放入框内以查询', + 'Place the device QR code in the frame to query'); + String get cameraPermissionDenied => _pick('未获得相机权限', 'Camera permission denied'); + String get cameraPermissionDeniedHint => + _pick('请在系统设置中开启后重试', + 'Please enable camera permission in system settings and try again'); + String get cameraInitializing => _pick('正在初始化相机…', 'Initializing camera…'); + String get cameraUnavailable => _pick('相机不可用', 'Camera unavailable'); + String cameraOpenFailed(String code) => + _pick('相机打开失败:$code', 'Failed to open camera: $code'); + String get scanConfirmActivateTitle => _pick('确认激活设备', 'Confirm Device Activation'); + String get scanConfirmBindTitle => _pick('确认绑定', 'Confirm Binding'); + String scanConfirmContent(String storeName, String groupId, String payload) => + _pick( + '将设备绑定到门店「$storeName」' + '${groupId.isNotEmpty ? ' / 分组 $groupId' : ''}?\n\n' + '二维码内容:$payload', + 'Bind device to store "$storeName"' + '${groupId.isNotEmpty ? ' / Group $groupId' : ''}?\n\n' + 'QR code content: $payload', + ); + String get scanActivate => _pick('激活', 'Activate'); + String get scanBind => _pick('绑定', 'Bind'); + String get scanDeviceNotFound => _pick('未找到设备,请确认二维码内容', + 'Device not found, please check the QR code content'); + String get scanPayloadUnrecognized => _pick( + '无法识别该二维码,请扫描设备二维码或确认二维码内容', + 'Unrecognized QR code. Please scan a device QR code'); + String scanDeviceNotReady(String identifier) => _pick( + '未找到对应设备($identifier)。设备可能尚未联网或同步,请确认设备已开机联网、已激活并归属当前账号后重试', + 'No matching device found ($identifier). The device may not be online or synced yet. Please confirm it is powered on, connected, activated and assigned to this account, then retry'); + String scanQueryFailed(String error) => + _pick('查询失败:$error', 'Query failed: $error'); + String scanLoadStoresFailed(String error) => + _pick('加载门店失败:$error', 'Failed to load stores: $error'); + String get scanNoStores => _pick('暂无可选门店,请先在门店管理中创建', + 'No stores available. Please create one in Store Management first'); + String get scanSelectStoreTitle => _pick('选择目标门店', 'Select Target Store'); + String get scanActivateSuccess => _pick('设备已激活', 'Device activated'); + String get scanBindSuccess => _pick('设备已绑定', 'Device bound'); + String get scanPayloadNoMac => + _pick('未能从二维码中识别到 MAC 地址,请检查二维码内容', + 'Could not find MAC address in QR code'); + String scanLoadGroupsFailed(String error) => + _pick('加载分组失败:$error', 'Failed to load groups: $error'); + String scanNoGroups(String storeName) => + _pick('门店「$storeName」暂无设备分组,请先在设备分组中创建', + 'Store "$storeName" has no device groups. Please create one first'); + String get scanSelectGroupTitle => _pick('选择目标分组', 'Select Target Group'); + String scanBindFailed(String error) => + _pick('绑定失败:$error', 'Binding failed: $error'); + // ============ 扫码绑定表单(页面内表单) ============ + String get scanGroupLabel => _pick('门店分组', 'Store Group'); + String get scanStoreRequired => _pick('请先选择门店', 'Please select a store first'); + String get scanGroupRequired => _pick('请选择门店分组', 'Please select a store group'); + String get scanRoomRequired => _pick('请输入房号', 'Please enter room number'); + String get scanRoomInvalid => _pick( + '房号格式不正确(仅限中英文、数字、连字符、斜杠,长度 1-20)', + 'Invalid room number (letters/digits/hyphen/slash only, 1-20 chars)'); + // 房号改为选填:标签与提示都标明「选填」,避免用户误以为必填。 + String get roomNoOptional => _pick('房号(选填)', 'Room Number (optional)'); + String get scanRoomHint => _pick('选填,如 801、A101、3楼', 'Optional, e.g. 801, A101, 3rd floor'); + String get scanRescan => _pick('重新扫码', 'Scan Again'); + String get scanFormInvalid => + _pick('请完善门店与门店分组后再提交', 'Please complete store and store group'); + String get scanFormDeviceTitle => _pick('已识别设备', 'Recognized Device'); + String get sendCommand => _pick('下发指令', 'Send Command'); + String get editGroup => _pick('修改分组', 'Edit Group'); + String get macAddress => _pick('MAC 地址', 'MAC Address'); + String get ipAddress => _pick('IP 地址', 'IP Address'); + String get belongStore => _pick('所属门店', 'Store'); + String get belongGroup => _pick('所属分组', 'Group'); + String get roomNo => _pick('房号', 'Room No.'); + String deviceModel(String model) => _pick('型号:$model', 'Model: $model'); + String deviceSubtitleStore(String name) => _pick('门店:$name', 'Store: $name'); + String deviceSubtitleMac(String mac) => _pick('MAC:$mac', 'MAC: $mac'); + String deviceSubtitleRoom(String room) => _pick('房号:$room', 'Room: $room'); + String get deviceSubtitleNotSet => _pick('未设置', 'Not set'); + String get location => _pick('位置', 'Location'); + String get operatingSystem => _pick('操作系统', 'OS'); + String get uptime => _pick('运行时长', 'Uptime'); + // ============ 设备详情区块 ============ + String get basicInfo => _pick('基本信息', 'Basic Info'); + String get configParams => _pick('配置参数', 'Config Params'); + String get networkInfo => _pick('网络信息', 'Network Info'); + String get runtimeData => _pick('运行时数据', 'Runtime Data'); + String get deviceName => _pick('设备名称', 'Device Name'); + String get deviceId => _pick('设备 ID', 'Device ID'); + String get modelLabel => _pick('型号', 'Model'); + String get deviceSnLabel => _pick('设备序列号', 'Device SN'); + String get deviceUidLabel => _pick('设备 UID', 'Device UID'); + String get lastActiveTime => _pick('最后活跃时间', 'Last Active Time'); + String get minutesAgo => _pick('分钟前', 'min ago'); + String get cpuUsage => _pick('CPU 使用率', 'CPU Usage'); + String get memoryUsage => _pick('内存使用率', 'Memory Usage'); + String get diskUsage => _pick('磁盘使用率', 'Disk Usage'); + String get networkRxTx => _pick('网络(收/发)', 'Network (Rx/Tx)'); + String get showRuntimeMetrics => _pick('显示运行指标', 'Show Runtime Metrics'); + String get showRuntimeMetricsHint => + _pick('开启后在设备详情页展示 CPU、内存、磁盘、网络指标', + 'Show CPU, memory, disk and network metrics on device detail'); + String deviceListLoadFailed(String error) => + _pick('设备列表加载失败:$error', 'Failed to load device list: $error'); + String get noMatchedDevices => _pick('没有匹配的设备', 'No matched devices'); + String get allStores => _pick('全部门店', 'All Stores'); + String get selectStore => _pick('选择门店', 'Select Store'); + String get groupId => _pick('所属分组 ID', 'Group ID'); + String get groupIdHint => _pick('填写设备分组标识,如 g-core', + 'Enter device group identifier, e.g. g-core'); + String get saveSuccess => _pick('已保存', 'Saved successfully'); + String get groupListLoading => _pick('加载分组列表…', 'Loading groups…'); + + // ============ 设备状态 ============ + String get statusOnline => _pick('在线', 'Online'); + String get statusOffline => _pick('离线', 'Offline'); + String get statusWarning => _pick('告警', 'Warning'); + String get statusNormal => _pick('正常', 'Normal'); + String get statusDisabled => _pick('禁用', 'Disabled'); + + String deviceStatusLabel(DeviceStatusKey status) { + switch (status) { + case DeviceStatusKey.online: + return statusOnline; + case DeviceStatusKey.offline: + return statusOffline; + case DeviceStatusKey.warning: + return statusWarning; + } + } + + // ============ 指令下发 ============ + String get sendCommandTitle => _pick('下发指令', 'Send Command'); + String get sendOrgCommandTitle => _pick('下发门店指令', 'Send Store Command'); + String get commandType => _pick('指令类型', 'Command Type'); + String get commandConfirmTitle => _pick('确认下发', 'Confirm Send'); + String commandConfirmContent(String label) => + _pick('确定要向该设备下发「$label」指令吗?', + 'Are you sure you want to send the "$label" command?'); + String commandSent(String target, String label) => + _pick('已向$target下发「$label」指令(后端已接收,不代表设备已执行)', + 'Command "$label" sent to $target (accepted by server, not yet confirmed by device)'); + String get commandSendFailed => _pick('指令下发失败', 'Failed to send command'); + String get pleaseInput => _pick('请输入', 'Please enter'); + String get volumeRangeHint => _pick('请输入 0~100 的整数音量', + 'Please enter an integer volume between 0 and 100'); + String get maxVolume => _pick('最大音量', 'Max Volume'); + String get commandOfflineWarning => + _pick('该设备当前离线(或心跳已超时),指令可能不会立即生效;设备重新上报心跳后才会执行。', + 'This device is offline (or its heartbeat timed out). The command may not take effect immediately; it will run after the device reconnects.'); + String get commandOfflineConfirm => _pick('仍然下发', 'Send Anyway'); + String get commandMacMissing => + _pick('该设备缺少 MAC 地址,无法按 MAC 下发指令,请改用门店批量下发。', + 'This device has no MAC address, so a MAC-targeted command cannot be sent. Use store-level delivery instead.'); + + String deviceCommandLabel(String cmd) { + return _pick( + const { + 'powerOff': '关机', + 'reboot': '重启', + 'screenshot': '截屏', + 'themeUpdate': '主题更新', + 'versionUpdate': '版本更新', + 'marqueeUpdate': '滚动字幕更新', + 'weatherUpdate': '更新天气信息', + 'mediaZoneVol': '媒体区域音量', + 'defaultVol': '默认音量', + 'themeClear': '清除主题缓存', + 'cacheClear': '清除缓存', + 'storeExpire': '门店到期', + 'storeDisable': '门店禁用', + 'passwordUpdate': '修改设置密码', + 'worldAd': '获取世界广告', + }[cmd] ?? + cmd, + const { + 'powerOff': 'Power Off', + 'reboot': 'Reboot', + 'screenshot': 'Screenshot', + 'themeUpdate': 'Theme Update', + 'versionUpdate': 'Version Update', + 'marqueeUpdate': 'Marquee Update', + 'weatherUpdate': 'Update Weather', + 'mediaZoneVol': 'Media Zone Volume', + 'defaultVol': 'Default Volume', + 'themeClear': 'Clear Theme Cache', + 'cacheClear': 'Clear Cache', + 'storeExpire': 'Store Expired', + 'storeDisable': 'Store Disabled', + 'passwordUpdate': 'Change Settings Password', + 'worldAd': 'Fetch World Ads', + }[cmd] ?? + cmd, + ); + } + + String deviceCommandInputLabel(String cmd) { + if (cmd == 'passwordUpdate') return _pick('新密码', 'New Password'); + return _pick('音量(0-100)', 'Volume (0-100)'); + } + + // ============ 门店管理 ============ + String get storeManagement => _pick('门店管理', 'Store Management'); + String get storeCommands => _pick('门店指令', 'Store Commands'); + String get searchStoreHint => _pick('搜索门店名称', 'Search store name'); + String get storeDeleteConfirmTitle => _pick('删除门店', 'Delete Store'); + String storeDeleteConfirmContent(String name) => + _pick('确定要删除门店「$name」吗?', 'Are you sure you want to delete store "$name"?'); + String get storeDeleted => _pick('门店已删除', 'Store deleted'); + String get loadFailed => _pick('加载失败', 'Load failed'); + String get storeId => _pick('门店ID', 'Store ID'); + String get address => _pick('地址', 'Address'); + String get contactPhone => _pick('联系电话', 'Contact Phone'); + String get expireTime => _pick('到期时间', 'Expire Time'); + String get expired => _pick('已过期', 'Expired'); + String get noExpireTime => _pick('无到期时间', 'No expire date'); + String get createTime => _pick('创建时间', 'Create Time'); + String get storeName => _pick('门店名称', 'Store Name'); + String get storeNameRequired => _pick('请输入门店名称', 'Please enter store name'); + String get region => _pick('所在地区', 'Region'); + String get regionHint => _pick('请选择省 / 市 / 区', 'Please select province / city / district'); + String get addressDetail => _pick('详细地址', 'Detailed Address'); + String get addressDetailHint => _pick('街道 / 门牌号等', 'Street / house number'); + String get interfaceConfig => _pick('界面配置', 'Interface Config'); + String get themeSkin => _pick('主题皮肤', 'Theme Skin'); + String get themeCurrent => _pick('当前', 'Current'); + String get themeUnnamed => _pick('未命名主题', 'Unnamed Theme'); + String get noInterfaceConfig => _pick('当前门店暂无设备分组主题', + 'No device group themes for this store'); + String get editStore => _pick('编辑门店', 'Edit Store'); + String get addStore => _pick('新增门店', 'Add Store'); + String get create => _pick('创建', 'Create'); + String get sampleStore => _pick('示例门店', 'Sample Store'); + String get parentOrg => _pick('所属组织', 'Parent Organization'); + String get parentOrgRequired => _pick('请选择所属组织', 'Please select parent organization'); + String get regionRequired => _pick('请选择所在地区', 'Please select region'); + String get storeUpdatedPlaceholder => _pick('门店已更新(本地占位)', + 'Store updated (local placeholder)'); + String get storeCreatedPlaceholder => _pick('门店已创建(本地占位)', + 'Store created (local placeholder)'); + + // ============ 激活码管理 ============ + String get activationCodeManagement => _pick('激活码管理', 'Activation Code Management'); + String get noStoreAvailable => _pick('暂无可选门店', 'No stores available'); + String get pleaseSelectStoreFirst => _pick('请先选择门店', 'Please select a store first'); + String get currentActivationCode => _pick('当前激活码', 'Current Activation Code'); + String get noActivationCode => _pick('暂无激活码信息', 'No activation code info'); + String get generateActivationCode => _pick('生成激活码', 'Generate Activation Code'); + String get resetActivationCode => _pick('重置激活码', 'Reset Activation Code'); + String get resetActivationCodeConfirm => _pick('重置后原激活码将失效,确定要继续吗?', + 'The original code will become invalid after reset. Continue?'); + String get activationCodeGenerated => _pick('激活码已生成', 'Activation code generated'); + String get activationCodeReset => _pick('激活码已重置', 'Activation code reset'); + String get generateFailed => _pick('生成失败', 'Generate failed'); + String get resetFailed => _pick('重置失败', 'Reset failed'); + String get activationCodeResetSuccess => + _pick('激活码已重置', 'Activation code reset'); + String activationCodeResetFailed(String error) => + _pick('激活码重置失败:$error', 'Activation code reset failed: $error'); + String get maxActivateCount => _pick('最大激活数', 'Max Activations'); + String get activatedCount => _pick('已激活数', 'Activated Count'); + String get copy => _pick('复制', 'Copy'); + String get generate => _pick('生成', 'Generate'); + String get activationCodeCopied => _pick('激活码已复制', 'Activation code copied'); + String get generateActivationCodeHint => _pick( + '将为当前门店生成新的激活码,原有激活码将被替换。', + 'A new activation code will be generated for this store, replacing the existing one.'); + String activationCodeLoadFailed(String error) => + _pick('激活码加载失败:$error', 'Failed to load activation code: $error'); + String generateFailedWithError(String error) => + _pick('生成失败:$error', 'Generate failed: $error'); + String get activationCodeNoCodeHint => _pick('无激活码,请点击「生成激活码」', + 'No activation code, please tap "Generate Activation Code"'); + String get maxActivationCountLabel => _pick('最大激活次数', 'Max Activation Count'); + String get maxActivationCountHint => _pick('该激活码最多可激活的设备数量', + 'Maximum number of devices this code can activate'); + String get positiveIntegerRequired => _pick('请输入大于 0 的整数', + 'Please enter an integer greater than 0'); + + // 门店表单中的激活码/终端数 + String get terminalCountLabel => _pick('终端数', 'Terminal Count'); + String get terminalCountHint => _pick('该门店允许激活的终端数量', 'Number of terminals allowed'); + String get maxTerminalCount => _pick('最大终端数', 'Max Terminals'); + String get usedTerminalCount => _pick('已用终端数', 'Used Terminals'); + String get createActivationCode => _pick('创建激活码', 'Create Activation Code'); + String get activationCodeWillAutoGenerate => _pick('保存门店后将自动生成激活码', + 'Activation code will be auto-generated after saving the store'); + String get activeActivationCode => _pick('当前激活码', 'Current Activation Code'); + String get noActiveActivationCode => _pick('暂无正在使用的激活码', + 'No activation code currently in use'); + String get activationCodeGenerateHint => _pick('每次点击仅生成一个对应激活码', + 'Each tap generates one activation code'); + String storeSavedActivationFailed(String error) => + _pick('门店已保存,但激活码生成失败:$error', + 'Store saved, but activation code generation failed: $error'); + String storeListLoadFailed(String error) => + _pick('门店列表加载失败:$error', 'Failed to load store list: $error'); + + // ============ 用户管理 ============ + String get username => _pick('用户名', 'Username'); + String get usernameRequired => _pick('请输入用户名', 'Please enter username'); + String get email => _pick('邮箱', 'Email'); + String get mobile => _pick('手机号', 'Mobile'); + String get password => _pick('密码', 'Password'); + String get passwordEditHint => _pick('不修改请留空', 'Leave blank to keep unchanged'); + String get passwordRequired => _pick('请输入密码', 'Please enter password'); + String get confirmPassword => _pick('确认密码', 'Confirm Password'); + String get confirmPasswordHint => _pick('请再次输入密码', 'Please enter the password again'); + String get confirmPasswordRequired => _pick('请输入确认密码', 'Please enter confirm password'); + String get role => _pick('角色', 'Role'); + String get roleRequired => _pick('请选择角色', 'Please select a role'); + String get org => _pick('所属组织', 'Organization'); + String get selectOrg => _pick('请选择所属组织', 'Please select an organization'); + String get noOrgHint => _pick('暂无可选组织,保留原归属', + 'No organizations available, keep original'); + String get status => _pick('状态', 'Status'); + String get unassigned => _pick('未分配', 'Unassigned'); + String get searchUsernameHint => _pick('搜索用户名', 'Search username'); + String get searchOrgHint => _pick('搜索组织名称', 'Search organization name'); + String get noMatchingOrg => _pick('未找到匹配组织', 'No matching organization'); + String get roleLabel => _pick('角色', 'Role'); + String get orgLabel => _pick('所属组织', 'Organization'); + String get currentAccountInfo => _pick('当前账号信息', 'Current Account Info'); + String get passwordNotSaved => _pick('未保存', 'Not saved'); + String get noSubordinateAccount => + _pick('当前账号暂无下级账号', 'No subordinate accounts'); + String get showPassword => _pick('显示密码', 'Show password'); + String get hidePassword => _pick('隐藏密码', 'Hide password'); + String get editUser => _pick('编辑用户', 'Edit User'); + String get addUser => _pick('新增用户', 'Add User'); + String get deleteUserConfirmTitle => _pick('确认删除', 'Confirm Delete'); + String deleteUserConfirmContent(String name) => + _pick('确定要删除用户「$name」吗?', 'Are you sure you want to delete user "$name"?'); + String get rolesLoadFailed => _pick('角色列表加载失败', 'Failed to load roles'); + + // ============ 滚动字幕管理 ============ + String get marqueeManagement => _pick('滚动字幕管理', 'Scrolling Subtitle Management'); + String get addMarquee => _pick('新增滚动字幕', 'Add Scrolling Subtitle'); + String get editMarquee => _pick('编辑滚动字幕', 'Edit Scrolling Subtitle'); + String get marqueeContent => _pick('字幕内容', 'Subtitle Content'); + String get marqueeContentRequired => _pick('请输入字幕内容', 'Please enter subtitle content'); + String get marqueeSpeed => _pick('滚动速度', 'Scroll Speed'); + String get marqueeSpeedHint => _pick('像素/秒,建议 30~120', 'pixels/sec, recommended 30-120'); + String get marqueeSpeedInvalid => _pick('请选择滚动速度', 'Please select scroll speed'); + String get marqueeFontSizeInvalid => _pick('请输入大于 0 的字体大小', 'Please enter a font size greater than 0'); + String get marqueeSpeedSlow => _pick('慢', 'Slow'); + String get marqueeSpeedSlower => _pick('较慢', 'Slower'); + String get marqueeSpeedMedium => _pick('中', 'Medium'); + String get marqueeSpeedFaster => _pick('较快', 'Faster'); + String get marqueeSpeedFast => _pick('快', 'Fast'); + String get marqueeGradeDanger => _pick('危险', 'Danger'); + String get marqueeGradeUrgent => _pick('紧急', 'Urgent'); + String get marqueeGradeNormal => _pick('普通', 'Normal'); + String get targetStore => _pick('目标门店', 'Target Store'); + String get filterByStore => _pick('按门店筛选', 'Filter by Store'); + String get searchMarqueeHint => _pick('搜索字幕内容或门店', 'Search subtitle content or store'); + String get noMarqueeData => _pick('暂无滚动字幕', 'No scrolling subtitles'); + String get marqueeStatus => _pick('状态', 'Status'); + String get marqueeStatusEnabled => _pick('启用', 'Enabled'); + String get marqueeStatusDisabled => _pick('禁用', 'Disabled'); + String get marqueeStatusPending => _pick('待下发', 'Pending'); + String get marqueeStatusDispatched => _pick('已下发', 'Dispatched'); + String get dispatch => _pick('下发', 'Dispatch'); + String get cancelDispatch => _pick('取消下发', 'Cancel Dispatch'); + String get marqueeDispatched => _pick('字幕已下发', 'Subtitle dispatched'); + String get marqueeDispatchFailed => _pick('字幕下发失败', 'Failed to dispatch subtitle'); + String get marqueeDispatchCancelled => _pick('已取消下发', 'Dispatch cancelled'); + String get marqueeCancelFailed => _pick('取消下发失败', 'Failed to cancel dispatch'); + String get marqueeFontColor => _pick('字体颜色', 'Font Color'); + String get marqueeBackgroundColor => _pick('背景颜色', 'Background Color'); + String get marqueeFontSize => _pick('字体大小', 'Font Size'); + String get marqueeLevel => _pick('级别', 'Level'); + String get marqueeStartTime => _pick('开始时间', 'Start Time'); + String get marqueeEndTime => _pick('结束时间', 'End Time'); + String get marqueeHour => _pick('时', 'Hour'); + String get marqueeMinute => _pick('分', 'Minute'); + String get marqueeSecond => _pick('秒', 'Second'); + String get marqueeContentPreview => _pick('字幕内容', 'Subtitle Content'); + + // ============ 公司介绍 ============ + String get introCategoryTitle => _pick('公司介绍', 'Company Intro'); + String get introStoreFilter => _pick('门店名称', 'Store Name'); + String get introAllStores => _pick('全部门店', 'All Stores'); + String get introCategoryName => _pick('名称', 'Name'); + String get introCategoryTypeCode => _pick('类型编码', 'Type Code'); + String get introCategoryEdit => _pick('编辑大类', 'Edit Category'); + String get introCategoryAdd => _pick('新增大类', 'Add Category'); + String get introSubcategoryEdit => _pick('编辑小类', 'Edit Subcategory'); + String get introSubcategoryAdd => _pick('新增小类', 'Add Subcategory'); + String get introDetailEdit => _pick('编辑内容', 'Edit Content'); + String get introDetailAdd => _pick('新增内容', 'Add Content'); + String get introTitle => _pick('标题', 'Title'); + String get introTitleRequired => _pick('请输入标题', 'Please enter the title'); + String get introDescription => _pick('描述', 'Description'); + String get introMediaType => _pick('媒体类型', 'Media Type'); + String get introMediaUrl => _pick('媒体地址', 'Media URL'); + String get introMediaUrlRequired => + _pick('请上传或填写媒体地址', 'Please upload or enter the media URL'); + String get introImage => _pick('图片', 'Image'); + String get introVideo => _pick('视频', 'Video'); + String get introTypeLabel => _pick('类型', 'Type'); + String get introStoreLabel => _pick('门店', 'Store'); + String get introFilterStores => _pick('筛选门店', 'Filter Stores'); + String get introSelectStoresHint => _pick('可多选门店', 'Select multiple stores'); + String get introCategoryStore => _pick('所属门店', 'Store'); + String get introStoreRequired => _pick('请选择所属门店', 'Please select a store'); + String get introCategoryNameRequired => + _pick('请输入名称', 'Please enter the name'); + String get introCategoryTypeRequired => + _pick('请输入类型编码', 'Please enter the type code'); + String get introCategoryTypeNoChinese => + _pick('类型编码禁止包含中文', 'Type code must not contain Chinese characters'); + String get introStoreLoadFailed => + _pick('门店列表加载失败,请下拉刷新后重试', 'Failed to load stores, please refresh and retry'); + String get introSortOrder => _pick('排序号', 'Sort Order'); + String get introEnabled => _pick('启用', 'Enabled'); + String get introDisabled => _pick('禁用', 'Disabled'); + String get introUploadImage => _pick('上传图片', 'Upload Image'); + String get introPickImage => _pick('选择图片', 'Pick Image'); + String get introImagePreview => _pick('图片预览', 'Image Preview'); + String get introUploading => _pick('上传中…', 'Uploading…'); + String get introUploadFailed => _pick('图片上传失败', 'Image upload failed'); + String get introInsertImage => _pick('插入图片', 'Insert Image'); + String get introSubImageHint => + _pick('图片不超过 300KB,建议分辨率 1920×1080', 'Image ≤ 300KB, suggested 1920×1080'); + String get introDetailImageHint => _pick('图片不超过 3MB', 'Image ≤ 3MB'); + String get introDetailVideoHint => _pick('视频不超过 30MB', 'Video ≤ 30MB'); + String get introImageTypeInvalid => + _pick('请上传 jpg、png、gif 或 webp 图片', 'Please upload jpg, png, gif or webp image'); + String get introVideoTypeInvalid => + _pick('请上传 mp4、webm、mov 或 m4v 视频', 'Please upload mp4, webm, mov or m4v video'); + String get introFileTooLarge => _pick('文件大小超过限制', 'File exceeds the size limit'); + + // ============ 地区选择器 ============ + String get selectProvince => _pick('选择省份', 'Select Province'); + String get selectCity => _pick('选择城市', 'Select City'); + String get selectDistrict => _pick('选择区县', 'Select District'); + String get selectRegion => _pick('选择地区', 'Select Region'); + String get previousLevel => _pick('上一级', 'Previous'); + + // ============ 运行时长 ============ + String formatUptime(int seconds) { + final duration = Duration(seconds: seconds); + final days = duration.inDays; + final hours = duration.inHours % 24; + final minutes = duration.inMinutes % 60; + + final parts = []; + if (days > 0) parts.add('$days${_pick('天', 'd')}'); + if (hours > 0) parts.add('$hours${_pick('小时', 'h')}'); + if (minutes > 0 || parts.isEmpty) { + parts.add('$minutes${_pick('分', 'm')}'); + } + return parts.join(' '); + } + + // ============ 通用状态/操作(后置兼容) ============ + String get noOrgId => _pick('该门店缺少组织 ID,无法下发指令', + 'This store lacks an organization ID and cannot receive commands'); + + // ============ 通用删除确认 ============ + String get confirmDelete => _pick('确认删除', 'Confirm Delete'); + String deleteConfirmContent(String name) => + _pick('确定要删除「$name」吗?', 'Are you sure you want to delete "$name"?'); + + // ============ 渠道商管理 ============ + String get channelManagement => _pick('渠道商管理', 'Channel Management'); + String get addChannel => _pick('新增渠道', 'Add Channel'); + String get editChannel => _pick('编辑渠道商', 'Edit Channel'); + String get addSubChannel => _pick('新增子级', 'Add Child'); + String get channelName => _pick('名称', 'Name'); + String get channelType => _pick('类型', 'Type'); + String get channelSort => _pick('排序', 'Sort'); + String get channelStatus => _pick('状态', 'Status'); + String get channelContactPhone => _pick('联系电话', 'Contact Phone'); + String get channelAddress => _pick('地址', 'Address'); + String get channelOrgCode => _pick('组织编码', 'Org Code'); + String get channelTypeChannel => _pick('渠道', 'Channel'); + String get channelTypeAgent => _pick('代理', 'Agent'); + String get channelEnabled => _pick('启用', 'Enabled'); + String get channelDisabled => _pick('禁用', 'Disabled'); + String get channelParent => _pick('上级', 'Parent'); + String get channelNameRequired => _pick('请输入名称', 'Please enter a name'); + String get searchChannelHint => _pick('搜索渠道/代理商名称', 'Search channel/agent name'); + String get channelDetail => _pick('渠道详情', 'Channel Detail'); + String get channelExpireTime => _pick('到期时间', 'Expire Time'); + String get channelCannotDeleteWithChildren => + _pick('存在子集时不允许删除', 'Cannot delete a node that has children'); + String get channelFilterStatus => _pick('状态', 'Status'); + String get channelSwipeToDelete => _pick('左滑删除', 'Swipe left to delete'); + + // ============ 渠道版本管理 ============ + /// 渠道/代理节点下钻的 App 版本管理入口。 + String get channelVersionManage => _pick('版本管理', 'Version'); + String get versionName => _pick('版本名', 'Version Name'); + String get versionCode => _pick('版本号', 'Version Code'); + String get downloadUrlHint => _pick('下载地址', 'Download URL'); + String get forceUpdate => _pick('强制更新', 'Force Update'); + String get showPopup => _pick('弹窗提示', 'Popup'); + String get forceUpdateYes => _pick('是', 'Yes'); + String get forceUpdateNo => _pick('否', 'No'); + String get addVersion => _pick('新增版本', 'Add Version'); + String get editVersion => _pick('编辑版本', 'Edit Version'); + String get versionNameRequired => _pick('请输入版本名', 'Please enter a version name'); + String get versionCodeRequired => + _pick('请输入正整数版本号', 'Please enter a positive integer version code'); + String get versionUrlRequired => _pick('请输入下载地址', 'Please enter a download URL'); + String get versionUrlInvalid => + _pick('请输入以 http(s) 开头的下载地址', 'URL must start with http(s)'); + String get versionDeleteConfirm => _pick( + '确定删除该版本配置?删除后可重新新增。', + 'Delete this version config? You can add it again afterward.'); + String get versionDeleted => _pick('版本已删除', 'Version deleted'); + String get uploadFile => _pick('上传文件', 'Upload'); + String get uploading => _pick('上传中…', 'Uploading…'); + String get uploadSuccess => _pick('文件上传成功', 'Uploaded'); + String get uploadFailed => _pick('文件上传失败', 'Upload failed'); + String get pleaseSelectApk => _pick('请选择 .apk 文件', 'Please select an .apk file'); + + // ============ 版本更新 ============ + String get checkUpdate => _pick('检查更新', 'Check Update'); + String get updateAvailable => _pick('发现新版本', 'Update Available'); + String get currentVersion => _pick('当前版本', 'Current'); + String get latestVersion => _pick('最新版本', 'Latest'); + String get downloadUrl => _pick('下载地址', 'Download'); + String get updateNow => _pick('立即更新', 'Update Now'); + String get updateLater => _pick('稍后', 'Later'); + String get updateNoNew => _pick('已是最新版本', 'Already up to date'); + String get updateOpenFailed => _pick('无法打开下载地址', 'Failed to open download link'); +} + +/// 设备状态键(避免在模型层引入 BuildContext)。 +enum DeviceStatusKey { online, offline, warning } diff --git a/lib/utils/permissions.dart b/lib/utils/permissions.dart new file mode 100644 index 0000000..3e81365 --- /dev/null +++ b/lib/utils/permissions.dart @@ -0,0 +1,69 @@ +// 账号类别与功能模块权限判定。 +// +// 作为菜单可见性与路由可达性的**单一来源**:AdminHubPage 据此决定模块是否 +// 显示在界面,router.dart 的 redirect 据此决定是否拦截深链/返回键,二者保持 +// 完全一致,避免「界面看不到却能路由进入」或「点进去被弹回」的不一致。 +// +// 判定依据:登录用户 [User] 的 [User.superAdmin] / [User.orgId] / [User.orgType]。 +// - superAdmin=true 或 orgId 为空 → 管理员(可见全部模块); +// - orgType=1(渠道)→ 渠道商;orgType=2(代理商)→ 代理商; +// - 受限但类型未知 → 按渠道商兜底,确保「渠道商管理」对渠道商账号可开放 +// (fail-open,符合「渠道商账号点击渠道商管理应正常打开」的修复目标)。 +// +// 模块可见性规则: +// - 用户管理:所有已登录账号可见(管理员/渠道商/代理商各看自己范围内用户); +// - 渠道商管理:管理员 + 渠道商可见,代理商不可见; +// - 其余模块(设备/激活码/公司介绍/滚动字幕):所有账号可见。 +import '../models/user.dart'; + +enum AccountCategory { + /// 管理员(超级管理员 / 无组织归属)。 + admin, + + /// 渠道商(orgType=1)。 + channelMerchant, + + /// 代理商(orgType=2)。 + agent, + + /// 未知(理论上不会在 UI 中出现,作为兜底占位)。 + unknown, +} + +/// 功能模块权限判定工具。 +class SessionPermissions { + SessionPermissions._(); + + /// 依据当前登录用户推导账号类别。 + static AccountCategory category(User? user) { + if (user == null) return AccountCategory.unknown; + if (user.superAdmin) return AccountCategory.admin; + if (user.orgId.isEmpty) return AccountCategory.admin; + switch (user.orgType) { + case 0: + return AccountCategory.admin; // 平台 + case 1: + return AccountCategory.channelMerchant; // 渠道 + case 2: + return AccountCategory.agent; // 代理商 + default: + // 受限但类型未知:按渠道商兜底,开放「渠道商管理」。 + return AccountCategory.channelMerchant; + } + } + + /// 是否管理员(可访问系统管理 / 用户管理等全部模块)。 + static bool isAdmin(User? user) => category(user) == AccountCategory.admin; + + /// 是否可见 / 可访问「用户管理」(所有已登录账号)。 + /// + /// 管理员看全量,渠道商/代理商看自己组织范围内的用户;具体数据范围由 + /// [UserPage] 调用后端时按会话权限收敛,不再在菜单层一刀切隐藏。 + static bool canViewUserManagement(User? user) => user != null; + + /// 是否可见 / 可访问「渠道商管理」(管理员 + 渠道商;代理商不可)。 + static bool canViewChannelManagement(User? user) { + final c = category(user); + return c == AccountCategory.admin || c == AccountCategory.channelMerchant; + } +} diff --git a/lib/utils/responsive.dart b/lib/utils/responsive.dart new file mode 100644 index 0000000..01abad5 --- /dev/null +++ b/lib/utils/responsive.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +/// 响应式断点工具:用 [Size.shortestSide] 而非 width 判定窗口尺寸, +/// 使手机横屏(1600×900 → shortSide=900)正确落入 medium 档。 +/// +/// 详见 docs/手机端UI优化方案-v2.md §7.4。 +enum AppWindowSize { compact, medium, expanded } + +class AppResponsive { + AppResponsive(this.size, this.orientation); + + final Size size; + final Orientation orientation; + + factory AppResponsive.of(BuildContext context) => AppResponsive( + MediaQuery.sizeOf(context), + MediaQuery.orientationOf(context), + ); + + bool get isLandscape => orientation == Orientation.landscape; + + AppWindowSize get windowSize => switch (size.shortestSide) { + < 600 => AppWindowSize.compact, + < 840 => AppWindowSize.medium, // ★ 手机横屏 900 落这里 + _ => AppWindowSize.expanded, + }; + + /// 横屏且宽度够时启用主从(双栏)布局。 + bool get useMasterDetail => size.width >= 840 && isLandscape; + + /// 自适应网格列数(列表 / 卡片网格)。 + int gridCount({double minTileWidth = 320}) => + (size.width / minTileWidth).floor().clamp(2, 6); + + /// 内容最大宽度(限宽居中,避免横屏拉爆)。 + double get contentMaxWidth => switch (windowSize) { + AppWindowSize.compact => double.infinity, + AppWindowSize.medium => 560, + AppWindowSize.expanded => 720, + }; + + /// 栅格间距。 + double get gutter => switch (windowSize) { + AppWindowSize.compact => 12, + AppWindowSize.medium => 24, + _ => 32, + }; + + /// 卡片内边距(横屏更宽松)。 + double get cardPadding => windowSize == AppWindowSize.compact ? 16 : 20; +} + +extension AppResponsiveX on BuildContext { + AppResponsive get responsive => AppResponsive.of(this); +} diff --git a/lib/utils/session_org_scope.dart b/lib/utils/session_org_scope.dart new file mode 100644 index 0000000..1a90e6e --- /dev/null +++ b/lib/utils/session_org_scope.dart @@ -0,0 +1,37 @@ +import 'package:flutter/foundation.dart'; + +/// 当前登录用户的「数据权限范围」——其所属组织子树的根节点 ID。 +/// +/// 用途:在客户端对组织树 / 门店列表做二次收敛,确保非管理员用户只能看到 +/// 自己所属组织及下属门店的数据,防止后端 `/sys/org/tree` 返回全量树时被 +/// 越权展示其他组织的数据。 +/// +/// 由 [AuthNotifier] 在登录成功与会话恢复时写入 [setCurrentOrg],登出 / +/// 会话失效时 [clear]。读取方([OrgService])仅在 [enabled] 为 true 时过滤, +/// [currentOrgId] 为空(管理员 / 无法识别)时不做任何裁剪,行为与后端一致。 +class SessionOrgScope { + SessionOrgScope._(); + static final SessionOrgScope instance = SessionOrgScope._(); + + /// 当前用户所属组织 ID。空字符串表示「未限定」(管理员或无法识别), + /// 此时客户端不过滤,完全信任后端返回。 + String currentOrgId = ''; + + /// 是否启用客户端数据权限收敛。 + bool get enabled => currentOrgId.isNotEmpty; + + /// 写入当前用户组织范围。 + void setCurrentOrg(String? orgId) { + currentOrgId = orgId ?? ''; + if (kDebugMode) { + debugPrint( + '[SessionOrgScope] currentOrgId=${currentOrgId.isEmpty ? "(未限定/管理员)" : currentOrgId}', + ); + } + } + + /// 清空(登出 / 会话失效)。 + void clear() { + currentOrgId = ''; + } +} diff --git a/lib/utils/store_local_cache.dart b/lib/utils/store_local_cache.dart new file mode 100644 index 0000000..3eade0f --- /dev/null +++ b/lib/utils/store_local_cache.dart @@ -0,0 +1,139 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +/// 门店扩展字段本地持久化(SharedPreferences 封装)。 +/// +/// 用于「前端先占位」阶段:主题、品牌(启动图/Logo)、省市区归属等 +/// 全部只写本地缓存,不发起真实请求。key 以 `orgId` 隔离,切换组织后互不影响。 +/// +/// TODO(后端): 上述字段后续由 [OrgService.setStoreTheme] / +/// [OrgService.setStoreBranding] 对接 POST /sys/store/* 覆盖,本地缓存可作为离线兜底。 +class StoreLocalCache { + StoreLocalCache._(); + + /// 全局单例。 + static final StoreLocalCache instance = StoreLocalCache._(); + + SharedPreferences? _prefs; + + /// 懒初始化 SharedPreferences(仅在首次访问时调用)。 + Future get _instance async => + _prefs ??= await SharedPreferences.getInstance(); + + /// 外部预初始化(在 [main] 中 await,使同步读取可用)。 + Future initialize() async { + _prefs ??= await SharedPreferences.getInstance(); + } + + /// 主题 key:`store_theme_$orgId`。 + String _themeKey(String orgId) => 'store_theme_$orgId'; + + /// 品牌 key:`store_branding_$orgId`。 + String _brandingKey(String orgId) => 'store_branding_$orgId'; + + /// 省市区归属 key:`store_region_$orgId`。 + String _regionKey(String orgId) => 'store_region_$orgId'; + + /// 设备绑定关系 key:`device_binding_$deviceId`。 + String _bindingKey(String deviceId) => 'device_binding_$deviceId'; + + /// 保存门店主题。 + Future saveTheme(String orgId, String themeId) async { + final prefs = await _instance; + await prefs.setString(_themeKey(orgId), themeId); + } + + /// 读取门店主题 id;未设置返回 null。 + Future getTheme(String orgId) async { + final prefs = await _instance; + return prefs.getString(_themeKey(orgId)); + } + + /// 保存门店品牌(启动图 + Logo 本地路径或远程 URL)。 + Future saveBranding( + String orgId, + String splashUrl, + String logoUrl, + ) async { + final prefs = await _instance; + await prefs.setStringList( + _brandingKey(orgId), + [splashUrl, logoUrl], + ); + } + + /// 读取门店品牌;返回 `[splashUrl, logoUrl]`,未设置时元素为空字符串。 + Future> getBranding(String orgId) async { + final prefs = await _instance; + final raw = prefs.getStringList(_brandingKey(orgId)); + if (raw == null || raw.length < 2) { + return const ['', '']; + } + return raw; + } + + /// 保存门店省市区归属与街道详情。 + Future saveRegion( + String orgId, + String provinceCode, + String cityCode, + String districtCode, + String addressDetail, + ) async { + final prefs = await _instance; + await prefs.setStringList( + _regionKey(orgId), + [provinceCode, cityCode, districtCode, addressDetail], + ); + } + + /// 读取门店省市区归属;返回 `[provinceCode, cityCode, districtCode, addressDetail]` + /// 未设置时元素为空字符串。 + Future> getRegion(String orgId) async { + final prefs = await _instance; + final raw = prefs.getStringList(_regionKey(orgId)); + if (raw == null || raw.length < 4) { + return const ['', '', '', '']; + } + return raw; + } + + /// 保存设备绑定关系(扫码绑定占位)。 + Future saveDeviceBinding( + String deviceId, + String storeId, + String storeName, + String? groupId, + ) async { + final prefs = await _instance; + await prefs.setStringList( + _bindingKey(deviceId), + [storeId, storeName, groupId ?? ''], + ); + } + + /// 读取设备绑定关系;返回 `[storeId, storeName, groupId]`, + /// 未设置时元素为空字符串。 + Future> getDeviceBinding(String deviceId) async { + final prefs = await _instance; + final raw = prefs.getStringList(_bindingKey(deviceId)); + if (raw == null || raw.length < 3) { + return const ['', '', '']; + } + return raw; + } + + // ================ 通用 String 存取(用于语言、主题等非门店隔离配置) ================ + + /// 保存字符串。 + Future setString(String key, String value) async { + final prefs = await _instance; + await prefs.setString(key, value); + } + + /// 读取字符串;不存在返回 null。 + String? getString(String key) { + final prefs = _prefs; + if (prefs == null) return null; + return prefs.getString(key); + } +} diff --git a/lib/utils/uuid.dart b/lib/utils/uuid.dart new file mode 100644 index 0000000..b99bdb4 --- /dev/null +++ b/lib/utils/uuid.dart @@ -0,0 +1,17 @@ +import 'dart:math'; + +/// 生成与 web 前端一致的 UUID v4 字符串。 +/// +/// 模板:`xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx` +String generateUuid() { + final random = Random.secure(); + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replaceAllMapped( + RegExp('[xy]'), + (match) { + final r = random.nextInt(16); + final c = match.group(0)!; + final v = c == 'x' ? r : (r & 0x3) | 0x8; + return v.toRadixString(16); + }, + ); +} diff --git a/lib/widgets/app_bottom_nav.dart b/lib/widgets/app_bottom_nav.dart new file mode 100644 index 0000000..85021ee --- /dev/null +++ b/lib/widgets/app_bottom_nav.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../utils/i10n.dart'; +import '../utils/responsive.dart'; + +/// 底部导航栏(配合 [StatefulShellRoute] 使用)。 +/// +/// 五个 tab:首页、设备、门店、管理、我的。 +class AppBottomNav extends StatelessWidget { + const AppBottomNav({super.key, required this.shell}); + + /// go_router 的导航壳。 + final StatefulNavigationShell shell; + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + + final railDestinations = [ + NavigationRailDestination( + icon: const Icon(Icons.home_outlined), + selectedIcon: const Icon(Icons.home), + label: Text(t.home), + ), + NavigationRailDestination( + icon: const Icon(Icons.devices_outlined), + selectedIcon: const Icon(Icons.devices), + label: Text(t.devices), + ), + NavigationRailDestination( + icon: const Icon(Icons.store_outlined), + selectedIcon: const Icon(Icons.store), + label: Text(t.stores), + ), + NavigationRailDestination( + icon: const Icon(Icons.admin_panel_settings_outlined), + selectedIcon: const Icon(Icons.admin_panel_settings), + label: Text(t.admin), + ), + NavigationRailDestination( + icon: const Icon(Icons.person_outline), + selectedIcon: const Icon(Icons.person), + label: Text(t.profile), + ), + ]; + + void onSelect(int index) => shell.goBranch( + index, + // 点击当前 tab 时回到该分支根路由。 + initialLocation: index == shell.currentIndex, + ); + + // 横屏:改用左侧 NavigationRail,腾出底部 80dp 纵向空间。 + if (context.responsive.isLandscape) { + return Scaffold( + body: Row( + children: [ + NavigationRail( + selectedIndex: shell.currentIndex, + onDestinationSelected: onSelect, + labelType: NavigationRailLabelType.all, + destinations: railDestinations, + ), + const VerticalDivider(width: 1, thickness: 1), + Expanded(child: shell), + ], + ), + ); + } + + return Scaffold( + body: shell, + bottomNavigationBar: NavigationBar( + selectedIndex: shell.currentIndex, + onDestinationSelected: onSelect, + destinations: [ + NavigationDestination( + icon: const Icon(Icons.home_outlined), + selectedIcon: const Icon(Icons.home), + label: t.home, + ), + NavigationDestination( + icon: const Icon(Icons.devices_outlined), + selectedIcon: const Icon(Icons.devices), + label: t.devices, + ), + NavigationDestination( + icon: const Icon(Icons.store_outlined), + selectedIcon: const Icon(Icons.store), + label: t.stores, + ), + NavigationDestination( + icon: const Icon(Icons.admin_panel_settings_outlined), + selectedIcon: const Icon(Icons.admin_panel_settings), + label: t.admin, + ), + NavigationDestination( + icon: const Icon(Icons.person_outline), + selectedIcon: const Icon(Icons.person), + label: t.profile, + ), + ], + ), + ); + } +} diff --git a/lib/widgets/app_bottom_sheet.dart b/lib/widgets/app_bottom_sheet.dart new file mode 100644 index 0000000..33fa588 --- /dev/null +++ b/lib/widgets/app_bottom_sheet.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; + +import '../app/theme.dart'; + +/// 统一底部弹窗封装:一次性解决「无把手 / 无安全区 / 无限高 / 保存按钮埋在滚动区」。 +/// +/// - 顶部拖拽把手([BottomSheetThemeData.showDragHandle] 已全局兜底,这里显式再设一次)。 +/// - 安全区(不被手势条遮挡)。 +/// - 限宽 640 居中(横屏不铺满)。 +/// - [footer] 吸底操作栏始终可见,[body] 内部独立滚动。 +/// +/// 详见 docs/手机端UI优化方案-v2.md §8.4。 +Future showAppBottomSheet({ + required BuildContext context, + required Widget body, + Widget? footer, + String? title, + double heightFactor = 0.92, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + useSafeArea: true, + constraints: BoxConstraints( + maxWidth: 640, + maxHeight: MediaQuery.sizeOf(context).height * heightFactor, + ), + builder: (ctx) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (title != null) + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + title, + style: Theme.of(ctx) + .textTheme + .titleLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + ), + ), + Flexible( + child: SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + 20, + 0, + 20, + 20 + MediaQuery.viewInsetsOf(ctx).bottom, + ), + child: body, + ), + ), + if (footer != null) + Container( + decoration: BoxDecoration( + color: Theme.of(ctx).colorScheme.surface, + border: Border( + top: BorderSide( + color: Theme.of(ctx).colorScheme.outlineVariant, + ), + ), + boxShadow: AppShadows.lg, + ), + padding: const EdgeInsets.fromLTRB(20, 12, 20, 12), + // 吸底栏不受滚动影响,但底部手势条区域交给 SafeArea 处理。 + child: SafeArea(top: false, child: footer), + ), + ], + ), + ); +} diff --git a/lib/widgets/branding_editor.dart b/lib/widgets/branding_editor.dart new file mode 100644 index 0000000..1ccb6d7 --- /dev/null +++ b/lib/widgets/branding_editor.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; + +/// 品牌配置编辑(启动图 + Logo)。 +/// +/// 通过 [ImagePicker] 选择本地图片,并用「手机外框」预览: +/// - 启动图为全屏背景; +/// - Logo 为右下角角标。 +/// +/// [splashUrl]/[logoUrl] 为本地图片路径(占位),由父级保存。 +class BrandingEditor extends StatefulWidget { + const BrandingEditor({ + super.key, + this.initialSplashUrl, + this.initialLogoUrl, + }); + + final String? initialSplashUrl; + final String? initialLogoUrl; + + @override + State createState() => BrandingEditorState(); +} + +/// 品牌编辑器状态(公开,供父级通过 GlobalKey 读取所选图片路径)。 +class BrandingEditorState extends State { + final ImagePicker _picker = ImagePicker(); + String? _splashUrl; + String? _logoUrl; + + @override + void initState() { + super.initState(); + _splashUrl = widget.initialSplashUrl; + _logoUrl = widget.initialLogoUrl; + } + + Future _pickSplash() async { + final xFile = await _picker.pickImage(source: ImageSource.gallery); + if (xFile != null && mounted) { + setState(() => _splashUrl = xFile.path); + } + } + + Future _pickLogo() async { + final xFile = await _picker.pickImage(source: ImageSource.gallery); + if (xFile != null && mounted) { + setState(() => _logoUrl = xFile.path); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: FilledButton.tonal( + onPressed: _pickSplash, + child: const Text('选择启动图'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton.tonal( + onPressed: _pickLogo, + child: const Text('选择 Logo'), + ), + ), + ], + ), + const SizedBox(height: 12), + // 手机外框预览。 + Center( + child: Container( + width: 200, + height: 380, + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(28), + border: Border.all(color: Colors.black87, width: 8), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + clipBehavior: Clip.antiAlias, + child: Stack( + children: [ + // 启动图全屏背景。 + if (_splashUrl != null && _splashUrl!.isNotEmpty) + Positioned.fill( + child: Image.network( + _splashUrl!, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container( + color: theme.colorScheme.primaryContainer, + ), + ), + ) + else + Positioned.fill( + child: Container( + color: theme.colorScheme.primaryContainer, + child: Center( + child: Text( + '启动图预览', + style: TextStyle( + color: theme.colorScheme.onPrimaryContainer, + ), + ), + ), + ), + ), + // 顶部状态栏占位。 + Positioned( + top: 8, + left: 0, + right: 0, + child: Container( + height: 18, + color: Colors.black.withOpacity(0.25), + ), + ), + // Logo 右下角角标。 + if (_logoUrl != null && _logoUrl!.isNotEmpty) + Positioned( + right: 16, + bottom: 24, + child: Container( + width: 56, + height: 56, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black26, + blurRadius: 6, + offset: Offset(0, 2), + ), + ], + ), + child: ClipOval( + child: Image.network( + _logoUrl!, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => + const Icon(Icons.image, size: 28), + ), + ), + ), + ), + ], + ), + ), + ), + ], + ); + } + + /// 当前启动图本地路径(未选返回 null)。 + String? get splashUrl => _splashUrl; + + /// 当前 Logo 本地路径(未选返回 null)。 + String? get logoUrl => _logoUrl; +} diff --git a/lib/widgets/cloud_card.dart b/lib/widgets/cloud_card.dart new file mode 100644 index 0000000..a4f60b9 --- /dev/null +++ b/lib/widgets/cloud_card.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +import '../app/theme.dart'; + +/// 品牌云卡:橙→金渐变表面 + 深棕文字,用于首页 / 概览的招牌区块。 +/// +/// 自动把内部文字与图标设为 [AppBrand.onCloud](亮橙底上的深棕), +/// 因此卡片内无需再单独指定前景色。 +class CloudCard extends StatelessWidget { + const CloudCard({ + super.key, + this.child, + this.padding = const EdgeInsets.all(20), + this.radius, + }); + + final Widget? child; + final EdgeInsetsGeometry padding; + + /// 圆角覆盖;默认用 [AppRadius.lg]。 + final double? radius; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Container( + decoration: BoxDecoration( + gradient: AppBrand.cloudGradient, + borderRadius: BorderRadius.circular(radius ?? AppRadius.lg), + boxShadow: AppShadows.md, + ), + padding: padding, + foregroundDecoration: scheme.brightness == Brightness.dark + ? BoxDecoration( + borderRadius: BorderRadius.circular(radius ?? AppRadius.lg), + color: Colors.black.withOpacity(0.28), + ) + : null, + child: DefaultTextStyle( + style: const TextStyle(color: AppBrand.onCloud), + child: IconTheme( + data: const IconThemeData(color: AppBrand.onCloud), + child: child ?? const SizedBox.shrink(), + ), + ), + ); + } +} diff --git a/lib/widgets/device_command_sheet.dart b/lib/widgets/device_command_sheet.dart new file mode 100644 index 0000000..d9e5c16 --- /dev/null +++ b/lib/widgets/device_command_sheet.dart @@ -0,0 +1,441 @@ +import 'package:flutter/material.dart'; + +import '../models/device.dart'; +import '../services/api_client.dart'; +import '../services/device_service.dart'; +import '../utils/i10n.dart'; + +/// 指令参数项。 +/// +/// [key] **必须与 Web 后台 `src/api/device.ts` 的 `commandTypeOptions` +/// 参数 key 完全一致**,否则后端取不到值,设备端会因缺少参数而忽略该指令。 +/// 当前约定: +/// - `passwordUpdate` → `passwd` +/// - `mediaZoneVol` → `vol` +/// - `defaultVol` → `vol` + `maxVol` +class _CommandParam { + const _CommandParam(this.key, this.label, {this.numeric = false}); + + /// 提交给后端的参数名。 + final String key; + + /// 输入框标签。 + final String label; + + /// 是否为 0~100 的整数(音量类参数)。 + final bool numeric; +} + +/// 设备指令项。 +class _CommandItem { + const _CommandItem(this.value, this.label, {this.params = const []}); + + final String value; + final String label; + + /// 需要用户填写的参数;为空表示无需输入,直接确认后下发。 + final List<_CommandParam> params; + + bool get needsInput => params.isNotEmpty; +} + +/// 设备指令下发弹窗。 +/// +/// 两种用法: +/// 1. 单设备:传 [device],指令按 MAC 下发([DeviceService.sendCommandByMac]); +/// 2. 门店批量:传 [orgId](不传 [device]),指令按组织下发 +/// ([DeviceService.sendCommandByOrg]),影响该门店下全部设备。 +class DeviceCommandSheet extends StatefulWidget { + const DeviceCommandSheet({ + super.key, + this.device, + this.orgId, + this.orgName, + required this.deviceService, + }) : assert(device != null || orgId != null, + 'device 与 orgId 至少传其一'); + + /// 单设备模式下的目标设备。 + final Device? device; + + /// 门店批量模式下的组织 ID。 + final String? orgId; + + /// 门店批量模式下的组织名称(仅用于展示)。 + final String? orgName; + + final DeviceService deviceService; + + /// 是否为门店批量模式。 + bool get isOrgMode => orgId != null && orgId!.isNotEmpty; + + @override + State createState() => _DeviceCommandSheetState(); +} + +class _DeviceCommandSheetState extends State { + final List _paramControllers = []; + bool _sending = false; + + /// 设备心跳超过该分钟数即视为「可能离线」,下发前给出提示。 + static const int _heartbeatStaleMinutes = 3; + + List<_CommandItem> _commands(BuildContext context) { + final t = AppLocalizations.of(context); + return [ + _CommandItem(DeviceCommand.powerOff, t.deviceCommandLabel(DeviceCommand.powerOff)), + _CommandItem(DeviceCommand.reboot, t.deviceCommandLabel(DeviceCommand.reboot)), + _CommandItem(DeviceCommand.screenshot, t.deviceCommandLabel(DeviceCommand.screenshot)), + _CommandItem(DeviceCommand.themeUpdate, t.deviceCommandLabel(DeviceCommand.themeUpdate)), + _CommandItem(DeviceCommand.versionUpdate, t.deviceCommandLabel(DeviceCommand.versionUpdate)), + _CommandItem(DeviceCommand.marqueeUpdate, t.deviceCommandLabel(DeviceCommand.marqueeUpdate)), + _CommandItem(DeviceCommand.weatherUpdate, t.deviceCommandLabel(DeviceCommand.weatherUpdate)), + _CommandItem( + DeviceCommand.mediaZoneVol, + t.deviceCommandLabel(DeviceCommand.mediaZoneVol), + params: [ + _CommandParam('vol', t.deviceCommandInputLabel(DeviceCommand.mediaZoneVol), + numeric: true), + ], + ), + _CommandItem( + DeviceCommand.defaultVol, + t.deviceCommandLabel(DeviceCommand.defaultVol), + params: [ + _CommandParam('vol', t.deviceCommandInputLabel(DeviceCommand.defaultVol), + numeric: true), + _CommandParam('maxVol', t.maxVolume, numeric: true), + ], + ), + _CommandItem(DeviceCommand.themeClear, t.deviceCommandLabel(DeviceCommand.themeClear)), + _CommandItem(DeviceCommand.cacheClear, t.deviceCommandLabel(DeviceCommand.cacheClear)), + _CommandItem(DeviceCommand.storeExpire, t.deviceCommandLabel(DeviceCommand.storeExpire)), + _CommandItem(DeviceCommand.storeDisable, t.deviceCommandLabel(DeviceCommand.storeDisable)), + _CommandItem( + DeviceCommand.passwordUpdate, + t.deviceCommandLabel(DeviceCommand.passwordUpdate), + params: [ + _CommandParam('passwd', t.deviceCommandInputLabel(DeviceCommand.passwordUpdate)), + ], + ), + _CommandItem(DeviceCommand.worldAd, t.deviceCommandLabel(DeviceCommand.worldAd)), + ]; + } + + /// 设备是否「可能离线」:状态为 offline,或心跳时间早于阈值。 + /// + /// 用于下发前提示,不改变下发行为(仍可强制下发)。 + bool _isLikelyOffline(Device device) { + if (device.status == DeviceStatus.offline) return true; + final heartbeat = device.lastSyncTime; + if (heartbeat == null) return true; + return DateTime.now().difference(heartbeat).inMinutes > _heartbeatStaleMinutes; + } + + Future _send(_CommandItem item) async { + final t = AppLocalizations.of(context); + final data = {}; + if (item.needsInput) { + for (var i = 0; i < item.params.length; i++) { + final param = item.params[i]; + final value = _paramControllers[i].text.trim(); + if (value.isEmpty) { + // maxVol 允许留空(后端对该项有默认值),其余参数必填。 + if (param.key == 'maxVol') continue; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${t.pleaseInput}${param.label}')), + ); + return; + } + if (param.numeric) { + final vol = int.tryParse(value); + if (vol == null || vol < 0 || vol > 100) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.volumeRangeHint)), + ); + return; + } + } + // 与 Web 端 `data[key] = item.value.trim()` 保持一致:统一传字符串, + // 避免后端按 String 解析数字时抛类型转换异常。 + data[param.key] = value; + } + } + + setState(() => _sending = true); + try { + if (widget.isOrgMode) { + await widget.deviceService.sendCommandByOrg( + orgId: widget.orgId!, + command: item.value, + data: data.isNotEmpty ? data : null, + ); + } else { + final mac = widget.device!.mac.trim(); + if (mac.isEmpty) { + if (!mounted) return; + setState(() => _sending = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.commandMacMissing)), + ); + return; + } + await widget.deviceService.sendCommandByMac( + mac: mac, + command: item.value, + data: data.isNotEmpty ? data : null, + ); + } + if (!mounted) return; + Navigator.pop(context); + final target = widget.isOrgMode + ? '${t.belongStore}「${widget.orgName ?? widget.orgId}」' + : '${t.deviceDetails}「${widget.device!.macLabel}」'; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.commandSent(target, item.label))), + ); + } catch (e) { + if (!mounted) return; + setState(() => _sending = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${t.commandSendFailed}:${formatApiError(e)}')), + ); + } + } + + @override + void dispose() { + for (final c in _paramControllers) { + c.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final t = AppLocalizations.of(context); + final commands = _commands(context); + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.isOrgMode ? t.sendOrgCommandTitle : t.sendCommandTitle, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + if (widget.isOrgMode) ...[ + Text( + '${t.belongStore}:${widget.orgName ?? widget.orgId}', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + Text( + '${t.commandType}:${t.allStores}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ] else ...[ + Text( + '${t.deviceDetails}:${widget.device!.macLabel}', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + Text( + '${t.macAddress}:${widget.device!.mac}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + Text( + _buildHeartbeatLine(t), + style: theme.textTheme.bodySmall?.copyWith( + color: widget.device != null && _isLikelyOffline(widget.device!) + ? theme.colorScheme.error + : theme.colorScheme.onSurfaceVariant, + ), + ), + ], + const SizedBox(height: 16), + Text( + t.commandType, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: commands.map((item) { + return ActionChip( + label: Text(item.label), + onPressed: _sending ? null : () => _onCommandTap(item), + ); + }).toList(), + ), + ], + ), + ), + ); + } + + String _buildHeartbeatLine(AppLocalizations t) { + final device = widget.device; + if (device == null) return ''; + final heartbeat = device.lastSyncTime; + if (heartbeat == null) { + return '${t.lastActiveTime}:${t.deviceSubtitleNotSet}'; + } + final minutes = DateTime.now().difference(heartbeat).inMinutes; + return '${t.lastActiveTime}:${_format(heartbeat)}($minutes${t.minutesAgo})'; + } + + String _format(DateTime time) { + String two(int v) => v.toString().padLeft(2, '0'); + return '${time.year}-${two(time.month)}-${two(time.day)} ' + '${two(time.hour)}:${two(time.minute)}'; + } + + void _onCommandTap(_CommandItem item) { + if (!item.needsInput) { + _showConfirm(item); + return; + } + _showInputDialog(item); + } + + /// 下发前校验:单设备模式下若设备离线/心跳超时,先给出警告。 + Future _confirmIfOffline() async { + final device = widget.device; + final t = AppLocalizations.of(context); + if (device == null || !_isLikelyOffline(device)) return true; + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(t.commandConfirmTitle), + content: Text(t.commandOfflineWarning), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(t.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(t.commandOfflineConfirm), + ), + ], + ), + ) ?? + false; + return ok; + } + + void _showConfirm(_CommandItem item) async { + final t = AppLocalizations.of(context); + final proceed = await _confirmIfOffline(); + if (!proceed || !mounted) return; + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(t.commandConfirmTitle), + content: Text(t.commandConfirmContent(item.label)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: Text(t.cancel), + ), + FilledButton( + onPressed: _sending + ? null + : () { + Navigator.pop(ctx); + _send(item); + }, + child: _sending + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(t.confirm), + ), + ], + ), + ); + } + + void _showInputDialog(_CommandItem item) async { + final t = AppLocalizations.of(context); + final proceed = await _confirmIfOffline(); + if (!proceed || !mounted) return; + for (final c in _paramControllers) { + c.dispose(); + } + _paramControllers + ..clear() + ..addAll(List.generate(item.params.length, (_) => TextEditingController())); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(item.label), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: List.generate(item.params.length, (i) { + final param = item.params[i]; + return Padding( + padding: EdgeInsets.only( + bottom: i == item.params.length - 1 ? 0 : 12, + ), + child: TextField( + controller: _paramControllers[i], + decoration: InputDecoration( + labelText: param.label, + border: const OutlineInputBorder(), + ), + keyboardType: + param.numeric ? TextInputType.number : TextInputType.text, + obscureText: param.key == 'passwd', + ), + ); + }), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: Text(t.cancel), + ), + FilledButton( + onPressed: _sending + ? null + : () { + Navigator.pop(ctx); + _send(item); + }, + child: _sending + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(t.sendCommand), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/device_list_tile.dart b/lib/widgets/device_list_tile.dart new file mode 100644 index 0000000..b3fd9f1 --- /dev/null +++ b/lib/widgets/device_list_tile.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; + +import '../models/device.dart'; +import '../services/device_service.dart'; +import '../utils/i10n.dart'; +import 'device_command_sheet.dart'; +import 'status_chip.dart'; + +/// 设备列表项。 +class DeviceListTile extends StatelessWidget { + const DeviceListTile({ + super.key, + required this.device, + this.onTap, + }); + + /// 设备数据。 + final Device device; + + /// 点击回调。 + final VoidCallback? onTap; + + IconData _iconByStatus() { + switch (device.status) { + case DeviceStatus.online: + return Icons.dns; + case DeviceStatus.offline: + return Icons.dns_outlined; + case DeviceStatus.warning: + return Icons.warning_amber; + } + } + + String _title(BuildContext context) { + // 设备标识以 MAC 为主(房号独立显示在副标题,不再用「/」粘连)。 + return device.macLabel; + } + + String _subtitle(BuildContext context) { + // 副标题保持「门店 + 型号」不变,并追加独立的「房号:XXX」字段。 + final t = AppLocalizations.of(context); + final store = device.storeName.isEmpty + ? t.deviceSubtitleStore(t.deviceSubtitleNotSet) + : t.deviceSubtitleStore(device.storeName); + final model = '${t.modelLabel}:${device.model.isNotEmpty ? device.model : t.deviceSubtitleNotSet}'; + final room = '${t.roomNo}:${device.roomNo.isNotEmpty ? device.roomNo : t.deviceSubtitleNotSet}'; + return '$store $model $room'; + } + + void _showCommandSheet(BuildContext context) { + showModalBottomSheet( + useSafeArea: true, + context: context, + isScrollControlled: true, + builder: (ctx) => DeviceCommandSheet( + device: device, + deviceService: DeviceService(), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: ListTile( + leading: Icon(_iconByStatus(), size: 32), + title: Text( + _title(context), + style: const TextStyle(fontWeight: FontWeight.w600), + ), + subtitle: Text(_subtitle(context)), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Builder( + builder: (context) => IconButton( + icon: const Icon(Icons.touch_app_outlined), + tooltip: AppLocalizations.of(context).sendCommand, + onPressed: () => _showCommandSheet(context), + ), + ), + StatusChip(status: device.status), + ], + ), + onTap: onTap, + ), + ); + } +} diff --git a/lib/widgets/metric_card.dart b/lib/widgets/metric_card.dart new file mode 100644 index 0000000..c237d24 --- /dev/null +++ b/lib/widgets/metric_card.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; + +/// 指标卡片:展示设备单项运行指标。 +class MetricCard extends StatelessWidget { + const MetricCard({ + super.key, + required this.icon, + required this.title, + required this.value, + this.progress, + this.accentColor, + }); + + /// 指标图标。 + final IconData icon; + + /// 指标名称(如:CPU 使用率)。 + final String title; + + /// 指标值文本(如:87.0%)。 + final String value; + + /// 进度(0.0 ~ 1.0),为 null 时不显示进度条。 + final double? progress; + + /// 强调色,默认使用主题主色。 + final Color? accentColor; + + @override + Widget build(BuildContext context) { + final color = accentColor ?? Theme.of(context).colorScheme.primary; + final progressValue = progress?.clamp(0.0, 1.0).toDouble(); + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Icon(icon, size: 20, color: color), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + style: Theme.of(context).textTheme.bodyMedium, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + value, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + if (progressValue != null) ...[ + const SizedBox(height: 12), + LinearProgressIndicator( + value: progressValue, + color: color, + backgroundColor: color.withOpacity(0.15), + borderRadius: BorderRadius.circular(4), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/widgets/org_picker_sheet.dart b/lib/widgets/org_picker_sheet.dart new file mode 100644 index 0000000..965ca75 --- /dev/null +++ b/lib/widgets/org_picker_sheet.dart @@ -0,0 +1,337 @@ +import 'package:flutter/material.dart'; + +import '../models/org_node.dart'; + +/// 组织选择项:携带层级深度与是否可选,用于按组织树自上而下排序并缩进展示。 +class OrgPickerOption { + const OrgPickerOption({ + required this.node, + this.depth = 0, + this.selectable = true, + }); + + final OrgNode node; + final int depth; + final bool selectable; +} + +/// 组织选择器:以底部弹窗形式展示层级组织列表,支持关键字实时搜索, +/// 选中后通过 Navigator.pop 返回对应的 [OrgPickerOption]。 +class OrgPickerSheet extends StatefulWidget { + const OrgPickerSheet({ + super.key, + required this.title, + required this.searchHint, + required this.emptyHint, + required this.options, + this.selectedId, + }); + + final String title; + final String searchHint; + final String emptyHint; + final List options; + final String? selectedId; + + @override + State createState() => _OrgPickerSheetState(); +} + +class _OrgPickerSheetState extends State { + final _searchController = TextEditingController(); + String _keyword = ''; + String? _selectedId; + + @override + void initState() { + super.initState(); + _selectedId = widget.selectedId; + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + void _onSelect(OrgPickerOption option) { + if (_selectedId == option.node.id) { + Navigator.pop(context, option); + return; + } + setState(() => _selectedId = option.node.id); + Future.delayed(const Duration(milliseconds: 120), () { + if (mounted) Navigator.pop(context, option); + }); + } + + List get _filteredOptions { + final keyword = _keyword.trim().toLowerCase(); + final list = widget.options.where((option) { + if (!option.selectable) return false; + if (keyword.isEmpty) return true; + return option.node.name.toLowerCase().contains(keyword); + }).toList(); + // 将当前已选中的组织置顶展示,方便快速找到并确认当前选择。 + if (_selectedId?.isNotEmpty == true) { + final selectedIndex = list.indexWhere((o) => o.node.id == _selectedId); + if (selectedIndex > 0) { + final selected = list.removeAt(selectedIndex); + list.insert(0, selected); + } + } + return list; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final filtered = _filteredOptions; + return Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 标题栏 + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + children: [ + Expanded( + child: Text( + widget.title, + style: theme.textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + ), + IconButton( + icon: const Icon(Icons.close), + tooltip: MaterialLocalizations.of(context).closeButtonTooltip, + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + const Divider(height: 1), + // 搜索框 + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: TextField( + controller: _searchController, + autofocus: true, + decoration: InputDecoration( + hintText: widget.searchHint, + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.search), + suffixIcon: _keyword.isEmpty + ? null + : IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + setState(() => _keyword = ''); + }, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + ), + onChanged: (v) => setState(() => _keyword = v), + ), + ), + // 结果列表 + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 420), + child: filtered.isEmpty + ? SizedBox( + height: 120, + child: Center( + child: Text( + widget.emptyHint, + style: TextStyle( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ) + : ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: filtered.length, + separatorBuilder: (_, __) => const Divider( + height: 1, + indent: 16, + endIndent: 16, + ), + itemBuilder: (context, index) { + final option = filtered[index]; + final selected = _selectedId == option.node.id; + final baseStyle = theme.textTheme.bodyMedium; + return ListTile( + dense: true, + contentPadding: EdgeInsets.only( + left: 16 + option.depth * 16.0, + right: 16, + ), + title: Text( + option.node.name, + style: option.selectable + ? baseStyle?.copyWith( + color: selected + ? theme.colorScheme.primary + : null, + fontWeight: selected + ? FontWeight.bold + : FontWeight.normal, + ) + : baseStyle?.copyWith( + color: theme.disabledColor, + ), + ), + trailing: selected + ? Icon( + Icons.check_circle, + color: theme.colorScheme.primary, + ) + : null, + selected: selected, + selectedTileColor: theme.colorScheme.primaryContainer + .withOpacity(0.38), + onTap: option.selectable + ? () => _onSelect(option) + : null, + ); + }, + ), + ), + // 底部安全区 + SizedBox(height: MediaQuery.of(context).padding.bottom), + ], + ), + ); + } +} + +/// 表单中的「组织选择」输入框:点击后弹出 [OrgPickerSheet], +/// 进入页面时自动展开选择器并高亮已选项。 +class OrgPickerFormField extends StatefulWidget { + const OrgPickerFormField({ + super.key, + this.value, + required this.labelText, + required this.options, + required this.onChanged, + this.validator, + this.autoOpen = true, + this.searchHint, + this.emptyHint, + }); + + final String? value; + final String labelText; + final List options; + final ValueChanged onChanged; + final String? Function(String?)? validator; + final bool autoOpen; + final String? searchHint; + final String? emptyHint; + + @override + State createState() => _OrgPickerFormFieldState(); +} + +class _OrgPickerFormFieldState extends State { + bool _autoOpened = false; + FormFieldState? _field; + + @override + void initState() { + super.initState(); + _scheduleAutoOpen(); + } + + @override + void didUpdateWidget(covariant OrgPickerFormField oldWidget) { + super.didUpdateWidget(oldWidget); + // 外部 value 变更时同步到 FormField 状态。 + if (oldWidget.value != widget.value) { + _field?.didChange(widget.value); + } + // 选项从空变为有值时(异步加载完成),立即自动展开选择器。 + if (oldWidget.options.isEmpty && widget.options.isNotEmpty) { + _scheduleAutoOpen(); + } + } + + void _scheduleAutoOpen() { + if (!_shouldAutoOpen) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _shouldAutoOpen) { + _autoOpened = true; + _openPicker(); + } + }); + } + + bool get _shouldAutoOpen => + widget.autoOpen && widget.options.isNotEmpty && !_autoOpened; + + Future _openPicker() async { + if (!mounted) return; + final selected = await showModalBottomSheet( + useSafeArea: true, + context: context, + isScrollControlled: true, + builder: (ctx) => OrgPickerSheet( + title: widget.labelText, + searchHint: widget.searchHint ?? '搜索组织名称', + emptyHint: widget.emptyHint ?? '未找到匹配组织', + options: widget.options, + selectedId: widget.value, + ), + ); + if (selected != null && mounted) { + _field?.didChange(selected.node.id); + widget.onChanged(selected.node.id); + } + } + + String? _displayName(String? value) { + if (value == null || value.isEmpty) return null; + for (final option in widget.options) { + if (option.node.id == value) return option.node.name; + } + return value; + } + + @override + Widget build(BuildContext context) { + return FormField( + initialValue: widget.value, + validator: widget.validator, + builder: (field) { + _field = field; + final displayName = _displayName(field.value); + final theme = Theme.of(context); + return InkWell( + onTap: _openPicker, + borderRadius: BorderRadius.circular(8), + child: InputDecorator( + decoration: InputDecoration( + labelText: widget.labelText, + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.account_tree), + suffixIcon: const Icon(Icons.arrow_drop_down), + errorText: field.errorText, + ), + child: Text( + displayName ?? widget.labelText, + style: TextStyle( + color: displayName == null ? theme.hintColor : null, + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/widgets/org_tree_filter.dart b/lib/widgets/org_tree_filter.dart new file mode 100644 index 0000000..731e970 --- /dev/null +++ b/lib/widgets/org_tree_filter.dart @@ -0,0 +1,334 @@ +import 'package:flutter/material.dart'; + +import '../models/org_node.dart'; +import '../services/org_service.dart'; + +/// 打开一个底部抽屉式的「组织树筛选」面板。 +/// +/// - [initialSelected]:初始已选中的组织节点 ID 集合(空集表示未筛选)。 +/// - [allowMultiple]:是否允许多选(默认 true,与 Web 端 check-strictly 非级联多选一致)。 +/// +/// 返回值为用户「确定」时选中的节点 ID 集合;点击「取消」或空白处关闭返回 null +/// (调用方应据此保留原有筛选状态不变)。 +Future?> showOrgTreeFilterSheet( + BuildContext context, { + required Set initialSelected, + bool allowMultiple = true, +}) { + return showModalBottomSheet?>( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (ctx) => _OrgTreeFilterSheet( + initialSelected: initialSelected, + allowMultiple: allowMultiple, + ), + ); +} + +/// 组织树筛选抽屉的内部状态组件。 +class _OrgTreeFilterSheet extends StatefulWidget { + const _OrgTreeFilterSheet({ + required this.initialSelected, + required this.allowMultiple, + }); + + final Set initialSelected; + final bool allowMultiple; + + @override + State<_OrgTreeFilterSheet> createState() => _OrgTreeFilterSheetState(); +} + +class _OrgTreeFilterSheetState extends State<_OrgTreeFilterSheet> { + List _tree = []; + bool _loading = true; + String? _error; + + /// 已选中的节点 ID 集合(命中状态与抽屉内一致,确定时回传)。 + final Set _selected = {}; + + /// 展开/折叠状态:集合内的节点视为「展开」。 + final Set _expanded = {}; + + /// 顶部搜索关键词(非空时进入「搜索定位」模式)。 + String _query = ''; + + @override + void initState() { + super.initState(); + _selected.addAll(widget.initialSelected); + _loadTree(); + } + + Future _loadTree() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final tree = await OrgService().getOrgTree(); + if (!mounted) return; + // 默认展开顶层节点,便于一眼看到其下组织。 + final topIds = tree.map((n) => n.id).toSet(); + setState(() { + _tree = tree; + _loading = false; + _expanded.addAll(topIds); + }); + } catch (e) { + if (!mounted) return; + setState(() { + _loading = false; + _error = e.toString(); + }); + } + } + + /// 切换节点选中状态(非级联:仅影响当前节点本身)。 + void _toggleSelect(OrgNode node) { + setState(() { + if (_selected.contains(node.id)) { + _selected.remove(node.id); + } else { + if (widget.allowMultiple) { + _selected.add(node.id); + } else { + // 单选:先清空再选中当前节点。 + _selected.clear(); + _selected.add(node.id); + } + } + }); + } + + /// 切换节点展开/折叠(无子节点时无效)。 + void _toggleExpand(OrgNode node) { + if (node.children.isEmpty) return; + setState(() { + if (_expanded.contains(node.id)) { + _expanded.remove(node.id); + } else { + _expanded.add(node.id); + } + }); + } + + /// 计算当前应展示的扁平行(含层级 depth)。 + /// + /// - 无搜索关键词:按 [_expanded] 折叠/展开展示完整树; + /// - 有搜索关键词:递归保留命中节点及其祖先路径(忽略展开态),便于「搜索定位节点」。 + List<_OrgRow> _computeRows() { + final rows = <_OrgRow>[]; + final q = _query.trim().toLowerCase(); + if (q.isNotEmpty) { + // 命中子树:仅保留自身或后代匹配的节点(保留祖先链)。 + final matched = _filterTree(_tree, q); + void walk(List ns, int depth) { + for (final n in ns) { + rows.add(_OrgRow(node: n, depth: depth)); + if (n.children.isNotEmpty) walk(n.children, depth + 1); + } + } + + walk(matched, 0); + return rows; + } + void walk(List ns, int depth) { + for (final n in ns) { + rows.add(_OrgRow(node: n, depth: depth)); + if (n.children.isNotEmpty && _expanded.contains(n.id)) { + walk(n.children, depth + 1); + } + } + } + + walk(_tree, 0); + return rows; + } + + /// 递归过滤组织树:保留自身匹配或后代匹配的节点,并裁剪无关节点。 + static List _filterTree(List nodes, String query) { + final result = []; + for (final n in nodes) { + final children = _filterTree(n.children, query); + final selfMatch = n.name.toLowerCase().contains(query); + if (selfMatch || children.isNotEmpty) { + result.add(n.copyWith(children: children)); + } + } + return result; + } + + @override + Widget build(BuildContext context) { + final rows = _computeRows(); + final colorScheme = Theme.of(context).colorScheme; + + return SizedBox( + height: MediaQuery.of(context).size.height * 0.85, + child: Column( + children: [ + // 顶部标题栏 + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 8, 4), + child: Row( + children: [ + Expanded( + child: Text( + '组织筛选', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + const Divider(height: 1), + // 搜索框 + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: TextField( + decoration: InputDecoration( + hintText: '搜索组织名称', + prefixIcon: const Icon(Icons.search), + isDense: true, + suffixIcon: _query.isEmpty + ? null + : IconButton( + icon: const Icon(Icons.clear), + onPressed: () => setState(() => _query = ''), + ), + ), + onChanged: (v) => setState(() => _query = v), + ), + ), + // 树体 + Expanded( + child: _loading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('组织树加载失败:$_error'), + const SizedBox(height: 8), + FilledButton.tonal( + onPressed: _loadTree, + child: const Text('重试'), + ), + ], + ), + ) + : rows.isEmpty + ? const Center(child: Text('无匹配组织')) + : ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: rows.length, + separatorBuilder: (_, __) => + const Divider(height: 1), + itemBuilder: (ctx, i) { + final row = rows[i]; + final node = row.node; + final hasChildren = node.children.isNotEmpty; + final isExpanded = _expanded.contains(node.id); + return Padding( + // 按层级深度缩进(参考 channel_management_page 范式)。 + padding: EdgeInsets.only( + left: 12.0 + row.depth * 20, + ), + child: Row( + children: [ + if (hasChildren) + IconButton( + icon: Icon( + isExpanded + ? Icons.expand_less + : Icons.expand_more, + ), + visualDensity: VisualDensity.compact, + onPressed: () => _toggleExpand(node), + ) + else + const SizedBox(width: 40), + Checkbox( + value: _selected.contains(node.id), + onChanged: (_) => _toggleSelect(node), + ), + Expanded( + child: InkWell( + onTap: () => _toggleSelect(node), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 10, + horizontal: 4, + ), + child: Text( + node.name, + style: const TextStyle( + fontSize: 15, + ), + ), + ), + ), + ), + ], + ), + ); + }, + ), + ), + const Divider(height: 1), + // 底部操作栏 + Padding( + padding: EdgeInsets.fromLTRB( + 16, + 8, + 16, + MediaQuery.of(context).padding.bottom + 8, + ), + child: Row( + children: [ + Expanded( + child: Text( + '已选 ${_selected.length} 项', + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + ), + TextButton( + onPressed: () => setState(() => _selected.clear()), + child: const Text('清空'), + ), + const SizedBox(width: 4), + TextButton( + onPressed: () => Navigator.pop(context, null), + child: const Text('取消'), + ), + const SizedBox(width: 4), + FilledButton( + onPressed: () => Navigator.pop(context, Set.from(_selected)), + child: const Text('确定'), + ), + ], + ), + ), + ], + ), + ); + } +} + +/// 组织树的一行(节点 + 层级深度)。 +class _OrgRow { + const _OrgRow({required this.node, required this.depth}); + + final OrgNode node; + final int depth; +} diff --git a/lib/widgets/region_picker.dart b/lib/widgets/region_picker.dart new file mode 100644 index 0000000..306102f --- /dev/null +++ b/lib/widgets/region_picker.dart @@ -0,0 +1,208 @@ +import 'package:flutter/material.dart'; + +import '../data/region_data.dart'; + +/// 省→市→区三级联动结果。 +class RegionResult { + const RegionResult({ + required this.provinceCode, + required this.provinceName, + required this.cityCode, + required this.cityName, + required this.districtCode, + required this.districtName, + }); + + final String provinceCode; + final String provinceName; + final String cityCode; + final String cityName; + final String districtCode; + final String districtName; + + /// 拼接展示文本(省名+市名+区名)。 + String get label => + '$provinceName$cityName$districtName'; +} + +/// 省→市→区三级联动底部弹起选择器。 +/// +/// 数据源统一来自 [RegionData](已加载的 `province.json`)。 +/// 返回 [RegionResult](含 code 与 name);用户取消时弹出 null。 +class RegionPicker extends StatefulWidget { + const RegionPicker({ + super.key, + this.initialProvinceCode, + this.initialCityCode, + this.initialDistrictCode, + }); + + final String? initialProvinceCode; + final String? initialCityCode; + final String? initialDistrictCode; + + /// 打开选择器底部弹窗,返回所选 [RegionResult] 或 null。 + static Future show({ + required BuildContext context, + String? initialProvinceCode, + String? initialCityCode, + String? initialDistrictCode, + }) { + return showModalBottomSheet( + useSafeArea: true, + context: context, + isScrollControlled: true, + builder: (ctx) => RegionPicker( + initialProvinceCode: initialProvinceCode, + initialCityCode: initialCityCode, + initialDistrictCode: initialDistrictCode, + ), + ); + } + + @override + State createState() => _RegionPickerState(); +} + +class _RegionPickerState extends State { + final RegionData _region = RegionData.instance; + + /// 当前选中层级:0=省 1=市 2=区。 + int _level = 0; + String? _provinceCode; + String? _cityCode; + String? _districtCode; + + @override + void initState() { + super.initState(); + _provinceCode = widget.initialProvinceCode; + _cityCode = widget.initialCityCode; + _districtCode = widget.initialDistrictCode; + // 若已带初始省,直接进入市层级以便快速定位。 + if (_provinceCode != null && _provinceCode!.isNotEmpty) { + _level = 1; + if (_cityCode != null && _cityCode!.isNotEmpty) { + _level = 2; + } + } + } + + List get _currentList { + switch (_level) { + case 0: + return _region.provinces; + case 1: + return _region.citiesOf(_provinceCode ?? ''); + case 2: + return _region.districtsOf( + _provinceCode ?? '', + _cityCode ?? '', + ); + default: + return const []; + } + } + + String _levelTitle() { + switch (_level) { + case 0: + return '选择省份'; + case 1: + return '选择城市'; + case 2: + return '选择区县'; + default: + return '选择地区'; + } + } + + void _onSelect(RegionNode node) { + if (_level == 0) { + setState(() { + _provinceCode = node.code; + _cityCode = null; + _districtCode = null; + _level = 1; + }); + } else if (_level == 1) { + setState(() { + _cityCode = node.code; + _districtCode = null; + _level = 2; + }); + } else { + final provinceName = _region.nameOf(_provinceCode ?? ''); + final cityName = _region.nameOf(_cityCode ?? ''); + Navigator.pop( + context, + RegionResult( + provinceCode: _provinceCode ?? '', + provinceName: provinceName, + cityCode: _cityCode ?? '', + cityName: cityName, + districtCode: node.code, + districtName: node.name, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final nodes = _currentList; + return Container( + height: MediaQuery.of(context).size.height * 0.6, + padding: const EdgeInsets.only(top: 8), + child: Column( + children: [ + Row( + children: [ + const SizedBox(width: 16), + Expanded( + child: Text( + _levelTitle(), + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + ), + if (_level > 0) + TextButton( + onPressed: () => setState(() => _level--), + child: const Text('上一级'), + ), + const SizedBox(width: 8), + ], + ), + const Divider(height: 1), + Expanded( + child: ListView.separated( + itemCount: nodes.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final node = nodes[index]; + final isSelected = _isSelected(node); + return ListTile( + title: Text(node.name), + trailing: isSelected + ? Icon(Icons.check, + color: Theme.of(context).colorScheme.primary) + : const Icon(Icons.chevron_right), + onTap: () => _onSelect(node), + ); + }, + ), + ), + ], + ), + ); + } + + bool _isSelected(RegionNode node) { + if (_level == 0) return node.code == _provinceCode; + if (_level == 1) return node.code == _cityCode; + return node.code == _districtCode; + } +} diff --git a/lib/widgets/status_chip.dart b/lib/widgets/status_chip.dart new file mode 100644 index 0000000..dd58b8d --- /dev/null +++ b/lib/widgets/status_chip.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +import '../models/device.dart'; +import '../utils/i10n.dart'; + +/// 设备状态标签。 +class StatusChip extends StatelessWidget { + const StatusChip({super.key, required this.status}); + + /// 设备状态。 + final DeviceStatus status; + + Color _color(BuildContext context) { + switch (status) { + case DeviceStatus.online: + return Colors.green; + case DeviceStatus.offline: + return Colors.grey; + case DeviceStatus.warning: + return Colors.orange; + } + } + + @override + Widget build(BuildContext context) { + final color = _color(context); + final t = AppLocalizations.of(context); + final label = switch (status) { + DeviceStatus.online => t.statusOnline, + DeviceStatus.offline => t.statusOffline, + DeviceStatus.warning => t.statusWarning, + }; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: color.withOpacity(0.15), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: color.withOpacity(0.6)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.circle, size: 8, color: color), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + color: color, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/store_theme_grid.dart b/lib/widgets/store_theme_grid.dart new file mode 100644 index 0000000..b624990 --- /dev/null +++ b/lib/widgets/store_theme_grid.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; + +import '../models/theme_template.dart'; + +/// 主题模板网格卡片(封面图 + 名称)。 +/// +/// 用于门店表单中「主题皮肤」选择;[selectedId] 高亮选中态, +/// [onSelected] 回传选中模板的 [ThemeTemplate.id]。 +class StoreThemeGrid extends StatelessWidget { + const StoreThemeGrid({ + super.key, + required this.templates, + required this.onSelected, + this.selectedId, + }); + + /// 可选主题模板列表。 + final List templates; + + /// 选中回调,回传模板 id。 + final ValueChanged onSelected; + + /// 当前选中的模板 id(高亮)。 + final String? selectedId; + + @override + Widget build(BuildContext context) { + if (templates.isEmpty) { + return const SizedBox( + height: 80, + child: Center(child: Text('暂无可用主题模板')), + ); + } + + return GridView.count( + crossAxisCount: 3, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 0.9, + children: templates.map((template) { + final selected = template.id == selectedId; + return _ThemeCard( + template: template, + selected: selected, + onTap: () => onSelected(template.id), + ); + }).toList(), + ); + } +} + +class _ThemeCard extends StatelessWidget { + const _ThemeCard({ + required this.template, + required this.selected, + required this.onTap, + }); + + final ThemeTemplate template; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final primary = _accentColor(template.name); + final coverUrl = template.coverUrl; + + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: selected + ? Theme.of(context).colorScheme.primary + : Colors.transparent, + width: selected ? 2.5 : 1, + ), + ), + child: Column( + children: [ + // 封面预览(优先 coverUrl,否则用名称哈希色块)。 + Expanded( + child: Container( + decoration: BoxDecoration( + color: coverUrl == null || coverUrl.isEmpty ? primary : null, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(10)), + image: coverUrl != null && coverUrl.isNotEmpty + ? DecorationImage( + image: NetworkImage(coverUrl), + fit: BoxFit.cover, + ) + : null, + ), + alignment: Alignment.center, + child: coverUrl == null || coverUrl.isEmpty + ? Text( + template.name.isEmpty ? '主题' : template.name[0], + style: const TextStyle( + color: Colors.white, + fontSize: 28, + fontWeight: FontWeight.bold, + ), + ) + : null, + ), + ), + // 名称。 + Container( + height: 34, + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Text( + template.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + ), + ), + ], + ), + ), + ); + } + + static const _palette = [ + Color(0xFF3B82F6), + Color(0xFF8B5CF6), + Color(0xFFEC4899), + Color(0xFFF59E0B), + Color(0xFF10B981), + Color(0xFF6366F1), + ]; + + static Color _accentColor(String name) { + var hash = 0; + for (var i = 0; i < name.length; i++) { + hash = (hash + name.codeUnitAt(i) * (i + 1)) % _palette.length; + } + return _palette[hash]; + } +} diff --git a/lib/widgets/store_theme_group_list.dart b/lib/widgets/store_theme_group_list.dart new file mode 100644 index 0000000..7885f79 --- /dev/null +++ b/lib/widgets/store_theme_group_list.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; + +import '../models/theme_group.dart'; + +/// 门店界面配置:按设备分组展示已添加的主题。 +/// +/// 每组横向滚动显示主题卡片,激活主题带高亮边框; +/// 点击主题卡片触发 [onThemeTap]。 +class StoreThemeGroupList extends StatelessWidget { + const StoreThemeGroupList({ + super.key, + required this.groups, + this.onThemeTap, + this.emptyHint, + }); + + /// 分组及主题数据。 + final List groups; + + /// 主题点击回调;参数为 (groupId, theme)。 + final void Function(int groupId, ThemePage theme)? onThemeTap; + + /// 空数据提示文案。 + final String? emptyHint; + + @override + Widget build(BuildContext context) { + if (groups.isEmpty) { + return SizedBox( + height: 80, + child: Center( + child: Text( + emptyHint ?? '暂无界面配置数据', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: groups.map((group) => _buildGroup(context, group)).toList(), + ); + } + + Widget _buildGroup(BuildContext context, ThemeGroup group) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + group.groupName, + style: theme.textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.bold), + ), + ), + Text( + '${group.themes.length}/${group.themeLimit}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + if (group.activeThemeName != null) + Padding( + padding: const EdgeInsets.only(top: 2, bottom: 6), + child: Text( + '当前:${group.activeThemeName}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.primary, + ), + ), + ) + else + const SizedBox(height: 6), + SizedBox( + height: 132, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: group.themes.length, + separatorBuilder: (_, __) => const SizedBox(width: 10), + itemBuilder: (context, index) { + final themePage = group.themes[index]; + return _ThemeCard( + themePage: themePage, + onTap: onThemeTap != null + ? () => onThemeTap!(group.groupId, themePage) + : null, + ); + }, + ), + ), + ], + ), + ); + } +} + +class _ThemeCard extends StatelessWidget { + const _ThemeCard({ + required this.themePage, + this.onTap, + }); + + final ThemePage themePage; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final primary = Theme.of(context).colorScheme.primary; + final photoUrl = themePage.themePhoto; + + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: Container( + width: 120, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: themePage.active ? primary : Colors.transparent, + width: themePage.active ? 2 : 1, + ), + ), + child: Column( + children: [ + Expanded( + child: ClipRRect( + borderRadius: + const BorderRadius.vertical(top: Radius.circular(9)), + child: Container( + width: double.infinity, + color: photoUrl == null || photoUrl.isEmpty + ? primary.withOpacity(0.2) + : null, + child: photoUrl != null && photoUrl.isNotEmpty + ? Image.network( + photoUrl, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _placeholder(primary), + ) + : _placeholder(primary), + ), + ), + ), + Container( + height: 34, + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Text( + themePage.name.isEmpty ? '未命名主题' : themePage.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12), + ), + ), + ], + ), + ), + ); + } + + Widget _placeholder(Color primary) { + return Center( + child: Icon(Icons.image_outlined, color: primary, size: 28), + ); + } +} diff --git a/lib/widgets/store_theme_preview.dart b/lib/widgets/store_theme_preview.dart new file mode 100644 index 0000000..4a3fed0 --- /dev/null +++ b/lib/widgets/store_theme_preview.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; + +import '../models/theme_template.dart'; + +/// 模拟门店主页头部预览浮层。 +/// +/// 依据选中的 [ThemeTemplate] 渲染一个模拟的门店主页头部 +///(主色背景 + 门店名 + Logo 占位),用于主题选择时实时预览效果。 +class StoreThemePreview extends StatelessWidget { + const StoreThemePreview({ + super.key, + this.template, + this.storeName = '示例门店', + }); + + /// 选中的主题模板;空时回退默认主题。 + final ThemeTemplate? template; + + /// 预览中的门店名称。 + final String storeName; + + @override + Widget build(BuildContext context) { + final primary = _accentColor(template?.name ?? ''); + final label = template?.name ?? '默认主题'; + final onColor = Colors.white; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [primary, primary.withOpacity(0.75)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _placeholderLogo(primary, onColor), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + storeName, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.25), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + label, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + // 模拟内容卡占位。 + Container( + height: 56, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.18), + borderRadius: BorderRadius.circular(8), + ), + ), + ], + ), + ); + } + + Widget _placeholderLogo(Color primary, Color onColor) { + return Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + border: Border.all(color: primary, width: 2), + ), + alignment: Alignment.center, + child: Icon(Icons.storefront, color: primary, size: 26), + ); + } + + static const _palette = [ + Color(0xFF3B82F6), + Color(0xFF8B5CF6), + Color(0xFFEC4899), + Color(0xFFF59E0B), + Color(0xFF10B981), + Color(0xFF6366F1), + ]; + + static Color _accentColor(String name) { + var hash = 0; + for (var i = 0; i < name.length; i++) { + hash = (hash + name.codeUnitAt(i) * (i + 1)) % _palette.length; + } + return _palette[hash]; + } +} diff --git a/ohos/AppScope/app.json5 b/ohos/AppScope/app.json5 new file mode 100644 index 0000000..5c224b3 --- /dev/null +++ b/ohos/AppScope/app.json5 @@ -0,0 +1,10 @@ +{ + "app": { + "bundleName": "com.ainetv.aishicloud", + "vendor": "example", + "versionCode": 1000000, + "versionName": "1.0.0", + "icon": "$media:app_icon", + "label": "$string:app_name" + } +} diff --git a/ohos/AppScope/resources/base/element/string.json b/ohos/AppScope/resources/base/element/string.json new file mode 100644 index 0000000..025927e --- /dev/null +++ b/ohos/AppScope/resources/base/element/string.json @@ -0,0 +1,8 @@ +{ + "string": [ + { + "name": "app_name", + "value": "云主页运营助手" + } + ] +} diff --git a/ohos/AppScope/resources/base/media/app_icon.png b/ohos/AppScope/resources/base/media/app_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..f37764b1f7606623616dcdc169cc858273ea2d94 GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZYBRYe;|OLfu)tPp=D){ QB2a?C)78&qol`;+0Lr!y6951J literal 0 HcmV?d00001 diff --git a/ohos/build-profile.json5 b/ohos/build-profile.json5 new file mode 100644 index 0000000..b1a8de8 --- /dev/null +++ b/ohos/build-profile.json5 @@ -0,0 +1,36 @@ +{ + "app": { + "signingConfigs": [], + "products": [ + { + "name": "default", + "signingConfig": "default", + "compileSdkVersion": 11, + "compatibleSdkVersion": 11, + "runtimeOS": "OpenHarmony" + } + ], + "buildModeSet": [ + { + "name": "debug" + }, + { + "name": "release" + } + ] + }, + "modules": [ + { + "name": "entry", + "srcPath": "./entry", + "targets": [ + { + "name": "default", + "applyToProducts": [ + "default" + ] + } + ] + } + ] +} diff --git a/ohos/entry/build-profile.json5 b/ohos/entry/build-profile.json5 new file mode 100644 index 0000000..a0faa67 --- /dev/null +++ b/ohos/entry/build-profile.json5 @@ -0,0 +1,25 @@ +{ + "apiType": "stageMode", + "buildOption": { + }, + "buildOptionSet": [ + { + "name": "release", + "arkOptions": { + "obfuscation": { + "ruleOptions": { + "enable": false, + "files": [ + "./obfuscation-rules.txt" + ] + } + } + } + } + ], + "targets": [ + { + "name": "default" + } + ] +} diff --git a/ohos/entry/oh-package.json5 b/ohos/entry/oh-package.json5 new file mode 100644 index 0000000..88e5d15 --- /dev/null +++ b/ohos/entry/oh-package.json5 @@ -0,0 +1,11 @@ +{ + "name": "entry", + "version": "1.0.0", + "description": "运维管理台 OpenHarmony 入口模块", + "main": "", + "author": "", + "license": "", + "dependencies": { + "@ohos/flutter_ohos": "1.0.0" + } +} diff --git a/ohos/entry/src/main/ets/entryability/EntryAbility.ets b/ohos/entry/src/main/ets/entryability/EntryAbility.ets new file mode 100644 index 0000000..dda80fb --- /dev/null +++ b/ohos/entry/src/main/ets/entryability/EntryAbility.ets @@ -0,0 +1,21 @@ +import { FlutterAbility } from '@ohos/flutter_ohos'; +import window from '@ohos.window'; + +/** + * 应用入口 Ability。 + * + * Flutter OpenHarmony 工程中,入口 Ability 直接继承 FlutterAbility, + * Flutter 引擎与 Dart 侧代码由 flutter_ohos 插件负责加载。 + */ +export default class EntryAbility extends FlutterAbility { + onWindowStageCreate(windowStage: window.WindowStage): void { + super.onWindowStageCreate(windowStage); + windowStage.loadContent('pages/Index', (err) => { + if (err.code) { + console.error(`Failed to load the content. Cause: ${JSON.stringify(err)}`); + return; + } + console.info('Succeeded in loading the content.'); + }); + } +} diff --git a/ohos/entry/src/main/ets/pages/Index.ets b/ohos/entry/src/main/ets/pages/Index.ets new file mode 100644 index 0000000..f9e6997 --- /dev/null +++ b/ohos/entry/src/main/ets/pages/Index.ets @@ -0,0 +1,21 @@ +import { FlutterPage } from '@ohos/flutter_ohos'; + +/** + * 首页组件。 + * + * 承载 Flutter 视图:FlutterPage 由 flutter_ohos 插件提供, + * 会在此页面内渲染 Dart 侧的完整 UI。 + */ +@Entry +@Component +struct Index { + build() { + Column() { + FlutterPage() + .width('100%') + .height('100%') + } + .width('100%') + .height('100%') + } +} diff --git a/ohos/entry/src/main/module.json5 b/ohos/entry/src/main/module.json5 new file mode 100644 index 0000000..d75fce0 --- /dev/null +++ b/ohos/entry/src/main/module.json5 @@ -0,0 +1,44 @@ +{ + "module": { + "name": "entry", + "type": "entry", + "description": "$string:module_desc", + "mainElement": "EntryAbility", + "deviceTypes": [ + "phone" + ], + "deliveryWithInstall": true, + "installationFree": false, + "pages": "$profile:main_pages", + "requestPermissions": [ + { + "name": "ohos.permission.INTERNET" + } + ], + "network": { + "cleartextTraffic": true + }, + "abilities": [ + { + "name": "EntryAbility", + "srcEntry": "./ets/entryability/EntryAbility.ets", + "description": "$string:entry_desc", + "icon": "$media:app_icon", + "label": "$string:entry_label", + "startWindowIcon": "$media:app_icon", + "startWindowBackground": "$color:start_window_background", + "exported": true, + "skills": [ + { + "entities": [ + "entity.system.home" + ], + "actions": [ + "action.system.home" + ] + } + ] + } + ] + } +} diff --git a/ohos/entry/src/main/resources/base/element/color.json b/ohos/entry/src/main/resources/base/element/color.json new file mode 100644 index 0000000..162a7b6 --- /dev/null +++ b/ohos/entry/src/main/resources/base/element/color.json @@ -0,0 +1,8 @@ +{ + "color": [ + { + "name": "start_window_background", + "value": "#FFFFFF" + } + ] +} diff --git a/ohos/entry/src/main/resources/base/element/string.json b/ohos/entry/src/main/resources/base/element/string.json new file mode 100644 index 0000000..1f1bcdd --- /dev/null +++ b/ohos/entry/src/main/resources/base/element/string.json @@ -0,0 +1,16 @@ +{ + "string": [ + { + "name": "module_desc", + "value": "云主页运营助手主模块" + }, + { + "name": "entry_desc", + "value": "云主页运营助手入口" + }, + { + "name": "entry_label", + "value": "云主页运营助手" + } + ] +} diff --git a/ohos/entry/src/main/resources/base/media/app_icon.png b/ohos/entry/src/main/resources/base/media/app_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..f37764b1f7606623616dcdc169cc858273ea2d94 GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZYBRYe;|OLfu)tPp=D){ QB2a?C)78&qol`;+0Lr!y6951J literal 0 HcmV?d00001 diff --git a/ohos/entry/src/main/resources/base/profile/main_pages.json b/ohos/entry/src/main/resources/base/profile/main_pages.json new file mode 100644 index 0000000..1898d94 --- /dev/null +++ b/ohos/entry/src/main/resources/base/profile/main_pages.json @@ -0,0 +1,5 @@ +{ + "src": [ + "pages/Index" + ] +} diff --git a/ohos/hvigor/hvigor-config.json5 b/ohos/hvigor/hvigor-config.json5 new file mode 100644 index 0000000..941ccf5 --- /dev/null +++ b/ohos/hvigor/hvigor-config.json5 @@ -0,0 +1,5 @@ +{ + "modelVersion": "5.0.0", + "dependencies": { + } +} diff --git a/ohos/oh-package.json5 b/ohos/oh-package.json5 new file mode 100644 index 0000000..06b921e --- /dev/null +++ b/ohos/oh-package.json5 @@ -0,0 +1,12 @@ +{ + "name": "cloudhome_ops", + "version": "1.0.0", + "description": "设备/系统运维管理台 - OpenHarmony 端壳工程", + "main": "", + "author": "", + "license": "", + "dependencies": {}, + "devDependencies": { + "@ohos/hypium": "1.0.15" + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..d67cb1b --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,727 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + characters: + dependency: transitive + description: + name: characters + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + clock: + dependency: transitive + description: + name: clock + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" + source: hosted + version: "1.1.1" + collection: + dependency: transitive + description: + name: collection + sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf + url: "https://pub.dev" + source: hosted + version: "1.19.0" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + url: "https://pub.dev" + source: hosted + version: "0.3.4+2" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + dio: + dependency: "direct main" + description: + name: dio + sha256: "0df44ebba85e503958eb75d07eedd3c86275a58c1d3eda2f2ce8f0a2c3abbb3c" + url: "https://pub.dev" + source: hosted + version: "5.11.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "0786d0b7295a373de356fc0af4f6f1d0ab2844ed31b19dfc5e7556b70e24212c" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810 + url: "https://pub.dev" + source: hosted + version: "8.3.7" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33" + url: "https://pub.dev" + source: hosted + version: "0.9.3+2" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "8c9250b2bd2d8d4268e39c82543bacbaca0fda7d29e0728c3c4bbb7c820fd711" + url: "https://pub.dev" + source: hosted + version: "0.9.4+3" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b + url: "https://pub.dev" + source: hosted + version: "2.6.2" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "320fcfb6f33caa90f0b58380489fc5ac05d99ee94b61aa96ec2bff0ba81d3c2b" + url: "https://pub.dev" + source: hosted + version: "0.9.3+4" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "6382ce712ff69b0f719640ce957559dde459e55ecd433c767e06d139ddf16cab" + url: "https://pub.dev" + source: hosted + version: "2.0.29" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + flutter_slidable: + dependency: "direct main" + description: + name: flutter_slidable + sha256: a857de7ea701f276fd6a6c4c67ae885b60729a3449e42766bb0e655171042801 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 + url: "https://pub.dev" + source: hosted + version: "14.8.1" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "736eb56a911cf24d1859315ad09ddec0b66104bc41a7f8c5b96b4e2620cf5041" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: e83b2b05141469c5e19d77e1dfa11096b6b1567d09065b2265d7c6904560050c + url: "https://pub.dev" + source: hosted + version: "0.8.13" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "40c2a6a0da15556dc0f8e38a3246064a971a9f512386c3339b89f76db87269b6" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: eb06fe30bab4c4497bad449b66448f50edcc695f1c59408e78aa3a8059eb8f0e + url: "https://pub.dev" + source: hosted + version: "0.8.13" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: d58cd9d67793d52beefd6585b12050af0a7663c0c2a6ece0fb110a35d6955e04 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "9f143b0dba3e459553209e20cc425c9801af48e6dfa4f01a0fcf927be3f41665" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" + url: "https://pub.dev" + source: hosted + version: "10.0.7" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" + url: "https://pub.dev" + source: hosted + version: "3.0.8" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + url: "https://pub.dev" + source: hosted + version: "0.12.16+1" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + url: "https://pub.dev" + source: hosted + version: "1.15.0" + mime: + dependency: transitive + description: + name: mime + sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" + url: "https://pub.dev" + source: hosted + version: "1.0.6" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: d234581c090526676fd8fab4ada92f35c6746e3fb4f05a399665d75a399fb760 + url: "https://pub.dev" + source: hosted + version: "5.2.3" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + url: "https://pub.dev" + source: hosted + version: "8.3.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + path: + dependency: transitive + description: + name: path + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + url: "https://pub.dev" + source: hosted + version: "1.9.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" + url: "https://pub.dev" + source: hosted + version: "11.4.0" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc + url: "https://pub.dev" + source: hosted + version: "12.1.0" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f49cb15a064ea9d974fc7fbb302099353b7b170d07284e86e264561579e5bcf8 + url: "https://pub.dev" + source: hosted + version: "9.6.1" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "6ea98b3f17f60d3b527f2647ed2ab4dc0f6bfe25b22cb1c363f5d8f62252f6ac" + url: "https://pub.dev" + source: hosted + version: "0.1.4+1" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: a5c8a97ecf5616112a5b16d4b8e9ec0e5ae90ef63ac69c0d7b8ae240be760b23 + url: "https://pub.dev" + source: hosted + version: "4.4.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: caeae01858a0a7d2df67a445ac98e1ad95e55a0e77c73044f4e9b1c8c2289cbd + url: "https://pub.dev" + source: hosted + version: "0.2.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "5bcf0772a761b04f8c6bf814721713de6f3e5d9d89caf8d3fe031b02a342379e" + url: "https://pub.dev" + source: hosted + version: "2.4.11" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" + url: "https://pub.dev" + source: hosted + version: "1.12.0" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" + url: "https://pub.dev" + source: hosted + version: "0.7.3" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "0aedad096a85b49df2e4725fa32118f9fa580f3b14af7a2d2221896a02cd5656" + url: "https://pub.dev" + source: hosted + version: "6.3.17" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" + url: "https://pub.dev" + source: hosted + version: "6.3.3" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + url: "https://pub.dev" + source: hosted + version: "3.1.4" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b + url: "https://pub.dev" + source: hosted + version: "14.3.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: daf97c9d80197ed7b619040e86c8ab9a9dad285e7671ee7390f9180cc828a51e + url: "https://pub.dev" + source: hosted + version: "5.10.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.6.0 <4.0.0" + flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..ebb5903 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,50 @@ +name: cloudhome_ops +description: "CloudHome-Ops 移动端(Android + iOS + HarmonyOS),三端共用一套 lib/ 代码。" +publish_to: "none" +version: 1.0.2+8 + +environment: + sdk: ">=3.4.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + go_router: ^14.2.0 + flutter_riverpod: ^2.5.1 + dio: ^5.4.3 + intl: ^0.19.0 + # 跨平台相机/相册二维码扫码(替代华为 ScanKit),用于设备扫码查询/绑定。 + mobile_scanner: ^5.2.0 + # 相机权限申请(扫码前确保已授权,避免黑屏/报错)。 + permission_handler: ^11.3.1 + # 启动图/Logo 本地图片选择(相册/拍照)。 + image_picker: ^1.1.2 + # 通用文件选择器(APK 等非媒体文件上传)。 + file_picker: ^8.1.2 + # 门店扩展字段本地持久化(SharedPreferences 封装)。 + shared_preferences: ^2.2.2 + # 文件 MD5 计算(秒传去重 / fileName 取摘要)。 + # 腾讯云 COS 直传客户端已 vendoring 到 lib/services/cos/cos_client.dart + # (对齐 Web 端 cos-js-sdk-v5 的 STS 签名,并修复有效期逻辑)。 + crypto: ^3.0.3 + # 设备列表「向右滑动显示删除按钮」——滑动揭示可点击动作(区别于 Dismissible 松手即弹窗)。 + flutter_slidable: ^3.1.0 + # 读取当前 App 版本信息(包名 / versionCode / versionName),用于版本更新检测。 + package_info_plus: ^8.1.0 + # 打开浏览器跳转到 APK 下载地址 / 应用商店,用于版本更新提示。 + url_launcher: ^6.3.1 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^4.0.0 + +flutter: + uses-material-design: true + assets: + # 登录页 Logo(云朵 + 云管理 + ainetv)。 + - assets/images/logo.png + # 省市级联数据(省→市→区三级树)。 + - assets/data/MCityPiker/province.json diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..bc2a63d --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,17 @@ +// This is a basic Flutter widget smoke test. + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:cloudhome_ops/main.dart'; + +void main() { + testWidgets('App builds without throwing', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const ProviderScope(child: OpsConsoleApp())); + await tester.pump(); + + // 只要渲染树成功构建,即认为 smoke test 通过。 + expect(find.byType(ProviderScope), findsOneWidget); + }); +}