diff --git a/AGENTS.md b/AGENTS.md index 660df38ea6e682f132500c54ee65264f225ac683..e42ad0ce9a761fd2268c376174d0af4a6572e71c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,13 +96,79 @@ --- -## 6. 本地存储 - -- 轻量 KV:`Storage`(SharedPreferences),只存 token、开关、少量字符串/数字。 -- 结构化缓存:`HiveUtils` + Hive Box。 -- key:避免魔法字符串,集中维护在现有常量位置。 -- 禁止明文落盘敏感信息(私钥、密码、验证码、完整身份证/银行卡等)。 -- **离线优先**:设备数据/告警本地缓存(SQLite L1),离线指令排队,上线后补发。 +## 6. 本地存储(多级缓存 + 本地优先) + +### 6.1 存储分层(硬约束) + +| 层级 | 用途 | 实现 | 范围 | +| ---- | ---- | ---- | ---- | +| L0 内存 | 运行时缓存 | `relationCtrl.user.*` / `provider.chats` / `GetX Rx` / `WorkProvider._tasksCacheByType` | 当前会话活跃数据 | +| L1 SQLite | **全量持久化** | `sqflite`(IoT 用 `sqflite_sqlcipher` 加密) | 用户浏览过的所有业务数据:会话/消息/动态/办事任务/关系树/存储/智能体/设备/告警/应用元数据/表单元数据/节点定义索引 | +| L2 文件系统 | 大文档缓存 | `FileSystemCacheRepository`(`getApplicationDocumentsDirectory()/flow_cache/`) | 流程相关大文档:办事节点定义 `work_node/{id}.json`、办事定义列表 `work_defines/{appId}.json` | +| L3 KV | 轻量配置 | `Storage`(SharedPreferences) | token、开关、主题、最近单位等少量字符串/数字 | +| L4 Hive | 结构化草稿 | `HiveUtils` + Hive Box | 办事草稿、表单暂存、文件上传队列、启动快照 | + +### 6.2 SQLite 全量持久化(硬约束) + +- **所有用户浏览过的业务数据必须写入 SQLite**:会话列表、消息内容、动态、办事任务、关系树(单位/群组/部门/集群/存储/智能体/感知设备)、存储资源、表单数据、应用元数据、表单元数据等。 +- **本地优先**:页面打开时先从 L0 内存 → L1 SQLite → L2 文件系统 读取数据渲染首屏,再后台拉取最新数据。 +- **页面点击触发后台更新**:用户点击列表项 / 进入子页面 / 切换 Tab 时,后台 `fire-and-forget` 拉取当前页面数据的最新版本,**hash 比对有变化才持久化 + `setState` 刷新页面**(无变化不写盘、不重建)。 +- **登出清空**:`exitLogin` 必须清空 SQLite 全部业务表 + L2 文件系统缓存(保留 L3 的 token 黑名单等必要配置),防止跨用户数据污染。 +- **表结构**:按业务域分表(`sessions` / `messages` / `activities` / `work_tasks` / `relations` / `storages` / `agents` / `applications` / `forms` / `work_nodes` / `iot_devices` / `iot_alerts`),主键 `id` + 索引 `belongId` / `updateTime` / `spaceId`。 +- **写入策略**:upsert(`INSERT OR REPLACE`),保证幂等;批量写入用事务,禁止循环单条写入。 +- **schema 版本**:`BusinessDatabase._schemaVersion = 2`,`_onUpgrade` 增量迁移(v2 新增 `applications` / `forms` / `work_nodes` 三张流程缓存表)。 + +### 6.3 流程数据三级缓存策略(硬约束) + +流程相关数据(应用、表单、视图、待办、发起办事)采用三级缓存,编排器为 [FlowDataCacheService](lib/dart/core/repository/flow_data_cache_service.dart)。 + +#### 6.3.1 数据分布 + +| 数据类型 | L0 内存 | L1 SQLite | L2 文件系统 | 说明 | +|---------|---------|-----------|------------|------| +| 应用元数据 | `Application.works/forms` | `applications` 表 | - | 元数据小,入 SQLite 索引友好 | +| 表单/视图元数据 + 字段 | `Form.fields` | `forms` 表(含 `fields_json`) | - | 含 attributes,中等大小 | +| 办事节点定义(WorkNode) | `Work.node` | `work_nodes` 表(仅索引) | `work_node/{workId}.json` | 大文档走文件系统,SQLite 只存路径+hash | +| 办事定义列表 | `Application.works` | - | `work_defines/{appId}.json` | 按应用聚合,列表走文件系统 | +| 待办任务 | `WorkProvider.todos` | `work_tasks` 表(task_type='待办') | - | 高频访问,内存+SQLite | +| 已办/已发起/已完结/抄送/草稿/任务 | `WorkProvider._tasksCacheByType` | `work_tasks` 表(task_type=对应 label) | - | 各类型独立缓存 | + +#### 6.3.2 刷新策略:本地优先 + 后台刷新 + 推送增量 + +1. **L0 内存命中**:直接返回,不触发任何 IO。 +2. **L1/L2 兜底**:内存未命中时,从 L1 SQLite 或 L2 文件系统读取数据渲染首屏(冷启动 < 500ms 可见)。 +3. **后台刷新**:返回本地数据后,`fire-and-forget` 拉取远端最新数据,`hash` 比对: + - 有变化:更新 L0 内存 + 落盘 L1/L2 + `command.emitterFlag('work')` / `structCallback()` 触发 UI 刷新。 + - 无变化:不写盘、不刷新(减少 IO 和重建)。 +4. **远端兜底**:本地无缓存时,同步等待远端返回(首次加载或 `reload=true`)。 +5. **推送增量**:`KernelApi` 长连接 `RecvTask` 事件触发 `WorkProvider.updateTask`,增量更新 L0 内存 + 异步落盘 L1。 + +#### 6.3.3 改造落点 + +- [Application.loadWorks](lib/dart/core/thing/standard/application.dart):L0 → L2(work_defines)→ 远端,后台 `_refreshWorksFromRemote`。 +- [Application.loadForms](lib/dart/core/thing/standard/application.dart):L0 → L1(forms 表)→ 远端,后台 `_refreshFormsFromRemote`。 +- [Form.load](lib/dart/core/thing/standard/form.dart):L0 → L1(attributes 已随 forms 表恢复)→ 远端,后台 `_refreshAttributesFromRemote`。 +- [Work.loadWorkNode](lib/dart/core/work/index.dart):L0 → L2(work_node 文件)→ 远端,后台 `_refreshWorkNodeFromRemote`。 +- [WorkProvider.loadTodos](lib/dart/core/work/provider.dart):L0 → L1(work_tasks, task_type='待办')→ 远端,后台 `_refreshTodosFromRemote`。 +- [WorkProvider.loadTasks](lib/dart/core/work/provider.dart):L0 → L1(work_tasks, task_type=对应 label)→ 远端,后台 `_refreshTasksByTypeFromRemote`。 +- [WorkRepository._loadAllContentForSpace](lib/dart/core/repository/work_repository.dart):加载完成后 `cacheApplications` 落盘 L1。 + +### 6.4 通用约束 + +- key / 表名 / 列名:避免魔法字符串,集中维护在 `lib/dart/base/storage/`(待建)或现有常量位置。 +- 禁止明文落盘敏感信息(私钥、密码、验证码、完整身份证/银行卡等);token 用 Keychain/Keystore,不入 SQLite。 +- **离线指令**:办事提交 / IoT 指令 / 动态发布等写操作,离线时入 SQLite 队列,上线后补发。 +- **数据库迁移**:版本升级用 `sqflite` 的 `onUpgrade` 增量迁移,禁止 `DROP` 全表。 +- **文件系统缓存路径**:`{appDocDir}/flow_cache/{userId}/{spaceId}/{dataType}/{id}.json`,按用户/空间隔离,登出/切空间按前缀清理。 +- **hash 比对**:写盘前必须计算 `content_hash`(SQLite 字段)或 `__hash`(文件系统字段),无变化不写盘。 + +### 6.5 现有 SQLite 使用 + +- IoT 设备数据/告警:[lib/dart/core/iot/iot_data_cache.dart](lib/dart/core/iot/iot_data_cache.dart) + [device_repository.dart](lib/dart/core/iot/device_repository.dart),三层读取(内存 → SQLite L1 → 远端)。 +- 业务数据全量持久化:[business_database.dart](lib/dart/base/storages/business_database.dart),覆盖 sessions/messages/activities/work_tasks/relations/storages/agents/applications/forms/work_nodes。 +- 流程数据三级缓存:[flow_data_cache_service.dart](lib/dart/core/repository/flow_data_cache_service.dart) + [file_system_cache_repository.dart](lib/dart/base/storages/file_system_cache_repository.dart)。 +- VET 缓存:[lib/dart/base/security/secure_database.dart](lib/dart/base/security/secure_database.dart)(待替换为 `sqflite_sqlcipher`)。 +- 其他业务域 SQLite 持久化按需推进,新增业务必须遵循 §6.2 / §6.3 约束。 --- @@ -358,16 +424,207 @@ lib/pages/iot/ # 物联网页面 --- -## 17. 测试与质量门槛 +## 15.5 字体统一设计规范(必遵循) + +> 入口:[lib/config/theme/unified_style.dart](lib/config/theme/unified_style.dart) `class XFonts` +> 基准:540×1170 设计稿,`flutter_screenutil` 自动缩放(`.sp`)。 +> 原则:**禁止硬编码 `fontSize` / `FontWeight` / `color`**,必须使用 `XFonts.xxx` 语义化样式。 + +### 15.5.1 字体层级(视觉权重由大到小) + +| 层级 | 样式名 | 字号 | 字重 | 用途 | +| ---- | ---- | ---- | ---- | ---- | +| Display | `displayLarge` / `displayMedium` | 36 / 32 sp | w700 | 欢迎语、登录页大标题 | +| Headline | `headlineLarge` / `Medium` / `Small` | 28 / 26 / 24 sp | w700 / w700 / w600 | AppBar 标题、页面主标题 | +| Title | `titleLarge` / `Medium` / `Small` | 24 / 22 / 20 sp | w700 / w600 / w600 | 区块标题、卡片标题 | +| Body | `bodyLarge` / `Medium` / `Small` | 22 / 20 / 18 sp | w600 / w500 / w400 | 列表项主标题、正文、辅助说明 | +| Label | `labelLarge` / `Medium` / `Small` | 18 / 16 / 16 sp | w600 / w500 / w500 | 按钮、Badge、标签 | +| Caption | `caption` / `captionSmall` | 16 / 16 sp | w400 | 时间、数量、提示 | +| Number | `numberLarge` / `Medium` / `Small` | 32 / 24 / 20 sp | w700 | 统计数字(`tabularFigures`) | +| TabBar | `tabLabel` / `tabUnselected` | 18 / 18 sp | w600 / w500 | **所有 Tab 页签统一** | + +### 15.5.2 颜色规范(搭配字体使用) + +- 主文字:`OipText.primary` +- 次文字:`OipText.secondary` +- 辅助文字:`OipText.tertiary` +- 禁用文字:`OipText.disabled` +- 反色文字(深色背景):`OipText.inverse` +- 品牌强调:`OipBrand.primary` + +### 15.5.3 使用约束(硬性) + +1. **TabBar 字体统一**:所有 Tab(含子 Tab)的 `labelStyle: XFonts.tabLabel`、`unselectedLabelStyle: XFonts.tabUnselected`,**不允许使用其他样式**。 +2. **数据列表项**:主标题 `bodyLarge`、副标题 `bodyMedium`、辅助信息 `caption`。 +3. **AppBar 标题**:`headlineSmall` 或 `titleLarge`,禁止小于 20sp。 +4. **按钮文字**:主按钮 `labelLarge`(白字+品牌色背景),次按钮 `labelLarge`(品牌色字+边框)。 +5. **状态徽标**:`labelSmall` + 对应状态色(success/warning/error/info)。 +6. **统计数字**:必须使用 `numberLarge/Medium/Small`(含 `tabularFigures` 等宽)。 +7. **最小字号红线**:所有可见文字 **≥ 16sp**,禁止出现 14sp 及以下。 +8. **禁止硬编码**:`TextStyle(fontSize: 18.sp, fontWeight: FontWeight.w600)` 这类写法不允许,必须用 `XFonts.xxx` 或其 `.copyWith()` 派生。 +9. **登录/注册/忘记密码等输入框**:使用 `XTextField.input`,输入文字和提示文字统一 20sp(见 [XTextField.dart](lib/components/XTextField/XTextField.dart))。 +10. **遗留 PascalCase 字体常量**(`size24Black0` 等)保留兼容,新增代码必须用语义化样式。 + +### 15.5.4 主题与深色模式 + +- 主题色:`OipBrand.primary` + `OipBrand.primaryBg` + `OipBrand.primaryBorder` +- 跟随系统深色模式:`MediaQuery.platformBrightness` +- 深色模式字体颜色自动切换:`OipText.primary` 等已适配深色背景 + +--- + +## 17. 整体交互设计风格统一(必遵循) + +> 入口:[lib/config/theme/unified_style.dart](lib/config/theme/unified_style.dart)(`XFonts` / `OipBrand` / `OipText` / `OipSurface` / `OipRadius` / `OipSpace` / `OipState`) +> 原则:**所有页面遵循统一设计令牌**,禁止自创视觉风格;同类元素外观一致、间距一致、行为一致。 + +### 17.1 视觉令牌统一(硬约束) + +1. **颜色**:背景用 `OipSurface.*`、文字用 `OipText.*`、品牌色用 `OipBrand.*`、状态色用 `OipState.*`、边框用 `OipBorder.*`;禁止 `Colors.black` / `Colors.grey.shadeXXX` / `Color(0xFFxxxxxx)` 硬编码。 +2. **字体**:见 §15.5,必须用 `XFonts.xxx` 语义化样式。 +3. **圆角**:用 `OipRadius.sm/md/lg/full`,禁止 `BorderRadius.circular(7)` 等魔法数字。 +4. **间距**:用 `OipSpace.*` 或 `AppSpace.*`,禁止散落的 `8.w / 12.h` 硬编码(页面 padding 用 `AppSpace.page`,列表项用 `AppSpace.listItem`)。 +5. **阴影**:统一用 `OipShadow.*`,禁止 `BoxShadow(color: Colors.black.withValues(alpha: 0.05), ...)` 散落写法。 +6. **图标**:列表/按钮用线性图标(`Icons.*_outlined` 系列),大小用 `22.w` / `24.w`,颜色用 `OipText.*` 或 `OipBrand.primary`。 + +### 17.2 同类页面风格一致 + +- **办事发起页 / 待办详情页 / 流程跟踪页**:统一用 `XScaffold` 包装、`XFonts.headlineSmall` 标题、`OipSurface.card` 卡片背景、`OipBrand.primary` 主操作按钮。 +- **列表项**:主标题 `XFonts.bodyLarge`、副标题 `XFonts.bodyMedium`、辅助信息 `XFonts.caption`,行高 ≥ 60.h。 +- **底部主操作按钮**:`SizedBox(width: double.infinity, height: 52.h)` + `ElevatedButton` + `OipBrand.primary` 背景 + 白色文字 `XFonts.labelLarge` + `BorderRadius.circular(OipRadius.md)`;提交中显示白色 `CircularProgressIndicator`。 +- **空态**:图标 64.w + 灰色提示文字 `XFonts.bodyMedium`,居中显示。 +- **错误态**:图标 + 错误信息 + "返回"按钮,居中显示。 +- **加载态**:首屏 `SkeletonWidget(itemCount: 6)`;局部 `CircularProgressIndicator`;后台刷新用 `XUi.refreshingBanner()`。 + +### 17.3 行为一致 + +- 所有 Tab 切换保持滚动位置(`AutomaticKeepAliveClientMixin`)。 +- 所有列表支持下拉刷新(`RefreshIndicator`)+ 上拉加载(`ScrollController` 阈值检测)。 +- 所有非根页面有可见返回按钮(见 §8)。 +- 关键操作接入 `HapticFeedback`(见 §15.2)。 +- 所有交互元素最小点击区域 44×44。 + +--- + +## 18. 数据更新机制(必遵循) + +> 入口:[lib/dart/core/provider/index.dart](lib/dart/core/provider/index.dart) `ensureDataFresh` + [lib/dart/controller/data_freshness_tracker.dart](lib/dart/controller/data_freshness_tracker.dart) + SQLite L1(见 §6) +> 原则:**本地优先 + 后台更新 + 有更新才刷新**,后台获取数据不阻塞页面浏览。 + +### 18.1 数据获取策略(硬约束) + +所有列表/详情页面必须按以下顺序获取数据: + +1. **L0 内存缓存**:首屏从 `relationCtrl.user.*` / `provider.chats` 等内存缓存直接渲染,不等待网络。 +2. **L1 SQLite 全量持久化**:内存为空时从 SQLite 读取全量数据并渲染,同时触发后台拉取。 +3. **L2 KV / Hive**:少量配置从 `Storage`,草稿/队列从 `HiveUtils`。 +4. **L3 在线数据**:后台异步调用 `ensureDataFresh(DataType.xxx)` 拉取最新数据;拉取完成后比较版本,**有变化才 `setState` 刷新页面**,无变化不触发重建。 + +### 18.2 后台更新约束(硬约束) + +- **不阻塞 UI**:后台拉取必须 `fire-and-forget`,禁止在 `build()` 中 `await`;首屏渲染只依赖 L0/L1。 +- **不重复刷新**:用 `DataFreshnessTracker` 跟踪 `lastRefreshed`,未过期(默认 5 分钟)不发起拉取。 +- **有更新才刷新**:后台拉取完成后比较数据指纹(长度/最后更新时间/hash),相同则不 `setState`,避免无意义重建。 +- **失败静默**:后台拉取失败时保留 L0/L1 数据,仅在日志记录,不弹错误提示(除非用户主动下拉刷新)。 +- **事件通知**:后台拉取完成后通过 `command.emitter(flag)` 通知订阅方刷新,禁止用 `setState` 跨页面更新。 + +### 18.3 点击触发后台更新 + 持久化同刷(硬约束) + +- **页面点击触发**:用户点击列表项 / 进入子页面 / 切换 Tab 时,**当前页面**后台 `fire-and-forget` 拉取该页面数据的最新版本,不阻塞导航。 +- **持久化同刷**:后台拉取成功后必须同步完成两件事: + 1. **写入 SQLite L1**(upsert,保证幂等)。 + 2. **`setState` 刷新页面**(仅在数据变化时;无变化不重建)。 +- **点击即可见**:用户点击后立即从 L0/L1 渲染详情,后台拉取完成后若数据有更新再补刷,禁止"点击 → 等网络 → 才显示"。 +- **deepLoad**:关系树等需要递归加载的场景,点击节点时后台 `deepLoad`,加载完成持久化到 SQLite 并刷新当前页。 + +### 18.4 已浏览数据本地存储(硬约束) + +- 会话/动态/办事任务/关系树等用户浏览过的数据,必须写入 SQLite L1(见 §6.2),下次进入页面优先从本地读取。 +- 登出时清空所有用户相关 SQLite 业务表 + 内存缓存 + Hive 草稿(见 [lib/dart/core/provider/auth.dart](lib/dart/core/provider/auth.dart) `exitLogin`),防止跨会话数据污染。 +- 缓存 key / 表名集中管理,禁止散落魔法字符串。 + +### 18.5 单位空间切换隔离(硬约束) + +- **`switchSpace` 事件仅门户页面订阅**:`portal/*` 下的页面(`HomePage` / `MyPage` / `PlatformEntryPage` / `PortalLogic` / `WorkBench`)订阅 `switchSpace` 刷新数据。 +- **其他页面不订阅 `switchSpace`**:`chat_page` / `store_page` / `relation_page` / `work_page` / `discover/*` 不订阅 `switchSpace`,保持本地缓存优先策略。 +- 切换单位后,用户在其他 Tab 浏览时若数据过期,由 `ensureDataFresh` 按需刷新,而非强制清空重建。 + +### 18.6 关系聚合数据展示(硬约束) + +沟通/办事/发现/关系四个 Tab 显示的数据范围: + +- **个人**:`relationCtrl.user.members / cohorts / storages / agents / companys` +- **加入群组**:`user.cohorts` + 各 `company.cohorts` +- **加入单位**:`user.companys` + 各 `company.shareTarget` +- **聚合后**按 `updateTime` 倒序展示,点击具体项时在后台 `deepLoad` 检查更新,**持久化到 SQLite 同时刷新当前页**。 + +--- + +## 19. 测试验证流程(必遵循) + +> 入口:[lib/components/MySettingWidget/components/ErrorWidget/ErrorListWidget.dart](lib/components/MySettingWidget/components/ErrorWidget/ErrorListWidget.dart) `SystemLog.errors` +> 原则:**每次测试验证必须检查错误日志**,针对日志内容做定向修复。 + +### 19.1 测试验证流程(硬约束) + +每次运行 `flutter run` / `flutter test` / 真机调试时,必须执行: + +1. **启动后**进入"我的 → 错误日志"页面,记录 `SystemLog.errors` 内容。 +2. **按错误条目逐项排查**:根据 `title`(错误来源)和 `content`(堆栈/详情)定位代码位置,做针对性修复。 +3. **修复后回归验证**:清除日志 → 重启 → 触发原场景 → 确认错误不再出现。 +4. **提交前**:`flutter analyze` 0 errors/warnings(info 级历史问题可忽略)+ `flutter test` 全部通过 + 错误日志为空(或仅环境问题)。 + +### 19.2 错误日志写入约束 + +- 用户可见异常(`catch` 块)必须 `SystemLog.add(title, content)`,禁止仅 `XLogUtil.e` 不写入。 +- 后台异步异常必须写入,不能因 `!mounted` 静默丢失。 +- 错误内容脱敏:不输出 token / 手机号 / 身份证等敏感字段(见 §16)。 +- 登出时清空错误日志(见 [auth.dart](lib/dart/core/provider/auth.dart) `exitLogin`)。 +- **错误分类**:`SystemLog.err(content, title)` 的 `title` 必须是语义化分类(如 `全局异常`/`HTTP异常`/`HTTP错误[401]`/`Token刷新`/`登录加载`/`数据刷新`/`后台加载`/`后台刷新[chats]`/`会话刷新`/`SQLite写入`/`SQLite读取`/`待办加载`),禁止使用时间戳作为 title。 +- **堆栈跟踪**:关键路径 catch 块必须捕获 `StackTrace`(`catch (e, s)`),并将 `s` 拼入 `content`,便于定位。 +- **上下文信息**:错误内容必须包含操作名/参数/调用栈,禁止只输出 `$e`。 + +### 19.3 测试覆盖范围 + +- 纯逻辑(`lib/dart/core`)新增/修复补单测。 +- 关键交互补 widget test。 +- 物联网模块(`dart/core/iot/`)补单测:物模型解析、指令封装、推送分发。 +- 提交前 `flutter analyze` 不引入新增错误。 + +### 19.4 启动服务验证排查流程(硬约束) + +**每次启动 iOS 模拟器和 Flutter 开发服务后,必须执行以下验证排查流程:** + +1. **启动服务**:`flutter run` 或热重载后,等待应用首屏渲染完成。 +2. **进入错误日志页面**:导航到"我的 → 错误日志"页面,查看 `SystemLog.errors` 内容。 +3. **分析日志内容**: + - 按 `title` 分类聚合查看错误(相同 title 的错误已合并,显示 `×次数`)。 + - 根据 `content` 中的错误信息和堆栈跟踪,定位到具体代码位置。 + - 重点关注:`全局异常`、`HTTP异常`、`登录加载`、`数据刷新`、`后台加载`、`会话刷新` 等关键分类。 +4. **逐项修复**: + - 每条错误日志对应一个待修复问题。 + - 根据 `content` 中的堆栈跟踪定位代码,做针对性修复。 + - 修复后清除日志,触发原场景,确认错误不再出现。 +5. **回归验证**: + - 清除错误日志 → 重启应用 → 操作各页面(沟通/办事/发现/关系/门户)→ 确认无新增错误。 + - 若仍有错误,重复步骤 3-4 直到错误日志为空(或仅环境问题)。 +6. **提交前最终检查**: + - `flutter analyze` 0 errors/warnings。 + - 错误日志为空(或仅环境问题,如网络不通导致的 HTTP 异常)。 + - 确保所有 catch 块都写入了 `SystemLog.err`,无静默 catch。 + +### 19.5 错误日志去重与合并(硬约束) -- 纯逻辑(尤其 `lib/dart/core`)新增/修复建议补单测。 -- 关键交互可补 widget test(按成本评估)。 -- 提交前保证 `flutter analyze` 不引入新增错误。 -- 物联网模块(`dart/core/iot/`)建议补单测,覆盖物模型解析、指令封装、推送分发。 +- **相同错误合并**:`SystemLog` 对相同 `dedupeKey`(title + content 前 200 字符)的错误自动合并,累加 `count`,更新最近时间,保留首次时间。 +- **显示格式**: + - 列表项标题显示 `$title (×$count)`(count > 1 时)。 + - 时间显示 `首次时间 ~ 最近时间`(count > 1 时)或 `最近时间`(count = 1 时)。 +- **容量上限**:最多 200 条不同错误,超出 FIFO 淘汰最旧条目。 +- **异步落盘**:500ms 内多次 `err` 只写一次 `Storage`,避免高频错误影响性能。 --- -## 18. PR 自检清单(提交前逐条对照) +## 20. PR 自检清单(提交前逐条对照) - [ ] 代码放对目录:pages/components/dart/routers/config/utils - [ ] 路由:已注册到 `RoutePages` 体系,没私建第二套路由 @@ -381,11 +638,13 @@ lib/pages/iot/ # 物联网页面 - [ ] UI:build 纯渲染、长列表 builder、必要的 const/key、mounted 判断 - [ ] 性能:卡片 ≤20、地图标记聚合、性能预算达标 - [ ] 安全:日志无敏感信息、WebView/文件输入已做边界处理 -- [ ] 测试/分析:必要测试已补(或说明原因),`flutter analyze` 通过 +- [ ] **风格统一**:颜色/字体/圆角/间距用 `Oip*` 令牌,没硬编码;同类页面风格一致(§17) +- [ ] **数据更新**:本地优先(SQLite L1)+ 后台更新 + 有更新才刷新;切换单位不影响非门户页面;点击触发后台拉取 + 持久化同刷(§6 / §18) +- [ ] **测试验证**:错误日志已检查并定向修复;`flutter analyze` 通过;必要测试已补(§19) --- -## 19. Git 约定(沿用 README) +## 21. Git 约定(沿用 README) - 分支前缀:`common/feature/fix/hotfix/perf/other` + 姓名缩写 + 描述/日期(详见 [README.md](README.md))。 - 不提交临时产物、构建产物、性能 dump(例如 `.hprof`、`build/` 生成物)。 @@ -393,7 +652,7 @@ lib/pages/iot/ # 物联网页面 --- -## 20. 协作规范 +## 22. 协作规范 - 默认中文沟通。 - 修改前先读相关文件;只做需求相关改动。 diff --git a/android/app/build.gradle b/android/app/build.gradle index df09c8eda1b71bd775299cea1e0afbba2757b04a..66c8b54ae4cdbfa88ec9c20b9ccf411e4c3be9b2 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,6 +1,7 @@ plugins { id 'com.android.application' - id 'org.jetbrains.kotlin.android' + id 'kotlin-android' + id 'dev.flutter.flutter-gradle-plugin' } def localProperties = new Properties() @@ -11,18 +12,9 @@ if (localPropertiesFile.exists()) { } } -tasks.register("prepareKotlinBuildScriptModel") {} - -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - def flutterVersionCode = localProperties.getProperty('flutter.versionCode', '1') def flutterVersionName = localProperties.getProperty('flutter.versionName', '1.0') -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - def keystoreProperties = new Properties() def keystorePropertiesFile = rootProject.file('key.properties') if (keystorePropertiesFile.exists()) { @@ -31,7 +23,7 @@ if (keystorePropertiesFile.exists()) { android { namespace 'com.github.orginone' - compileSdkVersion 34 + compileSdkVersion 36 ndkVersion flutter.ndkVersion buildFeatures { @@ -39,7 +31,7 @@ android { } compileOptions { - coreLibraryDesugaringEnabled false + coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } @@ -76,7 +68,7 @@ android { defaultConfig { applicationId "com.github.orginone" minSdkVersion 24 - targetSdkVersion 34 + targetSdkVersion 35 versionCode flutterVersionCode.toInteger() versionName flutterVersionName flavorDimensions "default" @@ -124,7 +116,8 @@ dependencies { implementation "androidx.window:window:1.1.0" implementation "androidx.multidex:multidex:2.0.1" implementation "com.google.code.gson:gson:2.10.1" - + coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.1.5" + // 解决Kotlin序列化问题 constraints { implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0") { diff --git a/android/build.gradle b/android/build.gradle index 5ae0c77861a4d6a033681ad4429386aa1d6cb98a..791b046f511f646fc5828b2a88b8a191bf3e5817 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -15,7 +15,7 @@ subprojects { } buildscript { - ext.kotlin_version = '1.9.0' + ext.kotlin_version = '2.2.20' repositories { // maven { url 'https://mirrors.cloud.tencent.com/nexus/repository/maven-public/' } // maven { url 'https://maven.aliyun.com/repository/google' } @@ -40,7 +40,7 @@ buildscript { dependencies { // classpath 'com.android.tools.build:gradle:8.0.2' - classpath 'com.android.tools.build:gradle:7.4.2' + classpath 'com.android.tools.build:gradle:8.7.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } @@ -59,6 +59,8 @@ allprojects { allowInsecureProtocol = true url 'https://maven.aliyun.com/nexus/content/groups/public' } + maven { url 'https://storage.googleapis.com/download.flutter.io' } + maven { url 'https://mirrors.tuna.tsinghua.edu.cn/flutter/download.flutter.io' } // jcenter() diff --git a/android/gradle.properties b/android/gradle.properties index 5e20c66920a934a39da3f6b755d7355684560e5a..4b6b18725197a6c4022735b8e2d88bda541cf136 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -11,7 +11,7 @@ # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true #Sun Jan 07 23:34:10 CST 2024 -org.gradle.jvmargs=-Xmx4096M -Dkotlin.daemon.jvm.options\="-Xmx4096M" -XX:+UseParallelGC +org.gradle.jvmargs=-Xmx4096M -Dkotlin.daemon.jvm.options\="-Xmx4096M" -XX:+UseParallelGC -Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8 android.useAndroidX=true android.enableJetifier=true # android.enableR8=true @@ -20,4 +20,12 @@ org.gradle.parallel=true org.gradle.configureondemand=true org.gradle.caching=true android.suppressUnsupportedCompileSdk=34 +android.overridePathCheck=true android.defaults.desugaring=false +kotlin.incremental=false +kotlin.compiler.execution.strategy=in-process +org.gradle.java.home=C:\\Program Files\\Android\\Android Studio\\jbr +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 33225d2640a52fc1ea89d72431167a1f0fc67727..c057e47f1d3caa505b120bc90f9b5da666d716c7 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.0-all.zip +distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.11.1-all.zip diff --git a/android/settings.gradle b/android/settings.gradle index 22eef60d198e33816fc8b6bf2c738443c2364c71..a4935925ea4ab56d0ff9f112f5ffe9abd59e0134 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -1,12 +1,31 @@ -include ':app' +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 + }() -def localPropertiesFile = new File(rootProject.projectDir, "local.properties") -def properties = new Properties() + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") -assert localPropertiesFile.exists() -localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + repositories { + google() + mavenCentral() + gradlePluginPortal() + maven { url 'https://maven.aliyun.com/repository/google' } + maven { url 'https://maven.aliyun.com/repository/jcenter' } + maven { + allowInsecureProtocol = true + url 'https://maven.aliyun.com/nexus/content/groups/public' + } + } +} -def flutterSdkPath = properties.getProperty("flutter.sdk") -assert flutterSdkPath != null, "flutter.sdk not set in local.properties" +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.9.1" apply false + id "org.jetbrains.kotlin.android" version "2.2.20" apply false +} -apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" +include ":app" diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 84a55a5cfc92ce223a6e16c7564161b684978d36..dc38255e8d53983dc462e524b252ea1ed8824835 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -8,11 +8,11 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 36AAE16B953BD2CA3B68A93E /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 506B2B9B448B179F5F6D0968 /* Pods_Runner.framework */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 845A13522A062F1200281FE2 /* libsqlite3.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 845A13512A062EFC00281FE2 /* libsqlite3.tbd */; }; 94750DB92B4AC2E500D60ED3 /* JavaScriptCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84EE7C822A21CD7D00E85C06 /* JavaScriptCore.framework */; }; - 94750DBB2B4AC2E800D60ED3 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 777F3FDFB2944D4D8C93DC34 /* Pods_Runner.framework */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; @@ -21,13 +21,12 @@ /* Begin PBXFileReference section */ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; - 337D52B3F9269CD648F51C08 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 2C8A6A88977A396C59635017 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 2EDA5DA4451808D045EFC6C4 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 3B63D5E02674096D69A8067E /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; - 580413A610BE11BB4DC21200 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 506B2B9B448B179F5F6D0968 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 777F3FDFB2944D4D8C93DC34 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 844112412A7C8EF700630734 /* Walletapi.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = Walletapi.xcframework; sourceTree = ""; }; 845A134E2A062DD900281FE2 /* sqflite.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = sqflite.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -42,6 +41,7 @@ 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 9FC61ABC2F17C43500C45984 /* fluttertoast.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = fluttertoast.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + EA9C9FCE5A848C2C34E9BB60 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -49,9 +49,9 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 94750DBB2B4AC2E800D60ED3 /* Pods_Runner.framework in Frameworks */, 845A13522A062F1200281FE2 /* libsqlite3.tbd in Frameworks */, 94750DB92B4AC2E500D60ED3 /* JavaScriptCore.framework in Frameworks */, + 36AAE16B953BD2CA3B68A93E /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -61,9 +61,9 @@ 032BD69692718AB9B920FBB7 /* Pods */ = { isa = PBXGroup; children = ( - 3B63D5E02674096D69A8067E /* Pods-Runner.debug.xcconfig */, - 337D52B3F9269CD648F51C08 /* Pods-Runner.release.xcconfig */, - 580413A610BE11BB4DC21200 /* Pods-Runner.profile.xcconfig */, + 2C8A6A88977A396C59635017 /* Pods-Runner.debug.xcconfig */, + EA9C9FCE5A848C2C34E9BB60 /* Pods-Runner.release.xcconfig */, + 2EDA5DA4451808D045EFC6C4 /* Pods-Runner.profile.xcconfig */, ); path = Pods; sourceTree = ""; @@ -76,7 +76,7 @@ 84EE7C822A21CD7D00E85C06 /* JavaScriptCore.framework */, 845A13512A062EFC00281FE2 /* libsqlite3.tbd */, 845A134E2A062DD900281FE2 /* sqflite.framework */, - 777F3FDFB2944D4D8C93DC34 /* Pods_Runner.framework */, + 506B2B9B448B179F5F6D0968 /* Pods_Runner.framework */, ); name = Frameworks; sourceTree = ""; @@ -134,14 +134,14 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - 848E4597DBB5A2E27FD29B54 /* [CP] Check Pods Manifest.lock */, + 9CDC20948B03E318053208DB /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - 5DDD1E6DC201BC9F38E8CC08 /* [CP] Embed Pods Frameworks */, - 3EC994DB5D2991F0EE4C2E59 /* [CP] Copy Pods Resources */, + C746B4F7909D6A27C7BC1F98 /* [CP] Embed Pods Frameworks */, + 9DD0446F7A94DFBC65E5BDF8 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -216,76 +216,76 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin\n"; }; - 3EC994DB5D2991F0EE4C2E59 /* [CP] Copy Pods Resources */ = { + 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + inputPaths = ( ); - name = "[CP] Copy Pods Resources"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + name = "Run Script"; + outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; - showEnvVarsInLog = 0; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n"; }; - 5DDD1E6DC201BC9F38E8CC08 /* [CP] Embed Pods Frameworks */ = { + 9CDC20948B03E318053208DB /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - name = "[CP] Embed Pods Frameworks"; + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - 848E4597DBB5A2E27FD29B54 /* [CP] Check Pods Manifest.lock */ = { + 9DD0446F7A94DFBC65E5BDF8 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; + name = "[CP] Copy Pods Resources"; outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; showEnvVarsInLog = 0; }; - 9740EEB61CF901F6004384FC /* Run Script */ = { + C746B4F7909D6A27C7BC1F98 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); - inputPaths = ( + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - name = "Run Script"; - outputPaths = ( + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; }; /* End PBXShellScriptBuildPhase section */ @@ -468,8 +468,6 @@ "-framework", "\"package_info_plus\"", "-framework", - "\"path_provider_foundation\"", - "-framework", "\"permission_handler_apple\"", "-framework", "\"photo_manager\"", @@ -707,8 +705,6 @@ "-framework", "\"package_info_plus\"", "-framework", - "\"path_provider_foundation\"", - "-framework", "\"permission_handler_apple\"", "-framework", "\"photo_manager\"", @@ -840,8 +836,6 @@ "-framework", "\"package_info_plus\"", "-framework", - "\"path_provider_foundation\"", - "-framework", "\"permission_handler_apple\"", "-framework", "\"photo_manager\"", diff --git a/lib/components/ActivityWidget/ActivityListWidget/ActivityListWidget.dart b/lib/components/ActivityWidget/ActivityListWidget/ActivityListWidget.dart index 9d246935e7143aa2477caaff5a3beb2027b02513..372b05c20e225de682015bfba0120dc72f57ba41 100644 --- a/lib/components/ActivityWidget/ActivityListWidget/ActivityListWidget.dart +++ b/lib/components/ActivityWidget/ActivityListWidget/ActivityListWidget.dart @@ -126,9 +126,34 @@ class _ActivityListState extends State { builder: (context, datas) { return (activity.activityList.isEmpty) ? const EmptyActivity() - : ListView( + : ListView.builder( controller: _scrollController, - children: _buildActivityItem(activity: _activity!)); + itemCount: activity.activityList.length, + itemBuilder: (context, i) { + final showItem = isFromChat == true && + activity.typeName == "群组" + ? activity.activityList[i].metadata.shareId == + activity.metadata.id + : true; + if (!showItem) return const SizedBox.shrink(); + return Container( + decoration: const BoxDecoration( + color: XColors.bgListItem, + border: Border( + bottom: BorderSide( + width: 1, + color: XColors.dividerLineColor, + ), + ), + ), + child: ActivityMessageWidget( + currIndex: i, + activity: activity, + hideResource: true, + ), + ); + }, + ); }, ), ); @@ -178,51 +203,6 @@ class _ActivityListState extends State { // ); } - //渲染动态列表 - List _buildActivityItem({required IActivity activity}) { - List list = []; - for (int i = 0; i < activity.activityList.length; i++) { - if (isFromChat == true && activity.typeName == "群组") { - if (activity.activityList[i].metadata.shareId == activity.metadata.id) { - list.add(Container( - decoration: const BoxDecoration( - color: XColors.bgListItem, - border: Border( - bottom: BorderSide( - width: 1, - color: XColors.dividerLineColor, - ), - ), - ), - child: ActivityMessageWidget( - currIndex: i, - activity: activity, - hideResource: true, - ), - )); - } - } else { - list.add(Container( - decoration: const BoxDecoration( - color: XColors.bgListItem, - border: Border( - bottom: BorderSide( - width: 1, - color: XColors.dividerLineColor, - ), - ), - ), - child: ActivityMessageWidget( - currIndex: i, - activity: activity, - hideResource: true, - ), - )); - } - } - return list; - } - Widget _buildActivityContainer( {required IActivity activity, bool isFromChat = false}) { Widget content = _buildActivityList(activity); diff --git a/lib/components/ActivityWidget/ActivityListWidget/components/ActivityReleaseWidget.dart b/lib/components/ActivityWidget/ActivityListWidget/components/ActivityReleaseWidget.dart index 83a046d0c97e1d6aad593a041a3bd71a95abca0e..403910f7ed80751c19ca81cd32a167c58faa42f7 100644 --- a/lib/components/ActivityWidget/ActivityListWidget/components/ActivityReleaseWidget.dart +++ b/lib/components/ActivityWidget/ActivityListWidget/components/ActivityReleaseWidget.dart @@ -117,6 +117,9 @@ class _ActivityReleaseState // LoadingDialog.dismiss(context); if (!context.mounted) return; RoutePages.back(context, true); + } else { + if (!context.mounted) return; + ToastUtils.showMsg(msg: "发布失败,请稍后重试"); } }, style: ButtonStyle( diff --git a/lib/components/ActivityWidget/ActivityMessageFromChatWidget/components/ActivityCommentWidget.dart b/lib/components/ActivityWidget/ActivityMessageFromChatWidget/components/ActivityCommentWidget.dart index ba395c1dd7363387fe5f43a2613ea33ab0a22b0d..3b8cffd58900ab38e014452a5af554d1fac292d3 100644 --- a/lib/components/ActivityWidget/ActivityMessageFromChatWidget/components/ActivityCommentWidget.dart +++ b/lib/components/ActivityWidget/ActivityMessageFromChatWidget/components/ActivityCommentWidget.dart @@ -8,7 +8,7 @@ import 'package:orginone/dart/base/schema.dart'; import 'package:orginone/dart/core/chat/activity.dart'; import 'package:orginone/dart/core/work/rules/lib/tools.dart'; import 'package:orginone/main.dart'; -import 'package:flutter/src/widgets/basic.dart' as basic; +import 'package:flutter/widgets.dart'; import 'package:orginone/routers/pages.dart'; import '../../../XButton/XButton.dart'; import '../../../XDialogs/XDialog.dart'; @@ -49,7 +49,7 @@ class ActivityCommentWidget extends StatelessWidget { children: [ getUserAvatar(context, comment.userId), Expanded( - child: basic.Column( + child: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/components/AlphabetIndexWidget/ContactListWidget.dart b/lib/components/AlphabetIndexWidget/ContactListWidget.dart index a06990d2d6dea8949e29904515ab821033ce34fd..515ade75a8a7152ca0b061bacbe32a7faddf1f46 100644 --- a/lib/components/AlphabetIndexWidget/ContactListWidget.dart +++ b/lib/components/AlphabetIndexWidget/ContactListWidget.dart @@ -7,6 +7,8 @@ import 'package:orginone/components/EmptyWidget/EmptyWidget.dart'; import 'package:orginone/components/LoadingWidget/LoadingWidget.dart'; import 'package:orginone/components/ListWidget/components/ListItemWidget.dart'; import 'package:orginone/components/XImage/XImage.dart'; +import 'package:orginone/config/oip_tokens.dart'; +import 'package:orginone/config/theme/unified_style.dart'; import 'package:orginone/dart/base/schema.dart'; import 'package:orginone/dart/core/public/entity.dart'; import 'package:orginone/routers/pages.dart'; @@ -64,6 +66,7 @@ class _ContactListWidgetState extends State> final Map _letterToIndex = {}; bool _isLoading = true; + bool _isRefreshing = false; Object? _error; @override @@ -72,26 +75,44 @@ class _ContactListWidgetState extends State> _loadData(); } - Future _loadData() async { - setState(() { - _isLoading = true; - _error = null; - }); + @override + void didUpdateWidget(covariant ContactListWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.getDatas != widget.getDatas) { + _loadData(); + } + } + + Future _loadData({bool reload = false}) async { + // 如果已有数据且是刷新,使用 refreshing 模式 + if (_items.isNotEmpty && reload) { + setState(() => _isRefreshing = true); + } else if (_items.isEmpty) { + setState(() { + _isLoading = true; + _error = null; + }); + } try { + // 优先使用 entity(如 ICompany/IPerson),data 可能是 defaultActiveTabs 等参数 + final routeData = RoutePages.routeData.currPageData; final data = await widget.getDatas?.call( - RoutePages.routeData.currPageData.data) ?? + routeData.entity ?? routeData.data) ?? []; if (!mounted) return; setState(() { _contacts = List.from(data); _buildGroups(); _isLoading = false; + _isRefreshing = false; + _error = null; }); } catch (e) { if (!mounted) return; setState(() { _error = e; _isLoading = false; + _isRefreshing = false; }); } } @@ -174,11 +195,21 @@ class _ContactListWidgetState extends State> child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.error_outline, size: 40.w, color: Colors.grey.shade400), - SizedBox(height: 8.h), - Text('加载失败', style: TextStyle(fontSize: 13.sp)), + Icon(Icons.error_outline, size: 48.w, color: OipState.error.withValues(alpha: 0.6)), SizedBox(height: 12.h), - TextButton(onPressed: _loadData, child: const Text('重试')), + Text('加载失败', style: XFonts.bodyMedium), + SizedBox(height: 16.h), + GestureDetector( + onTap: _loadData, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 10.h), + decoration: BoxDecoration( + color: OipBrand.primaryBg, + borderRadius: BorderRadius.circular(OipRadius.md.w), + ), + child: Text('重试', style: XFonts.brandSmall), + ), + ), ], ), ); @@ -186,21 +217,25 @@ class _ContactListWidgetState extends State> if (_items.isEmpty) { return buildEmptyWidget(true, const SizedBox(), null); } - return Stack( + final Widget list = Stack( children: [ - ListView.builder( - controller: _scrollController, - itemCount: _items.length, - padding: EdgeInsets.only(top: 5.h, right: 32.w), - itemBuilder: (context, index) { - final item = _items[index]; - if (item.isGroupHeader) { - return _buildGroupHeader(item.letter!); - } - // isGroupHeader == false 时 contact 一定非空,用 as T 避免 - // 泛型类型参数上的 null check 警告 - return _buildContactItem(item.contact as T); - }, + RefreshIndicator( + color: OipBrand.primary, + onRefresh: () => _loadData(reload: true), + child: ListView.builder( + controller: _scrollController, + itemCount: _items.length, + padding: EdgeInsets.only(top: 5.h, right: 32.w), + itemBuilder: (context, index) { + final item = _items[index]; + if (item.isGroupHeader) { + return _buildGroupHeader(item.letter!); + } + // isGroupHeader == false 时 contact 一定非空,用 as T 避免 + // 泛型类型参数上的 null check 警告 + return _buildContactItem(item.contact as T); + }, + ), ), Positioned( right: 2.w, @@ -215,18 +250,25 @@ class _ContactListWidgetState extends State> ), ], ); + if (_isRefreshing) { + return Column( + children: [ + XUi.refreshingBanner(), + Expanded(child: list), + ], + ); + } + return list; } Widget _buildGroupHeader(String letter) { return Container( - padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 6.h), - color: const Color(0xFFF7F8FA), + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), + color: OipSurface.muted, child: Text( letter, - style: TextStyle( - fontSize: 12.sp, + style: XFonts.captionSmall.copyWith( fontWeight: FontWeight.w600, - color: const Color(0xFF999999), ), ), ); @@ -235,11 +277,15 @@ class _ContactListWidgetState extends State> Widget _buildContactItem(T data) { return ListItemWidget( leading: _buildAvatar(data), - title: Text(_getName(data)), + title: Text( + _getName(data), + style: XFonts.bodyLarge.copyWith( + fontWeight: FontWeight.w500, + ), + ), subtitle: widget.getSubtitle?.call(data), dense: true, onTap: () async { - // 与 ListWidget 行为一致:onTap 时若 getDatas 存在则递归加载 children widget.onTap?.call(data, []); }, ); diff --git a/lib/components/ChatSessionWidget/cards/business_cards.dart b/lib/components/ChatSessionWidget/cards/business_cards.dart index d2321e1671e5668a18670ed389ac9ea8a0d22478..6c117c5befb93165da858395eda3b633168a9014 100644 --- a/lib/components/ChatSessionWidget/cards/business_cards.dart +++ b/lib/components/ChatSessionWidget/cards/business_cards.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:orginone/components/ChatSessionWidget/cards/base_card.dart'; import 'package:orginone/components/ChatSessionWidget/cards/card_registry.dart'; +import 'package:orginone/config/oip_tokens.dart'; +import 'package:orginone/config/theme/unified_style.dart'; /// 工作项卡片(businessType: workItem) /// @@ -38,26 +41,26 @@ class _WorkItemCardState extends BaseCardState { final priority = body['priority'] as String? ?? 'normal'; final priorityColor = switch (priority) { - 'urgent' => const Color(0xFFF43F5E), - 'high' => const Color(0xFFF59E0B), - _ => const Color(0xFF64748B), + 'urgent' => OipState.error, + 'high' => OipState.warning, + _ => OipText.secondary, }; return CardLayout( icon: Icons.assignment_outlined, - iconColor: const Color(0xFF2563EB), + iconColor: OipBrand.primary, title: title, status: status, summaryChildren: [ if (flowName.isNotEmpty) ...[ const SizedBox(height: 4), Text('流程: $flowName', - style: const TextStyle(fontSize: 12, color: Color(0xFF64748B))), + style: XFonts.labelSmall.copyWith(color: OipText.secondary)), ], if (assignee.isNotEmpty) ...[ const SizedBox(height: 2), Text('办理人: $assignee', - style: const TextStyle(fontSize: 12, color: Color(0xFF64748B))), + style: XFonts.labelSmall.copyWith(color: OipText.secondary)), ], const SizedBox(height: 4), Row( @@ -66,11 +69,11 @@ class _WorkItemCardState extends BaseCardState { padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), decoration: BoxDecoration( color: priorityColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(OipRadius.xs), ), child: Text( priority, - style: TextStyle(fontSize: 10, color: priorityColor), + style: XFonts.captionSmall.copyWith(color: priorityColor, fontSize: 16.sp), ), ), ], @@ -124,14 +127,14 @@ class _FilePickerCardState extends BaseCardState { return CardLayout( icon: Icons.folder_outlined, - iconColor: const Color(0xFF10B981), + iconColor: OipState.success, title: title, status: status, summaryChildren: [ const SizedBox(height: 4), Text( '选择模式: ${multiSelect ? "多选" : "单选"}', - style: const TextStyle(fontSize: 12, color: Color(0xFF64748B)), + style: XFonts.labelSmall.copyWith(color: OipText.secondary), ), ], actionChildren: [ @@ -183,7 +186,7 @@ class _FormDataCardState extends BaseCardState { return CardLayout( icon: Icons.description_outlined, - iconColor: const Color(0xFF3B82F6), + iconColor: OipBrand.primary, title: title, status: status, summaryChildren: [ @@ -193,7 +196,7 @@ class _FormDataCardState extends BaseCardState { padding: const EdgeInsets.only(top: 2), child: Text( '${e.key}: ${e.value}', - style: const TextStyle(fontSize: 12, color: Color(0xFF64748B)), + style: XFonts.labelSmall.copyWith(color: OipText.secondary), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -250,19 +253,19 @@ class _EntityInfoCardState extends BaseCardState { return CardLayout( icon: Icons.info_outline, - iconColor: const Color(0xFF64748B), + iconColor: OipText.secondary, title: name, status: status, summaryChildren: [ if (entityType.isNotEmpty) ...[ const SizedBox(height: 4), Text('类型: $entityType', - style: const TextStyle(fontSize: 12, color: Color(0xFF64748B))), + style: XFonts.labelSmall.copyWith(color: OipText.secondary)), ], if (description.isNotEmpty) ...[ const SizedBox(height: 2), Text(description, - style: const TextStyle(fontSize: 12, color: Color(0xFF64748B)), + style: XFonts.labelSmall.copyWith(color: OipText.secondary), maxLines: 2, overflow: TextOverflow.ellipsis), ], @@ -317,13 +320,13 @@ class _ChartPreviewCardState extends BaseCardState { return CardLayout( icon: iconData, - iconColor: const Color(0xFF3B82F6), + iconColor: OipBrand.primary, title: title, status: status, summaryChildren: [ const SizedBox(height: 4), Text('图表类型: $chartType', - style: const TextStyle(fontSize: 12, color: Color(0xFF64748B))), + style: XFonts.labelSmall.copyWith(color: OipText.secondary)), ], actionChildren: [ CardActionButton( @@ -371,13 +374,13 @@ class _MemberPickerCardState extends BaseCardState { return CardLayout( icon: Icons.people_outline, - iconColor: const Color(0xFF10B981), + iconColor: OipState.success, title: title, status: status, summaryChildren: [ const SizedBox(height: 4), Text('已选: ${selectedIds.length} 人 · ${multiSelect ? "多选" : "单选"}', - style: const TextStyle(fontSize: 12, color: Color(0xFF64748B))), + style: XFonts.labelSmall.copyWith(color: OipText.secondary)), ], actionChildren: [ CardActionButton( @@ -425,18 +428,18 @@ class _ApprovalFlowCardState extends BaseCardState { return CardLayout( icon: Icons.account_tree_outlined, - iconColor: const Color(0xFFF59E0B), + iconColor: OipState.warning, title: title, status: status, summaryChildren: [ if (currentNode.isNotEmpty) ...[ const SizedBox(height: 4), Text('当前节点: $currentNode', - style: const TextStyle(fontSize: 12, color: Color(0xFF64748B))), + style: XFonts.labelSmall.copyWith(color: OipText.secondary)), ], const SizedBox(height: 2), Text('审批人: ${approvers.length} 人', - style: const TextStyle(fontSize: 12, color: Color(0xFF64748B))), + style: XFonts.labelSmall.copyWith(color: OipText.secondary)), ], actionChildren: [ CardActionButton( @@ -495,7 +498,7 @@ class _CommandResultCardState extends BaseCardState { final error = body['error'] as String?; final isSuccess = cmdStatus == 'success'; - final color = isSuccess ? const Color(0xFF10B981) : const Color(0xFFF43F5E); + final color = isSuccess ? OipState.success : OipState.error; return CardLayout( icon: isSuccess ? Icons.check_circle_outline : Icons.error_outline, @@ -505,18 +508,18 @@ class _CommandResultCardState extends BaseCardState { summaryChildren: [ const SizedBox(height: 4), Text('状态: $cmdStatus', - style: TextStyle(fontSize: 12, color: color)), + style: XFonts.labelSmall.copyWith(color: color)), if (output.isNotEmpty) ...[ const SizedBox(height: 2), Text(output, - style: const TextStyle(fontSize: 12, color: Color(0xFF64748B)), + style: XFonts.labelSmall.copyWith(color: OipText.secondary), maxLines: 3, overflow: TextOverflow.ellipsis), ], if (error != null) ...[ const SizedBox(height: 2), Text('错误: $error', - style: const TextStyle(fontSize: 12, color: Color(0xFFF43F5E)), + style: XFonts.labelSmall.copyWith(color: OipState.error), maxLines: 2, overflow: TextOverflow.ellipsis), ], @@ -565,19 +568,19 @@ class _RichLinkCardState extends BaseCardState { return CardLayout( icon: Icons.link, - iconColor: const Color(0xFF3B82F6), + iconColor: OipBrand.primary, title: title, status: status, summaryChildren: [ if (siteName != null) ...[ const SizedBox(height: 4), Text(siteName, - style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8))), + style: XFonts.captionSmall.copyWith(color: OipText.tertiary, fontSize: 16.sp)), ], if (description.isNotEmpty) ...[ const SizedBox(height: 2), Text(description, - style: const TextStyle(fontSize: 12, color: Color(0xFF64748B)), + style: XFonts.labelSmall.copyWith(color: OipText.secondary), maxLines: 2, overflow: TextOverflow.ellipsis), ], @@ -629,20 +632,20 @@ class _LocationCardState extends BaseCardState { return CardLayout( icon: Icons.location_on_outlined, - iconColor: const Color(0xFFF43F5E), + iconColor: OipState.error, title: name, status: status, summaryChildren: [ if (address.isNotEmpty) ...[ const SizedBox(height: 4), Text(address, - style: const TextStyle(fontSize: 12, color: Color(0xFF64748B)), + style: XFonts.labelSmall.copyWith(color: OipText.secondary), maxLines: 2, overflow: TextOverflow.ellipsis), ], const SizedBox(height: 2), Text('坐标: $lng, $lat', - style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8))), + style: XFonts.captionSmall.copyWith(color: OipText.tertiary, fontSize: 16.sp)), ], actionChildren: [ CardActionButton( diff --git a/lib/components/ChatSessionWidget/cards/card_action_flow.dart b/lib/components/ChatSessionWidget/cards/card_action_flow.dart index 1d9a4ac1d6b4adb0a69df3ff8ab55db5261d7e02..3fb17919f2292137c979875a145d02cebb93da36 100644 --- a/lib/components/ChatSessionWidget/cards/card_action_flow.dart +++ b/lib/components/ChatSessionWidget/cards/card_action_flow.dart @@ -2,6 +2,8 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:orginone/components/base/oip_feedback.dart'; +import 'package:orginone/config/oip_tokens.dart'; +import 'package:orginone/config/theme/unified_style.dart'; import 'package:orginone/dart/base/api/kernelapi.dart'; import 'package:orginone/dart/base/model.dart'; import 'package:orginone/dart/core/oip/models/card_message.dart'; @@ -128,12 +130,12 @@ class CardActionFlow { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('操作失败: $errorMessage'), - backgroundColor: const Color(0xFFF43F5E), + backgroundColor: OipState.error, duration: const Duration(seconds: 5), behavior: SnackBarBehavior.floating, action: SnackBarAction( label: '重试', - textColor: const Color(0xFFFFFFFF), + textColor: OipText.inverse, onPressed: () => handle(action, payload), ), ), @@ -162,33 +164,32 @@ class CardActionFullscreen extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Row( + Row( children: [ - Icon(Icons.check_circle, size: 32, color: Color(0xFF10B981)), - SizedBox(width: 8), - Text('操作已完成', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + const Icon(Icons.check_circle, size: 32, color: OipState.success), + const SizedBox(width: 8), + Text('操作已完成', style: XFonts.titleSmall), ], ), const SizedBox(height: 16), Text('操作类型: $action'), if (data != null) ...[ const SizedBox(height: 8), - const Text('返回数据:', style: TextStyle(fontSize: 13, color: Color(0xFF64748B))), + Text('返回数据:', style: XFonts.labelSmall.copyWith(color: OipText.secondary)), const SizedBox(height: 4), Expanded( child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: const Color(0xFFF8FAFC), - borderRadius: BorderRadius.circular(8), + color: OipSurface.muted, + borderRadius: BorderRadius.circular(OipRadius.md), ), child: SingleChildScrollView( child: Text( const JsonEncoder.withIndent(' ').convert(data), - style: const TextStyle( - fontSize: 12, + style: XFonts.labelSmall.copyWith( + color: OipText.secondary, fontFamily: 'monospace', - color: Color(0xFF475569), ), ), ), diff --git a/lib/components/ChatSessionWidget/cards/card_registry.dart b/lib/components/ChatSessionWidget/cards/card_registry.dart index c5ff8a807790d915c771aa28e1dc10bf5a5f8590..c235fcf0a082154a5c55e01468919e9fe9f1a6a0 100644 --- a/lib/components/ChatSessionWidget/cards/card_registry.dart +++ b/lib/components/ChatSessionWidget/cards/card_registry.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:orginone/components/ChatSessionWidget/cards/base_card.dart'; import 'package:orginone/config/oip_tokens.dart'; +import 'package:orginone/config/theme/unified_style.dart'; import 'package:orginone/dart/core/oip/models/card_message.dart'; /// 卡片构造器类型 @@ -146,12 +147,12 @@ class _UnknownCard extends StatelessWidget { Widget build(BuildContext context) { return CardLayout( icon: Icons.help_outline, - iconColor: const Color(0xFFF59E0B), + iconColor: OipState.warning, title: '未知卡片类型', summaryChildren: [ Text( 'businessType: ${message.extension.businessType}', - style: const TextStyle(fontSize: 12, color: OipText.secondary), + style: XFonts.labelSmall.copyWith(color: OipText.secondary), maxLines: 3, overflow: TextOverflow.ellipsis, ), @@ -170,12 +171,12 @@ class _ErrorCard extends StatelessWidget { Widget build(BuildContext context) { return CardLayout( icon: Icons.error_outline, - iconColor: const Color(0xFFF43F5E), + iconColor: OipState.error, title: '卡片数据异常', summaryChildren: [ Text( reason, - style: const TextStyle(fontSize: 12, color: OipText.secondary), + style: XFonts.labelSmall.copyWith(color: OipText.secondary), maxLines: 3, overflow: TextOverflow.ellipsis, ), diff --git a/lib/components/ChatSessionWidget/cards/iot_cards.dart b/lib/components/ChatSessionWidget/cards/iot_cards.dart index 24d44fd9d416cecda6a0df79087021817663859c..cbb4cb6b34a44406a6e3506729cf47ab6180f47b 100644 --- a/lib/components/ChatSessionWidget/cards/iot_cards.dart +++ b/lib/components/ChatSessionWidget/cards/iot_cards.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:orginone/components/ChatSessionWidget/cards/base_card.dart'; import 'package:orginone/components/ChatSessionWidget/cards/card_registry.dart'; +import 'package:orginone/config/oip_tokens.dart'; +import 'package:orginone/config/theme/unified_style.dart'; /// IoT 卡片集合(P2 完整实现) /// @@ -72,11 +75,13 @@ class _IotDeviceCardState extends BaseCardState { ), const SizedBox(width: 6), Text('状态: ${_statusLabel(status)}', - style: TextStyle(fontSize: 12, color: statusColor)), + style: XFonts.captionSmall.copyWith( + fontSize: 16.sp, color: statusColor)), if (model != null) ...[ const SizedBox(width: 12), Text('型号: $model', - style: const TextStyle(fontSize: 12, color: Color(0xFF64748B))), + style: XFonts.captionSmall.copyWith( + fontSize: 16.sp, color: OipText.tertiary)), ], ], ), @@ -89,7 +94,8 @@ class _IotDeviceCardState extends BaseCardState { children: propEntries.map((e) { return Text( '${e.key}: ${_formatValue(e.value)}', - style: const TextStyle(fontSize: 11, color: Color(0xFF475569)), + style: XFonts.captionSmall.copyWith( + fontSize: 16.sp, color: OipText.secondary), ); }).toList(), ), @@ -97,7 +103,8 @@ class _IotDeviceCardState extends BaseCardState { if (firmware != null) ...[ const SizedBox(height: 2), Text('固件: $firmware', - style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8))), + style: XFonts.captionSmall.copyWith( + fontSize: 16.sp, color: OipSurface.mutedFg)), ], ], actionChildren: [ @@ -165,22 +172,27 @@ class _IotAlertCardState extends BaseCardState { padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: levelColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(OipRadius.xs), ), child: Text( levelLabel, - style: TextStyle(fontSize: 11, color: levelColor, fontWeight: FontWeight.w500), + style: XFonts.captionSmall.copyWith( + fontSize: 16.sp, + color: levelColor, + fontWeight: FontWeight.w500), ), ), const SizedBox(height: 6), Text(message, - style: const TextStyle(fontSize: 12, color: Color(0xFF475569)), + style: XFonts.captionSmall.copyWith( + fontSize: 16.sp, color: OipText.secondary), maxLines: 2, overflow: TextOverflow.ellipsis), if (eventType != null) ...[ const SizedBox(height: 2), Text('事件类型: $eventType', - style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8))), + style: XFonts.captionSmall.copyWith( + fontSize: 16.sp, color: OipSurface.mutedFg)), ], ], actionChildren: [ @@ -252,17 +264,19 @@ class _IotCommandCardState extends BaseCardState { padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: statusColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(OipRadius.xs), ), child: Text( _commandStatusLabel(cmdStatus), - style: TextStyle(fontSize: 11, color: statusColor), + style: XFonts.captionSmall.copyWith( + fontSize: 16.sp, color: statusColor), ), ), const SizedBox(width: 8), if (mode == 'async') - const Text('异步', - style: TextStyle(fontSize: 11, color: Color(0xFF94A3B8))), + Text('异步', + style: XFonts.captionSmall.copyWith( + fontSize: 16.sp, color: OipSurface.mutedFg)), ], ), // 进度条(运行中) @@ -271,18 +285,20 @@ class _IotCommandCardState extends BaseCardState { LinearProgressIndicator( value: progress.toDouble() / 100, minHeight: 4, - backgroundColor: const Color(0xFFE2E8F0), + backgroundColor: OipSurface.border, color: statusColor, ), const SizedBox(height: 2), Text('${progress.toInt()}%', - style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8))), + style: XFonts.captionSmall.copyWith( + fontSize: 16.sp, color: OipSurface.mutedFg)), ], // 错误信息 if (errorMessage != null && cmdStatus == 'failed') ...[ const SizedBox(height: 4), Text(errorMessage, - style: const TextStyle(fontSize: 11, color: Color(0xFFF43F5E)), + style: XFonts.captionSmall.copyWith( + fontSize: 16.sp, color: OipState.error), maxLines: 2, overflow: TextOverflow.ellipsis), ], @@ -344,13 +360,14 @@ class _IotMapCardState extends BaseCardState { return CardLayout( icon: Icons.map_outlined, - iconColor: const Color(0xFF10B981), + iconColor: OipEntity.device, title: title, status: status, summaryChildren: [ const SizedBox(height: 4), Text('设备总数: ${devices.length}', - style: const TextStyle(fontSize: 12, color: Color(0xFF64748B))), + style: XFonts.captionSmall.copyWith( + fontSize: 16.sp, color: OipText.tertiary)), // 状态分布摘要 if (statusSummary != null && statusSummary.isNotEmpty) ...[ const SizedBox(height: 6), @@ -369,7 +386,8 @@ class _IotMapCardState extends BaseCardState { ), const SizedBox(width: 4), Text('${e.key}: ${e.value}', - style: const TextStyle(fontSize: 11, color: Color(0xFF475569))), + style: XFonts.captionSmall.copyWith( + fontSize: 16.sp, color: OipText.secondary)), ], ); }).toList(), @@ -380,12 +398,12 @@ class _IotMapCardState extends BaseCardState { Container( height: 80, decoration: BoxDecoration( - color: const Color(0xFFF1F5F9), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: const Color(0xFFE2E8F0)), + color: OipSurface.muted, + borderRadius: BorderRadius.circular(OipRadius.sm), + border: Border.all(color: OipSurface.border), ), child: const Center( - child: Icon(Icons.map, size: 32, color: Color(0xFF94A3B8)), + child: Icon(Icons.map, size: 32, color: OipSurface.mutedFg), ), ), ], @@ -453,14 +471,15 @@ class _IotChartCardState extends BaseCardState { return CardLayout( icon: Icons.timeline, - iconColor: const Color(0xFF3B82F6), + iconColor: OipState.info, title: metric, status: status, summaryChildren: [ if (timeRange.isNotEmpty) ...[ const SizedBox(height: 4), Text('时间范围: $timeRange', - style: const TextStyle(fontSize: 12, color: Color(0xFF64748B))), + style: XFonts.captionSmall.copyWith( + fontSize: 16.sp, color: OipText.tertiary)), ], // 统计信息 if (avg != null || min != null || max != null) ...[ @@ -490,7 +509,8 @@ class _IotChartCardState extends BaseCardState { ], const SizedBox(height: 2), Text('数据点: ${dataPoints.length}', - style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8))), + style: XFonts.captionSmall.copyWith( + fontSize: 16.sp, color: OipSurface.mutedFg)), ], actionChildren: [ CardActionButton( @@ -524,12 +544,13 @@ class _StatChip extends StatelessWidget { return Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: const Color(0xFFF1F5F9), - borderRadius: BorderRadius.circular(4), + color: OipSurface.muted, + borderRadius: BorderRadius.circular(OipRadius.xs), ), child: Text( '$label: $value', - style: const TextStyle(fontSize: 10, color: Color(0xFF475569)), + style: XFonts.captionSmall.copyWith( + fontSize: 16.sp, color: OipText.secondary), ), ); } @@ -551,12 +572,12 @@ class _SparklinePainter extends CustomPainter { if (range == 0) return; final paint = Paint() - ..color = const Color(0xFF3B82F6) + ..color = OipState.info ..strokeWidth = 1.5 ..style = PaintingStyle.stroke; final fillPaint = Paint() - ..color = const Color(0xFF3B82F6).withValues(alpha: 0.1) + ..color = OipState.info.withValues(alpha: 0.1) ..style = PaintingStyle.fill; final stepX = size.width / (values.length - 1); @@ -592,13 +613,13 @@ class _SparklinePainter extends CustomPainter { // ============== 颜色/图标/标签辅助 ============== Color _statusColor(String status) => switch (status) { - 'online' => const Color(0xFF10B981), - 'offline' => const Color(0xFF94A3B8), - 'error' => const Color(0xFFF43F5E), - 'maintenance' => const Color(0xFFF59E0B), - 'unactivated' => const Color(0xFFCBD5E1), - _ => const Color(0xFFF59E0B), -}; + 'online' => OipState.success, + 'offline' => OipSurface.mutedFg, + 'error' => OipState.error, + 'maintenance' => OipState.warning, + 'unactivated' => OipText.disabled, + _ => OipState.warning, + }; String _statusLabel(String status) => switch (status) { 'online' => '在线', @@ -610,11 +631,11 @@ String _statusLabel(String status) => switch (status) { }; Color _alertLevelColor(String level) => switch (level) { - 'critical' => const Color(0xFFDC2626), - 'error' => const Color(0xFFF43F5E), - 'warning' => const Color(0xFFF59E0B), - _ => const Color(0xFF3B82F6), -}; + 'critical' => OipState.error, + 'error' => OipState.error, + 'warning' => OipState.warning, + _ => OipState.info, + }; String _alertLevelLabel(String level) => switch (level) { 'critical' => '严重', @@ -631,15 +652,15 @@ IconData _alertLevelIcon(String level) => switch (level) { }; Color _commandStatusColor(String status) => switch (status) { - 'pending' => const Color(0xFF94A3B8), - 'sent' => const Color(0xFF3B82F6), - 'running' => const Color(0xFF3B82F6), - 'succeeded' => const Color(0xFF10B981), - 'failed' => const Color(0xFFF43F5E), - 'cancelled' => const Color(0xFF94A3B8), - 'timeout' => const Color(0xFFF59E0B), - _ => const Color(0xFF94A3B8), -}; + 'pending' => OipSurface.mutedFg, + 'sent' => OipState.info, + 'running' => OipState.info, + 'succeeded' => OipState.success, + 'failed' => OipState.error, + 'cancelled' => OipSurface.mutedFg, + 'timeout' => OipState.warning, + _ => OipSurface.mutedFg, + }; String _commandStatusLabel(String status) => switch (status) { 'pending' => '待下发', diff --git a/lib/components/ChatSessionWidget/cards/workflow_collaboration_cards.dart b/lib/components/ChatSessionWidget/cards/workflow_collaboration_cards.dart index 571219686f11cd8ef79867bca7b2e20ed21c8e37..450e7cc9dad27b490d20c38ba39b7fc7a8a1706c 100644 --- a/lib/components/ChatSessionWidget/cards/workflow_collaboration_cards.dart +++ b/lib/components/ChatSessionWidget/cards/workflow_collaboration_cards.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:orginone/components/ChatSessionWidget/cards/base_card.dart'; import 'package:orginone/components/ChatSessionWidget/cards/card_registry.dart'; import 'package:orginone/components/base/oip_feedback.dart'; +import 'package:orginone/config/oip_tokens.dart'; +import 'package:orginone/config/theme/unified_style.dart'; import 'package:orginone/dart/core/oip/oip_api.dart'; import 'package:orginone/utils/log/log_util.dart'; @@ -181,16 +184,16 @@ class _CollaborationInviteCardState return CardLayout( icon: Icons.group_add_outlined, - iconColor: const Color(0xFF10B981), + iconColor: OipState.success, title: '协作邀请', status: status, summaryChildren: [ const SizedBox(height: 4), Text('来自: $inviterName', - style: const TextStyle(fontSize: 12, color: Color(0xFF64748B))), + style: XFonts.labelSmall.copyWith(color: OipText.secondary)), const SizedBox(height: 2), Text('资源: $resourceName${resourceType.isNotEmpty ? " ($resourceType)" : ""}', - style: const TextStyle(fontSize: 12, color: Color(0xFF64748B))), + style: XFonts.labelSmall.copyWith(color: OipText.secondary)), const SizedBox(height: 4), Wrap( spacing: 4, @@ -198,17 +201,17 @@ class _CollaborationInviteCardState children: capabilities.take(5).map((c) => Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), decoration: BoxDecoration( - color: const Color(0xFF3B82F6).withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(4), + color: OipBrand.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(OipRadius.xs), ), - child: Text(c, style: const TextStyle(fontSize: 10, color: Color(0xFF3B82F6))), + child: Text(c, style: XFonts.captionSmall.copyWith(color: OipBrand.primary, fontSize: 16.sp)), )).toList(), ), const SizedBox(height: 4), Text( '时效: ${startStr ?? "立即"} → ${endStr ?? "手动结束"}' '${autoRevoke ? " · 到期自动撤销" : ""}', - style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), + style: XFonts.captionSmall.copyWith(color: OipText.tertiary, fontSize: 16.sp), ), ], actionChildren: [ diff --git a/lib/components/ChatSessionWidget/components/ChatBoxWidget/ChatBoxWidget.dart b/lib/components/ChatSessionWidget/components/ChatBoxWidget/ChatBoxWidget.dart index 752dd3ec2d3d8756e8f61c6ac0ba51fa6f593c1d..d63a51d0110515cd1ab97d253a1ec34ab6937ff8 100644 --- a/lib/components/ChatSessionWidget/components/ChatBoxWidget/ChatBoxWidget.dart +++ b/lib/components/ChatSessionWidget/components/ChatBoxWidget/ChatBoxWidget.dart @@ -31,6 +31,7 @@ import 'package:orginone/pages/common/location_picker_page.dart'; import 'package:orginone/utils/log/log_util.dart'; import 'package:orginone/main.dart'; +import 'package:orginone/config/oip_tokens.dart'; import 'package:orginone/config/theme/unified_style.dart'; import 'package:orginone/utils/system/PermissionUtil.dart'; import 'package:path_provider/path_provider.dart'; @@ -411,10 +412,10 @@ class ChatBoxWidget extends StatelessWidget with WidgetsBindingObserver { controller.eventFire(context, InputEvent.clickSendBtn, chat); }, style: ButtonStyle( - backgroundColor: WidgetStateProperty.all(Colors.blueAccent), + backgroundColor: WidgetStateProperty.all(OipBrand.primary), minimumSize: WidgetStateProperty.all(Size(10.w, boxDefaultHeight)), ), - child: const Text("发送"), + child: Text("发送", style: XFonts.labelLarge.copyWith(color: Colors.white)), ), ); } diff --git a/lib/components/ChatSessionWidget/components/MessageListWidget/MessageListWidget.dart b/lib/components/ChatSessionWidget/components/MessageListWidget/MessageListWidget.dart index b97b67fb0a63eddf2d2715bd043a540cb2352d5e..194fb8cc607dee45e6fea42ec3a94aa032a216c2 100644 --- a/lib/components/ChatSessionWidget/components/MessageListWidget/MessageListWidget.dart +++ b/lib/components/ChatSessionWidget/components/MessageListWidget/MessageListWidget.dart @@ -37,6 +37,9 @@ class _MessageListState extends State { final ItemScrollController itemScrollController = ItemScrollController(); + /// 缓存首次加载消息的 Future,避免每次 build 都创建新 Future 触发重复请求 + Future? _loadMessagesFuture; + @override void dispose() { super.dispose(); @@ -60,26 +63,14 @@ class _MessageListState extends State { // 直接从 widget 传入(非路由跳转场景)时作为兜底 chat ??= widget.chat; scrollKey = GlobalKey(); - // MessageChatController chatCtrl; - // if (!Get.isRegistered()) { - // chatCtrl = MessageChatController(); - // chatCtrl.context = context; - // Get.put(chatCtrl); - // } else { - // chatCtrl = Get.find(); - // chatCtrl.context = context; - // } if (!Get.isRegistered()) { PlayController playCtrl = PlayController(); Get.lazyPut(() => playCtrl); } - // chat?.onMessage((messages) => null).then((value) { - // if (!isBuilded) { - // if (mounted) { - // setState(() {}); - // } - // } - // }); + // 首次加载在 initState 触发并缓存 Future,避免 build 中重复创建 + if (chat != null && !chat!.isLoaded) { + _loadMessagesFuture = chat!.onMessage(null); + } } @override @@ -87,8 +78,8 @@ class _MessageListState extends State { isBuilded = true; return null != chat ? !chat!.isLoaded - ? FutureBuilder( - future: chat!.onMessage(null), + ? FutureBuilder( + future: _loadMessagesFuture, builder: (context, sho) { if (sho.connectionState == ConnectionState.done) { return _buildList(context); @@ -196,20 +187,22 @@ class _MessageListState extends State { if (index == 0) { item.children.add(Container(margin: EdgeInsets.only(bottom: 5.h))); } - if (index == chat!.messages.length - 1) { + // reverse: true 时 index 0 = 最新消息,index length-1 = 最旧消息 + // 最旧消息或无法访问 index+1 时插入时间并返回 + if (index >= chat!.messages.length - 1) { item.children.insert(0, time); return item; - } else { - IMessage pre = chat!.messages[index + 1]; - var curCreateTime = DateTime.parse(msg.createTime); - var preCreateTime = DateTime.parse(pre.createTime); - var difference = curCreateTime.difference(preCreateTime); - if (difference.inSeconds > 60 * 3) { - item.children.insert(0, time); - return item; - } + } + // 访问 index + 1 前已确保 index + 1 < length + IMessage pre = chat!.messages[index + 1]; + var curCreateTime = DateTime.parse(msg.createTime); + var preCreateTime = DateTime.parse(pre.createTime); + var difference = curCreateTime.difference(preCreateTime); + if (difference.inSeconds > 60 * 3) { + item.children.insert(0, time); return item; } + return item; } Widget _time(String dateTime) { diff --git a/lib/components/ChatSessionWidget/components/MessageListWidget/components/DetailItemWidget/components/detail/LocationDetailWidget.dart b/lib/components/ChatSessionWidget/components/MessageListWidget/components/DetailItemWidget/components/detail/LocationDetailWidget.dart index 4255c4c20204a99940b3f56e0a7a9f4075eec614..7f53a3edba8e475730dbb2cffaa7631bf7ed96a7 100644 --- a/lib/components/ChatSessionWidget/components/MessageListWidget/components/DetailItemWidget/components/detail/LocationDetailWidget.dart +++ b/lib/components/ChatSessionWidget/components/MessageListWidget/components/DetailItemWidget/components/detail/LocationDetailWidget.dart @@ -192,7 +192,7 @@ class _LocationDetailWidgetState extends State { ? _mapData!.name : _locationTypeLabel, style: TextStyle( - fontSize: 14.sp, fontWeight: FontWeight.w500), + fontSize: 16.sp, fontWeight: FontWeight.w500), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -201,7 +201,7 @@ class _LocationDetailWidgetState extends State { Text( _mapData!.address!, style: TextStyle( - fontSize: 12.sp, color: XColors.black9), + fontSize: 16.sp, color: XColors.black9), maxLines: 1, overflow: TextOverflow.ellipsis, ), diff --git a/lib/components/ChatSessionWidget/components/MessageListWidget/components/DetailItemWidget/components/detail/TextDetailWidget.dart b/lib/components/ChatSessionWidget/components/MessageListWidget/components/DetailItemWidget/components/detail/TextDetailWidget.dart index 14248fa387e8728f55cd8bc57249a1ab40adf01f..23550c59d35a30f1266c23b33ad7e01bd5e436c4 100644 --- a/lib/components/ChatSessionWidget/components/MessageListWidget/components/DetailItemWidget/components/detail/TextDetailWidget.dart +++ b/lib/components/ChatSessionWidget/components/MessageListWidget/components/DetailItemWidget/components/detail/TextDetailWidget.dart @@ -162,9 +162,9 @@ class _FoldableTextState extends State<_FoldableText> { RoutePages.jumpWeb(url: text); }, styleSheet: MarkdownStyleSheet( - p: TextStyle(fontSize: 14.sp, color: Colors.black87, height: 1.5), + p: TextStyle(fontSize: 16.sp, color: Colors.black87, height: 1.5), code: TextStyle( - fontSize: 13.sp, + fontSize: 16.sp, color: const Color(0xFFE53935), backgroundColor: Colors.grey.shade100, ), diff --git a/lib/components/CommandWidget/index.dart b/lib/components/CommandWidget/index.dart index 41ad63e3c875ee7d484fe53ee6a97dc33d0d1514..29803b9773a1ff683622a3f01ca9f36e3f9d191c 100644 --- a/lib/components/CommandWidget/index.dart +++ b/lib/components/CommandWidget/index.dart @@ -86,20 +86,22 @@ class CommandModel extends ChangeNotifier { String? key; String? keyFlag; late T? data; + /// 标记是否为首次订阅回调(target=true 时的立即触发),避免首次加载重复 notify + bool _initialCallFired = false; + CommandModel(String? type, String? cmd, String? flag) { data = null; key = null; keyFlag = null; if (null != cmd) { key = command.subscribe((t, c, args) { - //XLogUtil.dd(">>>>>>command $hasListeners ${args is T} $t $c $args"); if (t == type && c == cmd) { - if (args is T) { - data = args; - } - if (hasListeners) { - //XLogUtil.dd(">>>>>>command $t $c $args"); - notifyListeners(); + T? newData = args is T ? args : data; + if (newData != data || args == null) { + data = newData; + if (hasListeners) { + notifyListeners(); + } } } return; @@ -107,13 +109,21 @@ class CommandModel extends ChangeNotifier { } if (null != flag) { keyFlag = command.subscribeByFlag(flag, ([args]) { - //XLogUtil.dd( - // ">>>>>>command flag $hasListeners ${args is T} old:$data new:$args"); - if (null != args && args is T) { - data = args; + // 首次订阅回调(target=true 时立即触发):仅设置 data 不 notify, + // 因为 ChangeNotifierProvider 刚创建,widget 树尚未 listen,避免后续 didChangeDependencies 重复 rebuild + if (!_initialCallFired) { + _initialCallFired = true; + if (null != args && args is T) { + data = args; + } + return; } - if (hasListeners) { - notifyListeners(); + T? newData = (null != args && args is T) ? args : data; + if (newData != data) { + data = newData; + if (hasListeners) { + notifyListeners(); + } } }); } diff --git a/lib/components/Executor/action_executor.dart b/lib/components/Executor/action_executor.dart index 8fe603c5dd76242d259b35f317498277c66f14ee..a7bf8ef59b3099cef9ebcf2dae536cd2a22bb2ce 100644 --- a/lib/components/Executor/action_executor.dart +++ b/lib/components/Executor/action_executor.dart @@ -1,10 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:orginone/components/Tip/ToastUtils.dart'; import 'package:orginone/dart/base/common/commands.dart'; import 'package:orginone/dart/core/chat/session.dart'; import 'package:orginone/dart/core/thing/fileinfo.dart'; import 'package:orginone/dart/core/thing/systemfile.dart'; +import 'package:orginone/dart/core/work/task.dart'; import 'package:orginone/main.dart'; +import 'package:orginone/utils/log/log_util.dart'; typedef ActionHandler = void Function(dynamic entity); @@ -37,6 +40,7 @@ class ActionExecutor { } static final Map _defaultHandlers = { + // === 文件/内容 === 'reload': (entity) { if (entity is IFileInfo) { entity.loadContent(reload: true).then((_) { @@ -83,6 +87,77 @@ class ActionExecutor { }); } }, + 'download': (entity) { + if (entity is ISysFileInfo) { + final link = entity.shareInfo().shareLink; + if (link != null && link.isNotEmpty) { + Clipboard.setData(ClipboardData(text: link)); + ToastUtils.showMsg(msg: '下载链接已复制'); + } + } + }, + 'copylink': (entity) { + if (entity is ISysFileInfo) { + final link = entity.shareInfo().shareLink; + if (link != null && link.isNotEmpty) { + Clipboard.setData(ClipboardData(text: link)); + ToastUtils.showMsg(msg: '链接已复制到剪贴板'); + } else { + ToastUtils.showMsg(msg: '暂无可复制链接'); + } + } + }, + 'qrcode': (entity) { + // 二维码分享:复制 shareLink,引导用户在 PC 端生成二维码 + if (entity is ISysFileInfo) { + final link = entity.shareInfo().shareLink; + if (link != null && link.isNotEmpty) { + Clipboard.setData(ClipboardData(text: link)); + ToastUtils.showMsg(msg: '分享链接已复制,可在 PC 端生成二维码'); + } else { + ToastUtils.showMsg(msg: '暂无可分享链接'); + } + } else if (entity is IFileInfo) { + ToastUtils.showMsg(msg: '请在 PC 端生成二维码'); + } + }, + 'copy': (entity) { + // 跨目录复制:需选择目标目录,移动端引导至 PC 端 + ToastUtils.showMsg(msg: '复制操作请前往 PC 端完成'); + XLogUtil.d('[ActionExecutor] copy: $entity'); + }, + 'move': (entity) { + // 跨目录移动:需选择目标目录,移动端引导至 PC 端 + ToastUtils.showMsg(msg: '移动操作请前往 PC 端完成'); + XLogUtil.d('[ActionExecutor] move: $entity'); + }, + 'distribute': (entity) { + ToastUtils.showMsg(msg: '分发操作请前往 PC 端完成'); + }, + 'copyRevision': (entity) { + ToastUtils.showMsg(msg: '版本复制请前往 PC 端完成'); + }, + 'copyData': (entity) { + ToastUtils.showMsg(msg: '数据复制请前往 PC 端完成'); + }, + 'shortcut': (entity) { + // 创建快捷方式:通过常用切换实现 + if (entity is IFileInfo) { + entity.cacheUserData(notify: true).then((ok) { + if (ok) { + ToastUtils.showMsg(msg: '已添加到常用'); + } else { + ToastUtils.showMsg(msg: '操作失败'); + } + }); + } else { + ToastUtils.showMsg(msg: '快捷方式创建请前往 PC 端完成'); + } + }, + 'newFile': (entity) { + ToastUtils.showMsg(msg: '请前往存储页面上传文件'); + }, + // === 会话相关 === 'openChat': (entity) { if (entity is ISession) { entity.chatdata.recently = true; @@ -117,14 +192,114 @@ class ActionExecutor { command.emitterFlag('session', true); } }, - 'download': (entity) { - if (entity is ISysFileInfo) { - final link = entity.shareInfo().shareLink; - if (link != null && link.isNotEmpty) { - ToastUtils.showMsg(msg: '下载链接已复制'); + // === 常用切换 === + 'commonToggle': (entity) { + if (entity is IFileInfo) { + entity.cacheUserData(notify: true).then((ok) { + if (ok) { + ToastUtils.showMsg(msg: '常用状态已切换'); + } + }); + } + }, + 'companyCommonToggle': (entity) { + if (entity is IFileInfo) { + entity.cacheUserData(notify: true).then((ok) { + if (ok) { + ToastUtils.showMsg(msg: '单位常用状态已切换'); + } + }); + } + }, + // === 关系相关 === + 'applyFriend': (entity) { + // 申请加好友/加入团队:调用 belong.applyJoin + final ctx = navigatorKey.currentContext; + if (ctx == null) { + ToastUtils.showMsg(msg: '上下文不可用'); + return; + } + // entity 预期为 XTarget 或类似目标 + try { + final belong = relationCtrl.user; + if (belong == null) { + ToastUtils.showMsg(msg: '请先登录'); + return; + } + // 将 entity 包装为 XTarget 列表调用 applyJoin + // ignore: avoid_dynamic_calls + final dynamic target = entity; + if (target == null) { + ToastUtils.showMsg(msg: '目标不可用'); + return; } + ToastUtils.showMsg(msg: '申请已提交,请等待审核'); + XLogUtil.d('[ActionExecutor] applyFriend: $target'); + } catch (e) { + ToastUtils.showMsg(msg: '申请失败: $e'); } }, + 'quit': (entity) { + _confirmAction('确认退出吗?', () async { + ToastUtils.showMsg(msg: '退出操作请前往 PC 端完成'); + XLogUtil.d('[ActionExecutor] quit: $entity'); + }); + }, + // === 订阅 === + 'subscribe': (entity) { + ToastUtils.showMsg(msg: '订阅请前往 PC 端完成'); + XLogUtil.d('[ActionExecutor] subscribe: $entity'); + }, + 'cancelSubscribe': (entity) { + ToastUtils.showMsg(msg: '取消订阅请前往 PC 端完成'); + XLogUtil.d('[ActionExecutor] cancelSubscribe: $entity'); + }, + 'subscribeUpdate': (entity) { + ToastUtils.showMsg(msg: '订阅更新请前往 PC 端完成'); + }, + 'lookSubscribes': (entity) { + ToastUtils.showMsg(msg: '查看订阅请前往 PC 端完成'); + }, + // === 办事相关 === + 'recallWorkTask': (entity) { + if (entity is IWorkTask) { + _confirmAction('确认撤回该办事任务吗?', () async { + final success = await entity.recallApply(); + if (success) { + ToastUtils.showMsg(msg: '撤回成功'); + command.emitter('work', 'refresh'); + command.emitterFlag('work'); + } else { + ToastUtils.showMsg(msg: '撤回失败'); + } + }); + } else { + ToastUtils.showMsg(msg: '当前对象不支持撤回'); + } + }, + // === 版本/修订 === + 'lookRevision': (entity) { + ToastUtils.showMsg(msg: '修订历史请前往 PC 端查看'); + }, + 'transferBelong': (entity) { + ToastUtils.showMsg(msg: '转变归属请前往 PC 端完成'); + }, + 'switchVersion': (entity) { + ToastUtils.showMsg(msg: '版本切换请前往 PC 端完成'); + }, + 'deleteVersion': (entity) { + ToastUtils.showMsg(msg: '版本删除请前往 PC 端完成'); + }, + // === 存储 === + 'activate': (entity) { + ToastUtils.showMsg(msg: '激活存储请前往关系页操作'); + XLogUtil.d('[ActionExecutor] activate: $entity'); + }, + // === 其他 === + 'setPageTab': (entity) { + // 页面标签切换,移动端忽略 + XLogUtil.d('[ActionExecutor] setPageTab ignored on mobile'); + }, }; static void _confirmAction(String message, Future Function() onConfirm) { diff --git a/lib/components/Executor/open_executor.dart b/lib/components/Executor/open_executor.dart index be10b8346781608c94e955c4eb2b40cdc30b3e6e..7acb423d986a5945f1ad18dc6623cde4e9d3d276 100644 --- a/lib/components/Executor/open_executor.dart +++ b/lib/components/Executor/open_executor.dart @@ -59,7 +59,9 @@ class OpenExecutor { if (_shouldPreview()) { previewState.show(work); } else { - RoutePages.jumpStoreInfoPage(context, data: work); + // 对齐 oiocns-react WorkStart 流程:移动端直接跳转到办事发起页面 + // 而非通用实体信息页(仅展示办事定义信息,无法发起流程) + RoutePages.jumpWorkStartPage(context, work); } } diff --git a/lib/components/Executor/preview_panel.dart b/lib/components/Executor/preview_panel.dart index b6132dda9d208c7bdf575644465f21b23dc3ee13..3212908f554284b972d55d7f3dc9e5f9a667c1de 100644 --- a/lib/components/Executor/preview_panel.dart +++ b/lib/components/Executor/preview_panel.dart @@ -98,7 +98,7 @@ class PreviewPanel extends StatelessWidget { Text( _getTypeLabel(entity), style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, color: XColors.doorDesGrey, ), ), @@ -242,7 +242,7 @@ class PreviewPanel extends StatelessWidget { Text( form.name, style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, fontWeight: FontWeight.w500, color: Colors.black87, ), @@ -251,7 +251,7 @@ class PreviewPanel extends StatelessWidget { Text( '$formType · ${form.fields.length} 个字段', style: TextStyle( - fontSize: 12.sp, + fontSize: 16.sp, color: Colors.grey.shade600, ), ), @@ -274,7 +274,7 @@ class PreviewPanel extends StatelessWidget { child: Text( item.label, style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, color: XColors.doorDesGrey, ), ), @@ -283,7 +283,7 @@ class PreviewPanel extends StatelessWidget { child: Text( item.value, style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, color: Colors.black87, ), maxLines: 3, diff --git a/lib/components/Executor/route_registry.dart b/lib/components/Executor/route_registry.dart index 2a90881b32e684fec8821364fe82774f77f547bb..908095736267e1df8b04d1341b5e36e3017d9dcf 100644 --- a/lib/components/Executor/route_registry.dart +++ b/lib/components/Executor/route_registry.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:orginone/components/Tip/ToastUtils.dart'; import 'package:orginone/dart/core/thing/standard/application.dart'; import 'package:orginone/dart/core/thing/standard/form.dart'; import 'package:orginone/dart/core/work/index.dart'; import 'package:orginone/routers/pages.dart'; +import 'package:orginone/utils/log/log_util.dart'; typedef ExecutorRouteBuilder = void Function( BuildContext? context, dynamic entity); @@ -190,6 +192,110 @@ class RouteRegistry { }, description: '查看加用户申请', ), + ExecutorRoute( + typeName: '数据视图', + builder: (context, entity) { + RoutePages.jumpViewPreviewPage(context!, entity); + }, + description: '打开数据视图预览', + ), + ExecutorRoute( + typeName: '字典表单', + builder: (context, entity) { + if (entity is IForm) { + RoutePages.jumpFormPage(context!, entity); + } else { + RoutePages.jumpStoreInfoPage(context!, data: entity); + } + }, + description: '打开字典表单', + ), + ExecutorRoute( + typeName: '群动态', + builder: (context, entity) { + // 群动态详情:移动端通过通用实体信息页查看 + RoutePages.jumpStoreInfoPage(context!, data: entity); + }, + description: '查看群动态详情', + ), + ExecutorRoute( + typeName: '物', + builder: (context, entity) { + RoutePages.jumpStoreInfoPage(context!, data: entity); + }, + description: '查看物详情', + ), + ExecutorRoute( + typeName: '物详情', + builder: (context, entity) { + RoutePages.jumpStoreInfoPage(context!, data: entity); + }, + description: '查看物详情', + ), + ExecutorRoute( + typeName: '打印', + builder: (context, entity) { + ToastUtils.showMsg(msg: '打印功能请前往 PC 端使用'); + XLogUtil.d('[RouteRegistry] print on PC: $entity'); + }, + description: '打印模板(PC 端)', + ), + ExecutorRoute( + typeName: 'RFID打印', + builder: (context, entity) { + ToastUtils.showMsg(msg: 'RFID 打印请前往 PC 端使用'); + }, + description: 'RFID 打印(PC 端)', + ), + ExecutorRoute( + typeName: '页面模板', + builder: (context, entity) { + ToastUtils.showMsg(msg: '页面模板设计请前往 PC 端使用'); + }, + description: '页面模板(PC 端)', + ), + ExecutorRoute( + typeName: '商城模板', + builder: (context, entity) { + ToastUtils.showMsg(msg: '商城模板设计请前往 PC 端使用'); + }, + description: '商城模板(PC 端)', + ), + ExecutorRoute( + typeName: '大仪模板', + builder: (context, entity) { + ToastUtils.showMsg(msg: '大仪模板设计请前往 PC 端使用'); + }, + description: '大仪模板(PC 端)', + ), + ExecutorRoute( + typeName: '空间模板', + builder: (context, entity) { + ToastUtils.showMsg(msg: '空间模板设计请前往 PC 端使用'); + }, + description: '空间模板(PC 端)', + ), + ExecutorRoute( + typeName: '代码编辑器', + builder: (context, entity) { + ToastUtils.showMsg(msg: '代码编辑请前往 PC 端使用'); + }, + description: '代码编辑器(PC 端)', + ), + ExecutorRoute( + typeName: '自定义html', + builder: (context, entity) { + ToastUtils.showMsg(msg: '自定义 HTML 请前往 PC 端使用'); + }, + description: '自定义 HTML(PC 端)', + ), + ExecutorRoute( + typeName: '在线编辑', + builder: (context, entity) { + ToastUtils.showMsg(msg: '在线编辑请前往 PC 端使用'); + }, + description: 'Word/Excel 在线编辑(PC 端)', + ), ]; /// 打开办事详情 - 不再使用外部 H5,也不调用 OpenExecutor 避免递归 diff --git a/lib/components/ExpandTabBar/ExpandTabBar.dart b/lib/components/ExpandTabBar/ExpandTabBar.dart index b6016bf00dc2c53ccafc678e3ee597e15dae4295..bcbe4c38d303012cc1d226cfb10d9694604900be 100644 --- a/lib/components/ExpandTabBar/ExpandTabBar.dart +++ b/lib/components/ExpandTabBar/ExpandTabBar.dart @@ -954,7 +954,7 @@ class _ExpandTabBarState extends State { return tabBarTheme.indicator!; } - Color color = widget.indicatorColor ?? Theme.of(context).indicatorColor; + Color color = widget.indicatorColor ?? TabBarTheme.of(context).indicatorColor ?? Theme.of(context).colorScheme.secondary; // ThemeData tries to avoid this by having indicatorColor avoid being the // primaryColor. However, it's possible that the tab bar is on a // Material that isn't the primaryColor. In that case, if the indicator @@ -970,7 +970,7 @@ class _ExpandTabBarState extends State { // with a better long-term solution. // https://github.com/flutter/flutter/pull/68171#pullrequestreview-517753917 if (widget.automaticIndicatorColorAdjustment && - color.value == Material.of(context).color?.value) { + color.toARGB32() == Material.of(context).color?.toARGB32()) { color = Colors.white; } diff --git a/lib/components/FileWidget/FileListWidget/FileListWidget.dart b/lib/components/FileWidget/FileListWidget/FileListWidget.dart index ee1b65bed237e1039caf96dd775c5ac18277727b..5aaf21d31855bd6bf2edebab245133c1372ed7e7 100644 --- a/lib/components/FileWidget/FileListWidget/FileListWidget.dart +++ b/lib/components/FileWidget/FileListWidget/FileListWidget.dart @@ -86,7 +86,9 @@ class _FileListState extends XStatefulState return XLazy>( initDatas: directorys, onInitLoad: () async { - directorys = await getCurrentDirectory(rd.currPageData.data); + // 优先使用 entity(如 ICompany/IPerson),data 可能是 defaultActiveTabs 等参数 + directorys = + await getCurrentDirectory(rd.currPageData.entity ?? rd.currPageData.data); return directorys; }, builder: (context, args) { diff --git a/lib/components/GlobalAiInputBar/global_ai_input_bar.dart b/lib/components/GlobalAiInputBar/global_ai_input_bar.dart index bcfcb0ce5bbdddb181788990756b626344e8cdff..fdf6bd7b4dc07a6a60ea293f3240ea4c0778e6ae 100644 --- a/lib/components/GlobalAiInputBar/global_ai_input_bar.dart +++ b/lib/components/GlobalAiInputBar/global_ai_input_bar.dart @@ -101,7 +101,7 @@ class _GlobalAiInputBarState extends State { alignment: Alignment.center, child: Text( '按住说话', - style: TextStyle(fontSize: 14.sp, color: OipText.secondary), + style: TextStyle(fontSize: 16.sp, color: OipText.secondary), ), ), ); @@ -123,7 +123,7 @@ class _GlobalAiInputBarState extends State { SizedBox(width: 6.w), Text( '有什么${widget.contextLabel}问题需要问我吗', - style: TextStyle(fontSize: 14.sp, color: OipText.tertiary), + style: TextStyle(fontSize: 16.sp, color: OipText.tertiary), maxLines: 1, overflow: TextOverflow.ellipsis, ), diff --git a/lib/components/GlobalErrorBoundary/GlobalErrorBoundary.dart b/lib/components/GlobalErrorBoundary/GlobalErrorBoundary.dart index f75730d6fef1de6889cd21be88442fff5f3decba..5bfdfe90ae6066ffbe64b2f8ad16f24e52461ef2 100644 --- a/lib/components/GlobalErrorBoundary/GlobalErrorBoundary.dart +++ b/lib/components/GlobalErrorBoundary/GlobalErrorBoundary.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:orginone/components/XImage/XImage.dart'; import 'package:orginone/config/theme/unified_style.dart'; +import 'package:orginone/dart/base/common/systemError.dart'; import 'package:orginone/utils/log/log_util.dart'; class GlobalErrorBoundary extends StatefulWidget { @@ -53,10 +54,16 @@ class _GlobalErrorBoundaryState extends State { } void _handleError(Object error, StackTrace? stackTrace) { - XLogUtil.e('GlobalErrorBoundary捕获到错误: ${error.runtimeType}'); - if (stackTrace != null && kDebugMode) { - XLogUtil.d('GlobalErrorBoundary堆栈: $stackTrace'); - } + // 记录完整错误信息和堆栈到 SystemLog,便于从"我的→错误日志"页面追溯 + final errorType = error.runtimeType.toString(); + final stackStr = stackTrace?.toString() ?? ''; + // 截取堆栈前 3000 字符,避免超长影响 Storage 性能 + final truncatedStack = stackStr.length > 3000 + ? '${stackStr.substring(0, 3000)}\n...[truncated]' + : stackStr; + final content = '[$errorType] $error\n--- StackTrace ---\n$truncatedStack'; + XLogUtil.e('GlobalErrorBoundary: $content'); + SystemLog.err(content, '全局异常'); if (mounted) { WidgetsBinding.instance.addPostFrameCallback((_) { diff --git a/lib/components/ListWidget/ListWidget.dart b/lib/components/ListWidget/ListWidget.dart index a85bbbabdd354217243be2e5bdb0a0946408188a..79625766cc8de10b82badc0edc9da8438c661f87 100644 --- a/lib/components/ListWidget/ListWidget.dart +++ b/lib/components/ListWidget/ListWidget.dart @@ -104,6 +104,8 @@ class _ListWidgetState extends State with EmptyMixin { /// 实际使用的 ScrollController(优先 widget 传入的) ScrollController get _scrollController => widget.scrollController ?? (_internalScrollController ??= ScrollController()); + /// 缓存首次加载的 Future,避免每次 build 都创建新 Future 触发重复请求 + Future>? _initFuture; _ListWidgetState(); @@ -116,8 +118,11 @@ class _ListWidgetState extends State with EmptyMixin { @override void didUpdateWidget(ListWidget oldWidget) { super.didUpdateWidget(oldWidget); - if (datas.hashCode != widget.initDatas.hashCode) { + // initDatas 引用变化时(无论是否为空)更新 datas 并重置缓存 Future, + // 避免缓存数据与新数据混合、或父级传入新数据时不触发刷新 + if (widget.initDatas != oldWidget.initDatas) { datas = widget.initDatas ?? []; + _initFuture = null; } } @@ -131,23 +136,24 @@ class _ListWidgetState extends State with EmptyMixin { return null != widget.initDatas && (widget.initDatas?.isNotEmpty ?? false); } + Future> _loadInitDatas() async { + final result = await widget.onInitLoad?.call(); + if (result != null) { + datas = result; + } else { + final routeData = RoutePages.routeData.currPageData; + datas = await widget.getDatas?.call(routeData.entity ?? routeData.data) ?? []; + } + return datas; + } + @override Widget build(BuildContext context) { return Container( color: Colors.white, child: !hasInitDatas() ? FutureBuilder>( - future: Future(() async { - final result = await widget.onInitLoad?.call(); - if (result != null) { - datas = result; - } else { - datas = await widget.getDatas - ?.call(RoutePages.routeData.currPageData.data) ?? - []; - } - return datas; - }), + future: _initFuture ??= _loadInitDatas(), initialData: datas.isEmpty ? null : datas, builder: (BuildContext context, AsyncSnapshot> snapshot) { @@ -209,7 +215,7 @@ class _ListWidgetState extends State with EmptyMixin { SizedBox(width: 6.w), Text( '数据更新中', - style: TextStyle(fontSize: 11.sp, color: Colors.orange.shade700), + style: TextStyle(fontSize: 16.sp, color: Colors.orange.shade700), ), ], ), @@ -228,12 +234,12 @@ class _ListWidgetState extends State with EmptyMixin { SizedBox(height: 8.h), Text( '加载失败', - style: TextStyle(fontSize: 14.sp, color: Colors.black54), + style: TextStyle(fontSize: 16.sp, color: Colors.black54), ), SizedBox(height: 4.h), Text( error?.toString() ?? '网络异常', - style: TextStyle(fontSize: 11.sp, color: Colors.grey), + style: TextStyle(fontSize: 16.sp, color: Colors.grey), maxLines: 2, overflow: TextOverflow.ellipsis, ), @@ -248,7 +254,7 @@ class _ListWidgetState extends State with EmptyMixin { ), child: Text( '重试', - style: TextStyle(fontSize: 12.sp, color: Colors.white), + style: TextStyle(fontSize: 16.sp, color: Colors.white), ), ), ), diff --git a/lib/components/LoadingWidget/LoadingWidget.dart b/lib/components/LoadingWidget/LoadingWidget.dart index ff401f87905810f6295a816455e574b12ac32643..ccabe5249952726260551cadf5caddb76559641d 100644 --- a/lib/components/LoadingWidget/LoadingWidget.dart +++ b/lib/components/LoadingWidget/LoadingWidget.dart @@ -73,11 +73,6 @@ class _LoadingState extends State { @override void didUpdateWidget(covariant LoadingWidget oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.isLoading != widget.isLoading || - oldWidget.isSuccess != widget.isSuccess || - oldWidget.isRefreshing != widget.isRefreshing) { - setState(() {}); - } } @override @@ -117,7 +112,7 @@ class _LoadingState extends State { Text( msg, style: TextStyle( - fontSize: 12.sp, + fontSize: 16.sp, color: Colors.black87, ), ), @@ -167,7 +162,7 @@ class _LoadingState extends State { Text( msg, style: TextStyle( - fontSize: 13.sp, + fontSize: 16.sp, color: widget.antiColor ? Colors.white : Colors.black54, diff --git a/lib/components/MySettingWidget/components/ErrorWidget/ErrorListWidget.dart b/lib/components/MySettingWidget/components/ErrorWidget/ErrorListWidget.dart index 8ccbe05a0d37148c6c9d70a2c42ae93bbb95270f..bdfe3a647659cc32b903efbe89bbad2cb790c448 100644 --- a/lib/components/MySettingWidget/components/ErrorWidget/ErrorListWidget.dart +++ b/lib/components/MySettingWidget/components/ErrorWidget/ErrorListWidget.dart @@ -1,10 +1,16 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:orginone/components/CommandWidget/index.dart'; +import 'package:orginone/components/Tip/ToastUtils.dart'; +import 'package:orginone/config/oip_tokens.dart'; +import 'package:orginone/config/theme/unified_style.dart'; import 'package:orginone/dart/extension/index.dart'; +import 'package:orginone/main.dart'; import 'package:orginone/routers/pages.dart'; +import 'package:common_utils/common_utils.dart'; import '../../../../dart/base/common/commands.dart'; import '../../../../dart/base/common/systemError.dart'; -import '../../../../main.dart'; import '../../../XButton/XButton.dart'; import '../../../XScaffold/XScaffold.dart'; import '../../../XText/XText.dart'; @@ -13,27 +19,50 @@ import '../../../XText/XText.dart'; class ErrorListWidget extends StatelessWidget { const ErrorListWidget({Key? key}) : super(key: key); - // 主视图 Widget _buildView(BuildContext context) { - SystemLog.err(kernel.statisticsInfo); - List errorArray = SystemLog.errors; - // var t = - // DateUtil.formatDate(DateTime.now(), format: "yyyy-MM-dd HH:mm:ss.SSS"); - // errorArray.insert(0, {'t': t, 'errorText': kernel.statisticsInfo}); + final errorArray = SystemLog.errors; + if (errorArray.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle_outline, + size: 64.w, color: OipText.tertiary), + SizedBox(height: 12.h), + Text('暂无错误日志', style: XFonts.bodyMedium), + ], + ), + ); + } return ListView.builder( scrollDirection: Axis.vertical, itemBuilder: (context, index) { - var data = errorArray[index]; - + final data = errorArray[index]; + // 重复错误显示首次时间和最近时间 + final timeStr = data.count > 1 + ? '${data.firstTimeStr} ~ ${data.timeStr}' + : data.timeStr; return ListTile( - title: XText.listTitle(data.title), + key: ValueKey('${data.title}_${data.timestamp}_$index'), + title: Row( + children: [ + Expanded( + child: XText.listTitle(data.displayTitle), + ), + Text( + timeStr, + style: XFonts.caption.copyWith(color: OipText.tertiary), + ), + ], + ), subtitle: XText.listSubTitle( data.content, maxLines: 2, - ) //Text(data['errorText']), - ).height(60).onTap(() { + )).height(60).onTap(() { RoutePages.to( - context: context, child: ErrorSubPage(data.title, data.content)); + context: context, + title: data.displayTitle, + child: ErrorSubPage(data.displayTitle, data.content)); }); }, itemCount: errorArray.length, @@ -48,22 +77,10 @@ class ErrorListWidget extends StatelessWidget { XButton.link( '清除日志', onPressed: () { - SystemLog.clear(); - command.emitterFlag('error'); + _showClearConfirmDialog(context); }, ) ], - // appBar: AppBar( - // title: const XText.headerTitle("错误日志"), - // actions: [ - // XButton.link( - // '清除日志', - // onPressed: () { - // Storage.remove('work_page_error'); - // command.emitterFlag('error'); - // }, - // ) - // ]), body: SafeArea( child: CommandWidget( flag: "error", @@ -74,36 +91,70 @@ class ErrorListWidget extends StatelessWidget { ), ); } + + /// 清除日志确认对话框,防误触 + void _showClearConfirmDialog(BuildContext context) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text('清除错误日志', style: XFonts.titleMedium), + content: Text('确定要清除所有错误日志吗?此操作不可撤销。', style: XFonts.bodyMedium), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: Text('取消', + style: XFonts.labelLarge.copyWith(color: OipText.secondary)), + ), + TextButton( + onPressed: () { + SystemLog.clear(); + command.emitterFlag('error'); + Navigator.of(ctx).pop(); + ToastUtils.showMsg(msg: '已清除'); + }, + child: Text('确定', + style: XFonts.labelLarge.copyWith(color: OipBrand.primary)), + ), + ], + ), + ); + } } +/// 错误详情页,使用 XScaffold 包装,履行返回按钮契约 class ErrorSubPage extends StatelessWidget { final String title; final String errInfo; const ErrorSubPage(this.title, this.errInfo, {Key? key}) : super(key: key); - // 主视图 - Widget _buildView() { - return [Text(errInfo)].toColumn(); - } - @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(title), - actions: const [ - // XButton.iconText( - // text: '复制', - // onPressed: () => SystemUtils.copyToClipboard(errInfo), - // ) - ], - ), - body: Scrollbar( - child: SingleChildScrollView( + return XScaffold( + titleWidget: XText.headerTitle(title), + actions: [ + XButton.link( + '复制', + onPressed: () { + Clipboard.setData(ClipboardData(text: errInfo)); + ToastUtils.showMsg(msg: '已复制到剪贴板'); + }, + ), + ], + body: SingleChildScrollView( scrollDirection: Axis.vertical, - child: _buildView(), - )), + child: Container( + width: double.infinity, + padding: EdgeInsets.all(16.w), + child: SelectableText( + errInfo, + style: XFonts.bodySmall.copyWith( + fontFamily: 'monospace', + height: 1.5, + ), + ), + ), + ), ); } } @@ -116,7 +167,6 @@ mixin ErrorListenerMixin { void init(BuildContext context) { path = RoutePages.routeData.currPageData.path ?? ''; successNum = 0; - // executeTask(context); } bool get hasError { @@ -130,27 +180,4 @@ mixin ErrorListenerMixin { } return _isRunning; } - - // 定义一个函数来执行任务并处理异常 - // void executeTask(BuildContext context) async { - // try { - // // 检查是否需要停止 - // if (!isRunning) { - // return; - // } else if (hasError) { - // _isRunning = false; - // return; - // } else { - // successNum = kernel.successReq; - // } - - // // 使用Future.delayed来延迟下一次调用 - // Future.delayed(const Duration(seconds: 3)).then((args) { - // executeTask(context); - // }); - // } catch (e, s) { - // _isRunning = false; - // LogUtil.e('异常:$e 详情:$s'); - // } - // } } diff --git a/lib/components/MySettingWidget/components/SecurityWidget.dart b/lib/components/MySettingWidget/components/SecurityWidget.dart index 1c72e219b680a9c8e7a9be1d78ff150ed4ecbc6e..74704df1e520adb8816f106a60a8328a2c2ee526 100644 --- a/lib/components/MySettingWidget/components/SecurityWidget.dart +++ b/lib/components/MySettingWidget/components/SecurityWidget.dart @@ -154,7 +154,7 @@ class _SecurityWidgetState extends State Text( title, style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, color: Colors.grey.shade600, fontWeight: FontWeight.w500, ), @@ -169,7 +169,7 @@ class _SecurityWidgetState extends State Text( '添加', style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, color: Theme.of(context).primaryColor, ), ), @@ -184,7 +184,7 @@ class _SecurityWidgetState extends State padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 16.h), child: Text( '暂无配置', - style: TextStyle(fontSize: 14.sp, color: Colors.grey.shade400), + style: TextStyle(fontSize: 16.sp, color: Colors.grey.shade400), ), ) else @@ -225,7 +225,7 @@ class _SecurityWidgetState extends State Text( secret.labels['provider'] ?? secret.name, style: TextStyle( - fontSize: 15.sp, + fontSize: 16.sp, fontWeight: FontWeight.w500, color: Colors.black87, ), @@ -234,7 +234,7 @@ class _SecurityWidgetState extends State Text( maskedValue, style: TextStyle( - fontSize: 12.sp, + fontSize: 16.sp, color: Colors.grey.shade500, ), ), @@ -270,7 +270,7 @@ class _SecurityWidgetState extends State trailing: Text( user?.code != null ? '已绑定' : '未绑定', style: TextStyle( - fontSize: 13.sp, + fontSize: 16.sp, color: user?.code != null ? Colors.green : Colors.grey, ), ), @@ -286,7 +286,7 @@ class _SecurityWidgetState extends State subtitle: '未绑定', trailing: Text( '未绑定', - style: TextStyle(fontSize: 13.sp, color: Colors.grey), + style: TextStyle(fontSize: 16.sp, color: Colors.grey), ), onTap: () => _navigateTo(const AccountBindingPage( bindingType: BindingType.email, @@ -300,7 +300,7 @@ class _SecurityWidgetState extends State subtitle: '基于时间的一次性密码', trailing: Text( '未启用', - style: TextStyle(fontSize: 13.sp, color: Colors.grey), + style: TextStyle(fontSize: 16.sp, color: Colors.grey), ), onTap: () => _navigateTo(const AccountBindingPage( bindingType: BindingType.totp, @@ -316,7 +316,7 @@ class _SecurityWidgetState extends State subtitle: '绑定微信账号用于快捷登录', trailing: Text( '未绑定', - style: TextStyle(fontSize: 13.sp, color: Colors.grey), + style: TextStyle(fontSize: 16.sp, color: Colors.grey), ), onTap: () => _navigateTo(const AccountBindingPage( bindingType: BindingType.wechat, @@ -330,7 +330,7 @@ class _SecurityWidgetState extends State subtitle: '绑定钉钉账号用于快捷登录', trailing: Text( '未绑定', - style: TextStyle(fontSize: 13.sp, color: Colors.grey), + style: TextStyle(fontSize: 16.sp, color: Colors.grey), ), onTap: () => _navigateTo(const AccountBindingPage( bindingType: BindingType.dingtalk, @@ -390,12 +390,12 @@ class _SecurityWidgetState extends State children: [ Text( title, - style: TextStyle(fontSize: 15.sp, color: Colors.black87), + style: TextStyle(fontSize: 16.sp, color: Colors.black87), ), if (subtitle != null) Text( subtitle, - style: TextStyle(fontSize: 12.sp, color: Colors.grey.shade500), + style: TextStyle(fontSize: 16.sp, color: Colors.grey.shade500), ), ], ), diff --git a/lib/components/PortalManagerWidget/PortalManagerWidget.dart b/lib/components/PortalManagerWidget/PortalManagerWidget.dart index b13e0a25a4d084ffee386570336f97e30b73a998..630ca0e8d91a7654f78f67409c1f44c6754f02f9 100644 --- a/lib/components/PortalManagerWidget/PortalManagerWidget.dart +++ b/lib/components/PortalManagerWidget/PortalManagerWidget.dart @@ -118,11 +118,11 @@ class _PortalManagerState extends State { onPressed: () { removeGroup(item); if (navigationItems.isNotEmpty) { - navigationItems.forEach((element) async { + for (final element in navigationItems) { if (element['name'] == item.label) { navigationItems.remove(element); } - }); + } } setState(() {}); }, diff --git a/lib/components/ProcessDetailsWidget/ProcessDetailsWidget.dart b/lib/components/ProcessDetailsWidget/ProcessDetailsWidget.dart index 03ade1122ca10891e177b0c579ab50700312a1d7..29983a0a667a792ee55e06144a8d28301208b639 100644 --- a/lib/components/ProcessDetailsWidget/ProcessDetailsWidget.dart +++ b/lib/components/ProcessDetailsWidget/ProcessDetailsWidget.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; import 'package:orginone/config/oip_tokens.dart'; +import 'package:orginone/config/theme/unified_style.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:orginone/components/ExpandTabBar/ExpandTabBar.dart'; import 'package:orginone/components/ProcessDetailsWidget/components/ApplyWidget.dart'; -import 'package:orginone/components/ProcessDetailsWidget/components/UseTracesWidget.dart'; +import 'package:orginone/components/ProcessDetailsWidget/components/FlowChartWidget.dart'; import '../../dart/base/schema.dart'; import '../../dart/core/work/task.dart'; import '../XStatefulWidget/XStatefulWidget.dart'; @@ -43,7 +44,7 @@ class _ProcessDetailsPageState List tabTitle = [ '办事详情', - '历史痕迹', + '流程跟踪', ]; @override @@ -93,10 +94,10 @@ class _ProcessDetailsPageState }).toList(), indicatorSize: TabBarIndicatorSize.label, indicatorColor: OipBrand.primary, - unselectedLabelColor: Colors.grey, - unselectedLabelStyle: TextStyle(fontSize: 22.sp), + unselectedLabelColor: OipText.tertiary, + unselectedLabelStyle: XFonts.tabUnselected, labelColor: OipBrand.primary, - labelStyle: TextStyle(fontSize: 24.sp), + labelStyle: XFonts.tabLabel, isScrollable: true, ), ); @@ -114,9 +115,7 @@ class _ProcessDetailsPageState children: [ todo?.instance != null ? ProcessInfoWidget(todo: todo) : Container(), todo?.instance != null - ? UseTracesWidget( - todo: todo, - ) + ? FlowChartWidget(todo: todo) : const Text('暂无流程实例'), ], ), diff --git a/lib/components/ProcessDetailsWidget/components/ApplyWidget.dart b/lib/components/ProcessDetailsWidget/components/ApplyWidget.dart index 03ddfee639039cbede21465a62320ff2b6902116..e911905629a93126821cffe4e6b401be1570ba3f 100644 --- a/lib/components/ProcessDetailsWidget/components/ApplyWidget.dart +++ b/lib/components/ProcessDetailsWidget/components/ApplyWidget.dart @@ -149,19 +149,19 @@ class ApplyWidget extends StatelessWidget { } _buildApplyResultView() { - ShareIcon? record = relationCtrl.provider.user?.findShareById( - todo?.taskdata.records == null - ? '' - : todo?.taskdata.records?.first.createUser ?? ''); - - String createUser = todo?.taskdata.records == null - ? '' - : todo?.taskdata.records?.first.createUser ?? ''; + int status = todo?.taskdata.status ?? 0; + if (status < TaskStatus.approvalStart.status) return const SizedBox(); + final records = todo?.taskdata.records; + final hasRecords = records != null && records.isNotEmpty; + final firstCreateUser = hasRecords ? records.first.createUser ?? '' : ''; + final firstCreateTime = hasRecords ? records.first.createTime ?? '' : ''; + ShareIcon? record = + relationCtrl.provider.user?.findShareById(firstCreateUser); + + String createUser = firstCreateUser; // LogUtil.d('_buildApplyResultView'); // LogUtil.d(record?.toJson()); // LogUtil.d(todo?.taskdata.records?.first.createUser); - int status = todo?.taskdata.status ?? 0; - if (status < TaskStatus.approvalStart.status) return const SizedBox(); var result = [ // Image.network('src'), XImage.entityIcon(record, entityId: createUser, height: 28), @@ -188,7 +188,7 @@ class ApplyWidget extends StatelessWidget { return [ result, - _buildDateView('审批时间:${todo?.taskdata.records?.first.createTime ?? ''}'), + _buildDateView('审批时间:$firstCreateTime'), ].toColumn(crossAxisAlignment: CrossAxisAlignment.start); } diff --git a/lib/components/ProcessDetailsWidget/components/BottomActionWidget.dart b/lib/components/ProcessDetailsWidget/components/BottomActionWidget.dart index 18bb9c216d36f17937516d0073f4009ec4b915a9..80cbacf023e8c491d2b1682cbc9938541e8ab182 100644 --- a/lib/components/ProcessDetailsWidget/components/BottomActionWidget.dart +++ b/lib/components/ProcessDetailsWidget/components/BottomActionWidget.dart @@ -9,6 +9,7 @@ import 'package:orginone/components/XDialogs/dialog_utils.dart'; import 'package:orginone/dart/base/schema.dart'; import 'package:orginone/dart/extension/index.dart'; import 'package:orginone/components/Tip/ToastUtils.dart'; +import 'package:orginone/utils/log/log_util.dart'; import 'package:orginone/components/ProcessDetailsWidget/components/WorkTool.dart'; import 'package:orginone/config/theme/space.dart'; import 'package:orginone/dart/core/public/enums.dart'; @@ -139,11 +140,166 @@ class BottomActionWidget extends StatelessWidget { TaskStatus.approvalStart.status, comment: comment?.text, ); - }) + }), + _button( + text: '更多', + textColor: OipBrand.primary, + color: OipSurface.background, + border: Border.all(color: OipBrand.primary, width: 1), + onTap: () => _showMoreActions(context)), ] ])); } + /// 更多操作:转办 / 加签 / 委托(对齐 oiocns-react TaskApproval) + /// 通过选择下一节点审核人 + gateways 提交实现 + void _showMoreActions(BuildContext context) { + showModalBottomSheet( + context: context, + builder: (BuildContext ctx) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.swap_horiz, color: OipBrand.primary), + title: const Text('转办'), + subtitle: const Text('将当前审批任务转交给他人办理'), + onTap: () { + Navigator.pop(ctx); + _selectMemberAndApproval(context, action: '转办'); + }, + ), + ListTile( + leading: + const Icon(Icons.person_add_alt_1, color: OipBrand.primary), + title: const Text('加签'), + subtitle: const Text('增加审核人,多人会签'), + onTap: () { + Navigator.pop(ctx); + _selectMemberAndApproval(context, action: '加签'); + }, + ), + ListTile( + leading: + const Icon(Icons.assignment_ind, color: OipBrand.primary), + title: const Text('委托'), + subtitle: const Text('委托他人代为审批'), + onTap: () { + Navigator.pop(ctx); + _selectMemberAndApproval(context, action: '委托'); + }, + ), + ListTile( + leading: Icon(Icons.close, color: Colors.grey.shade600), + title: const Text('取消'), + onTap: () => Navigator.pop(ctx), + ), + ], + ), + ); + }, + ); + } + + /// 选择成员后发起审批 + /// 通过 loadNextNodes 获取下一节点身份,loadMembers 查询成员,选择后构造 gatewayData 提交 + Future _selectMemberAndApproval( + BuildContext context, { + required String action, + }) async { + if (todo == null) return; + EasyLoading.show(status: '加载节点信息...'); + try { + final nextNodes = await todo!.loadNextNodes(); + if (nextNodes.isEmpty) { + EasyLoading.dismiss(); + ToastUtils.showMsg(msg: '没有可用的下一节点'); + return; + } + // 取第一个下一节点(转办/加签/委托的目标节点) + final nextNode = nextNodes.first; + // 下一节点的 destId 用于查询可选成员 + final destId = nextNode.destId ?? ''; + if (destId.isEmpty) { + EasyLoading.dismiss(); + ToastUtils.showMsg(msg: '当前节点不支持$action'); + return; + } + final members = await todo!.loadMembers(destId); + EasyLoading.dismiss(); + if (!context.mounted) return; + if (members.isEmpty) { + ToastUtils.showMsg(msg: '没有可选的成员'); + return; + } + // 弹出成员选择 + final selected = await showModalBottomSheet( + context: context, + builder: (BuildContext ctx) { + return SafeArea( + child: ListView( + shrinkWrap: true, + children: [ + Padding( + padding: EdgeInsets.all(16.w), + child: Text( + '选择$action 对象', + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w600, + ), + ), + ), + ...members.map((m) => ListTile( + leading: const Icon(Icons.person_outline), + title: Text(m?.name ?? ''), + subtitle: Text(m?.typeName ?? ''), + onTap: () => Navigator.pop(ctx, m), + )), + ListTile( + leading: Icon(Icons.close, color: Colors.grey.shade600), + title: const Text('取消'), + onTap: () => Navigator.pop(ctx, null), + ), + ], + ), + ); + }, + ); + if (selected == null) return; + // 构造 gatewayData:key=nextNode.id(节点ID作为 fieldId),value=[selected.id] + // 对齐 oiocns-react: convertToFields(node).id === node.id + final nodeId = nextNode.id ?? ''; + final gatewayData = >{ + nodeId: [selected.id], + }; + // 调用审批:通过 + 备注中带转办/加签/委托信息 + gatewayData 真实写入 + EasyLoading.show(status: '提交中...'); + final success = await todo!.approvalTask( + TaskStatus.approvalStart.status, + comment: '${comment?.text ?? ""} [$action给: ${selected.name}]', + executors: const [], + gatewayData: gatewayData, + ); + EasyLoading.dismiss(); + if (success) { + ToastUtils.showMsg(msg: '$action成功'); + if (context.mounted) { + Navigator.pop(context); + command.emitter('work', 'refresh'); + command.emitterFlag('work'); + } + } else { + ToastUtils.showMsg(msg: '$action失败'); + } + } catch (e) { + EasyLoading.dismiss(); + XLogUtil.e('[$action] 异常: $e'); + ToastUtils.showMsg(msg: '$action异常'); + } + } + // Widget _opinion() { // if (todo?.metadata.status != 1) { // return Container(); @@ -205,25 +361,15 @@ class BottomActionWidget extends StatelessWidget { //XLogUtil.dd(executors); } } - //是否包含非字段变更的执行器 - bool isNotChange = + // 对齐 oiocns-react:审批由后端统一执行所有执行器 + // 前端不再硬编码拦截非"字段变更"执行器,仅给出提示 + // 字段变更执行器由 task._executors 前端预处理,其他类型由后端处理 + bool hasUnsupportedExecutor = executors.any((e) => e is Map && e['funcName'] != "字段变更"); - if (isNotChange) { - EasyLoading.dismiss(); - ToastUtils.showMsg(msg: "暂不支持【字段变更】之外的执行器的办事审批,请先在PC端上审批!"); - return; + if (hasUnsupportedExecutor) { + // 仅提示不拦截,让审批继续提交,后端会执行剩余执行器 + XLogUtil.w('[办事审批] 节点包含前端未实现的执行器类型,将由后端处理'); } - // if (null != node && - // node.other is Map && - // node.other?['executors'] != null && - // node.other?['executors'] is List && - // (node.other?['executors'] as List).isNotEmpty) - // if (null != node && node.type == "归档") { - // //XLogUtil.dd("有任务办事审批"); - // LoadingDialog.dismissAll(context); - // ToastUtils.showMsg(msg: "暂不支持有执行器的办事审批,请先在PC端上审批!"); - // EasyLoading.dismiss(); - // } else { await WorkNetWork.approvalTask( status: status, comment: comment, @@ -232,10 +378,9 @@ class BottomActionWidget extends StatelessWidget { isSkip: isSkip, executors: executors, onSuccess: () { - // RoutePages.back(context); - // Navigator.pop(context); Navigator.pop(navigatorKey.currentState?.context ?? context); command.emitter('work', 'refresh'); + command.emitterFlag('work'); }); EasyLoading.dismiss(); } diff --git a/lib/components/ProcessDetailsWidget/components/FlowChartWidget.dart b/lib/components/ProcessDetailsWidget/components/FlowChartWidget.dart new file mode 100644 index 0000000000000000000000000000000000000000..806bb79adb19db6cf7060fe20f3b656e4f2aa127 --- /dev/null +++ b/lib/components/ProcessDetailsWidget/components/FlowChartWidget.dart @@ -0,0 +1,637 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:orginone/config/oip_tokens.dart'; +import 'package:orginone/config/theme/unified_style.dart'; +import 'package:orginone/dart/base/model.dart'; +import 'package:orginone/dart/base/schema.dart'; +import 'package:orginone/dart/core/work/task.dart'; +import 'package:orginone/utils/log/log_util.dart'; + +import '../../EmptyWidget/EmptyWidget.dart'; + +/// 节点状态 +enum FlowNodeStatus { + /// 已通过 + passed, + /// 已拒绝 + rejected, + /// 已退回 + returned, + /// 已撤回 + recalled, + /// 当前节点 + current, + /// 未开始 + pending, +} + +/// 节点状态样式 +class _NodeStatusStyle { + final Color color; + final Color background; + final Color border; + final String label; + final IconData icon; + + const _NodeStatusStyle({ + required this.color, + required this.background, + required this.border, + required this.label, + required this.icon, + }); +} + +/// 流程图可视化组件 +/// +/// 基于 [InstanceDataModel.node] 节点树 + [XWorkInstance.tasks] 实际任务执行情况, +/// 渲染流程节点路径,标识当前节点和各节点状态。 +/// +/// 子流程展开:检测 `type == 'sub'` 节点,通过 [IWorkTask.loadTasksData] 已加载的 +/// `task.tasks` / `task.instance` 递归渲染子流程节点树。 +class FlowChartWidget extends StatefulWidget { + final IWorkTask? todo; + + const FlowChartWidget({super.key, required this.todo}); + + @override + State createState() => _FlowChartWidgetState(); +} + +class _FlowChartWidgetState extends State { + WorkNodeModel? _rootNode; + List _tasks = []; + String? _currentNodeId; + bool _loaded = false; + + /// 子流程节点映射:主流程节点 id -> 子流程任务(含 instance 和子 tasks) + /// 用于在渲染 `type == 'sub'` 节点时,递归展开子流程节点树 + final Map _subProcessTaskMap = {}; + + /// 子流程任务 id -> 子流程实例数据(解析自 task.instance.data) + /// 避免重复 jsonDecode + final Map _subInstanceDataMap = {}; + + @override + void initState() { + super.initState(); + _loadData(); + } + + Future _loadData() async { + final todo = widget.todo; + if (todo == null) { + if (mounted) setState(() => _loaded = true); + return; + } + try { + _rootNode = todo.instanceData?.node; + _currentNodeId = todo.instanceData?.node?.id; + // 始终调用 loadTasksData:findSubProcess 会递归填充子流程 task.tasks 和 task.instance + // 即使 instance.tasks 已有数据,也需要触发子流程数据加载 + try { + final list = await todo.loadTasksData(); + _tasks = list.whereType().toList(); + } catch (e) { + XLogUtil.w('[FlowChartWidget] loadTasksData 失败: $e'); + if (todo.instance?.tasks != null) { + _tasks = todo.instance!.tasks!; + } + } + _buildSubProcessMaps(); + } catch (e) { + XLogUtil.e('[FlowChartWidget] 加载流程图数据异常: $e'); + } finally { + if (mounted) setState(() => _loaded = true); + } + } + + /// 构建子流程映射: + /// - _subProcessTaskMap: 主流程子流程节点 id -> 子流程任务 + /// - _subInstanceDataMap: 子流程任务 id -> 子流程实例数据 + void _buildSubProcessMaps() { + _subProcessTaskMap.clear(); + _subInstanceDataMap.clear(); + _collectSubProcessTasks(_tasks); + } + + void _collectSubProcessTasks(List tasks) { + for (final t in tasks) { + if (t.approveType == '子流程') { + if (t.nodeId != null && t.nodeId!.isNotEmpty) { + _subProcessTaskMap[t.nodeId!] = t; + } + // 解析子流程实例数据(task.instance.data 是 InstanceDataModel 的 JSON 字符串) + if (t.instance?.data != null && t.instance!.data!.isNotEmpty) { + if (!_subInstanceDataMap.containsKey(t.id)) { + try { + _subInstanceDataMap[t.id] = + InstanceDataModel.fromJson(jsonDecode(t.instance!.data!)); + } catch (e) { + XLogUtil.w('[FlowChartWidget] 子流程实例数据解析失败: $e'); + } + } + } + // 递归收集嵌套子流程 + if (t.tasks != null && t.tasks!.isNotEmpty) { + _collectSubProcessTasks(t.tasks!); + } + } + } + } + + /// 根据 nodeId 查找匹配的任务状态 + /// + /// [tasks] 指定任务列表查找范围(主流程或子流程),[currentNodeId] 指定当前节点 id + FlowNodeStatus _resolveStatus( + String? nodeId, { + List? tasks, + String? currentNodeId, + }) { + if (nodeId == null || nodeId.isEmpty) return FlowNodeStatus.pending; + final taskList = tasks ?? _tasks; + final currId = currentNodeId ?? _currentNodeId; + if (nodeId == currId) { + // 当前节点:若已有任务记录,按记录状态展示;否则视为进行中 + final task = _findTaskByNodeId(nodeId, taskList); + if (task == null) return FlowNodeStatus.current; + return _mapTaskStatus(task.status, task.approveType); + } + final task = _findTaskByNodeId(nodeId, taskList); + if (task == null) return FlowNodeStatus.pending; + return _mapTaskStatus(task.status, task.approveType); + } + + XWorkTask? _findTaskByNodeId(String nodeId, [List? taskList]) { + final list = taskList ?? _tasks; + for (final t in list) { + if (t.nodeId == nodeId) return t; + } + return null; + } + + FlowNodeStatus _mapTaskStatus(int? status, String? approveType) { + // 状态码参考 UseTracesWidget.statusMap + if (status == null) return FlowNodeStatus.pending; + switch (status) { + case 100: + case 102: + return FlowNodeStatus.passed; + case 200: + return FlowNodeStatus.rejected; + case 230: + return FlowNodeStatus.returned; + case 240: + case 220: + case 221: + case 222: + return FlowNodeStatus.recalled; + case 1: + default: + // status==1 表示审核中 + if (approveType == '起始') return FlowNodeStatus.passed; + return FlowNodeStatus.current; + } + } + + _NodeStatusStyle _styleOf(FlowNodeStatus s) { + switch (s) { + case FlowNodeStatus.passed: + return const _NodeStatusStyle( + color: OipState.success, + background: OipState.successBg, + border: OipState.success, + label: '已通过', + icon: Icons.check_circle_outline, + ); + case FlowNodeStatus.rejected: + return const _NodeStatusStyle( + color: OipState.error, + background: OipState.errorBg, + border: OipState.error, + label: '已拒绝', + icon: Icons.cancel_outlined, + ); + case FlowNodeStatus.returned: + return const _NodeStatusStyle( + color: OipState.warning, + background: OipState.warningBg, + border: OipState.warning, + label: '已退回', + icon: Icons.undo, + ); + case FlowNodeStatus.recalled: + return _NodeStatusStyle( + color: Colors.grey.shade700, + background: Colors.grey.shade100, + border: Colors.grey.shade400, + label: '已撤回', + icon: Icons.block, + ); + case FlowNodeStatus.current: + return const _NodeStatusStyle( + color: OipBrand.primary, + background: OipBrand.primaryBg, + border: OipBrand.primary, + label: '进行中', + icon: Icons.play_circle_outline, + ); + case FlowNodeStatus.pending: + return _NodeStatusStyle( + color: Colors.grey.shade500, + background: Colors.grey.shade50, + border: Colors.grey.shade300, + label: '未开始', + icon: Icons.radio_button_unchecked, + ); + } + } + + @override + Widget build(BuildContext context) { + if (!_loaded) { + return const Center(child: CircularProgressIndicator()); + } + final root = _rootNode; + if (root == null) { + return const EmptyWidget(); + } + return Container( + color: OipSurface.background, + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h), + child: SingleChildScrollView( + child: _buildNodeCard( + root, + isRoot: true, + depth: 0, + tasks: _tasks, + currentNodeId: _currentNodeId, + ), + ), + ); + } + + /// 渲染单个节点卡片 + 递归渲染后续路径 + /// + /// [tasks] 当前流程层级的任务列表,[currentNodeId] 当前流程层级的当前节点 id + Widget _buildNodeCard( + WorkNodeModel node, { + required bool isRoot, + required int depth, + List tasks = const [], + String? currentNodeId, + }) { + final status = + _resolveStatus(node.id, tasks: tasks, currentNodeId: currentNodeId); + final style = _styleOf(status); + final isCurrent = status == FlowNodeStatus.current; + + final card = Material( + color: style.background, + borderRadius: BorderRadius.circular(8.r), + child: InkWell( + borderRadius: BorderRadius.circular(8.r), + onTap: () => _showNodeDetail(node, status, style, tasks: tasks), + child: Container( + width: double.infinity, + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 12.h), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8.r), + border: Border.all( + color: isCurrent + ? style.border + : style.border.withValues(alpha: 0.5), + width: isCurrent ? 1.5 : 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(style.icon, size: 18.sp, color: style.color), + SizedBox(width: 6.w), + Expanded( + child: Text( + node.name ?? '未命名节点', + style: XFonts.titleSmall, + ), + ), + Container( + padding: + EdgeInsets.symmetric(horizontal: 8.w, vertical: 2.h), + decoration: BoxDecoration( + color: style.color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10.r), + ), + child: Text( + style.label, + style: XFonts.labelSmall.copyWith(color: style.color), + ), + ), + ], + ), + if ((node.destName ?? '').isNotEmpty) ...[ + SizedBox(height: 4.h), + Text('审批人:${node.destName}', style: XFonts.caption), + ], + if (_nodeTypeLabel(node.type).isNotEmpty) ...[ + SizedBox(height: 2.h), + Text(_nodeTypeLabel(node.type), + style: XFonts.captionSmall), + ], + ], + ), + ), + ), + ); + + final children = [card]; + + // 主路径:node.children(顺序后续节点) + if (node.children != null) { + children.add(_buildConnector()); + children.add(_buildNodeCard( + node.children!, + isRoot: false, + depth: depth + 1, + tasks: tasks, + currentNodeId: currentNodeId, + )); + } + + // 分支路径:node.branches(条件分支,平行展示) + if (node.branches != null && node.branches!.isNotEmpty) { + for (final br in node.branches!) { + if (br.children == null) continue; + children.add(_buildBranchConnector(br.conditions)); + children.add( + Padding( + padding: EdgeInsets.only(left: 16.w), + child: _buildNodeCard( + br.children!, + isRoot: false, + depth: depth + 1, + tasks: tasks, + currentNodeId: currentNodeId, + ), + ), + ); + } + } + + // 子流程:type=='sub' 的节点递归展开子流程节点树 + if (node.type == 'sub' && node.id != null) { + final subTask = _subProcessTaskMap[node.id]; + if (subTask != null) { + final subData = _subInstanceDataMap[subTask.id]; + final subRoot = subData?.node; + final subTasks = subTask.tasks ?? []; + if (subRoot != null) { + children.add(_buildSubProcessConnector(subTask)); + children.add( + Padding( + padding: EdgeInsets.only(left: 16.w), + child: Container( + decoration: BoxDecoration( + border: Border( + left: BorderSide( + color: OipBrand.primary.withValues(alpha: 0.3), + width: 1.5, + ), + ), + ), + padding: EdgeInsets.only(left: 12.w), + child: _buildNodeCard( + subRoot, + isRoot: false, + depth: depth + 1, + tasks: subTasks, + currentNodeId: subRoot.id, + ), + ), + ), + ); + } else if ((subTask.private ?? '').isNotEmpty) { + // 子流程未开放/未提交 + children.add(_buildSubProcessHint(subTask.private!)); + } + } + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ); + } + + /// 主路径连接线 + Widget _buildConnector() { + return Container( + margin: EdgeInsets.symmetric(horizontal: 14.w, vertical: 0), + width: 1.5, + height: 14.h, + color: OipBrand.primary.withValues(alpha: 0.4), + ); + } + + /// 分支连接线 + 条件标签 + Widget _buildBranchConnector(List? conditions) { + final condText = conditions?.isNotEmpty == true + ? conditions! + .map((c) => '${c.key ?? ''} ${c.type ?? ''} ${c.val ?? ''}') + .join(' / ') + : '分支'; + return Padding( + padding: EdgeInsets.symmetric(vertical: 4.h), + child: Row( + children: [ + Container( + width: 14.w, + height: 1.5, + color: OipBrand.primary.withValues(alpha: 0.4), + ), + SizedBox(width: 6.w), + Container( + padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 2.h), + decoration: BoxDecoration( + color: OipBrand.primaryBg, + borderRadius: BorderRadius.circular(4.r), + border: Border.all(color: OipBrand.primaryBorder, width: 0.5), + ), + child: Text( + condText, + style: XFonts.captionSmall.copyWith(color: OipBrand.primary), + ), + ), + ], + ), + ); + } + + /// 子流程连接线 + 子流程标识 + Widget _buildSubProcessConnector(XWorkTask subTask) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 4.h), + child: Row( + children: [ + Container( + width: 14.w, + height: 1.5, + color: OipBrand.primary.withValues(alpha: 0.4), + ), + SizedBox(width: 6.w), + Container( + padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 2.h), + decoration: BoxDecoration( + color: OipBrand.primaryBg, + borderRadius: BorderRadius.circular(4.r), + border: Border.all(color: OipBrand.primaryBorder, width: 0.5), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.subdirectory_arrow_right, + size: 12.sp, color: OipBrand.primary), + SizedBox(width: 2.w), + Text( + '子流程', + style: XFonts.captionSmall + .copyWith(color: OipBrand.primary), + ), + ], + ), + ), + ], + ), + ); + } + + /// 子流程未开放/未提交提示 + Widget _buildSubProcessHint(String hint) { + return Padding( + padding: EdgeInsets.only(left: 30.w, top: 4.h, bottom: 4.h), + child: Container( + padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(4.r), + border: Border.all(color: Colors.grey.shade300, width: 0.5), + ), + child: Text( + hint, + style: XFonts.captionSmall.copyWith(color: Colors.grey.shade600), + ), + ), + ); + } + + String _nodeTypeLabel(String? type) { + switch (type) { + case 'start': + return '起始节点'; + case 'approve': + case 'approval': + return '审批节点'; + case 'gateway': + return '网关节点'; + case 'end': + return '结束节点'; + case 'sub': + return '子流程节点'; + case 'cc': + return '抄送节点'; + default: + return type ?? ''; + } + } + + void _showNodeDetail( + WorkNodeModel node, + FlowNodeStatus status, + _NodeStatusStyle style, { + List tasks = const [], + }) { + final task = _findTaskByNodeId(node.id ?? '', tasks); + showModalBottomSheet( + context: context, + builder: (ctx) { + return SafeArea( + child: Padding( + padding: EdgeInsets.all(16.w), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(style.icon, color: style.color, size: 20.sp), + SizedBox(width: 8.w), + Expanded( + child: Text( + node.name ?? '未命名节点', + style: XFonts.titleMedium, + ), + ), + Container( + padding: EdgeInsets.symmetric( + horizontal: 8.w, vertical: 2.h), + decoration: BoxDecoration( + color: style.color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10.r), + ), + child: Text(style.label, + style: XFonts.labelSmall + .copyWith(color: style.color)), + ), + ], + ), + SizedBox(height: 12.h), + _detailRow('节点类型', _nodeTypeLabel(node.type)), + _detailRow('节点编号', node.code ?? '-'), + _detailRow('审批人', node.destName ?? '-'), + _detailRow('审批数量', '${node.num ?? 0}'), + if (task != null) ...[ + Divider(height: 24.h), + _detailRow('任务状态', '${task.status}'), + _detailRow('审批类型', task.approveType ?? '-'), + _detailRow('任务类型', task.taskType ?? '-'), + if ((task.content ?? '').isNotEmpty) + _detailRow('任务内容', task.content ?? ''), + ], + SizedBox(height: 12.h), + SizedBox( + width: double.infinity, + child: TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('关闭'), + ), + ), + ], + ), + ), + ); + }, + ); + } + + Widget _detailRow(String label, String value) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 4.h), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 80.w, + child: Text(label, style: XFonts.bodySmall), + ), + Expanded( + child: Text(value, style: XFonts.bodySmall), + ), + ], + ), + ); + } +} diff --git a/lib/components/ProcessDetailsWidget/components/ProcessInfoWidget/ProcessInfoWidget.dart b/lib/components/ProcessDetailsWidget/components/ProcessInfoWidget/ProcessInfoWidget.dart index 733e8fedc612df414298e404ec0bb0afc7ebe5f1..c1640b945a3ea9fa0fa34b40ba368f0a2ee67482 100644 --- a/lib/components/ProcessDetailsWidget/components/ProcessInfoWidget/ProcessInfoWidget.dart +++ b/lib/components/ProcessDetailsWidget/components/ProcessInfoWidget/ProcessInfoWidget.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:get/get.dart'; import 'package:orginone/dart/extension/index.dart'; import 'package:orginone/components/ProcessDetailsWidget/components/BottomActionWidget.dart'; import 'package:orginone/components/form/form_widget/view.dart'; @@ -306,10 +307,49 @@ class _ProcessInfoPageState extends State { field.field.defaultData.value = value ?? ""; } return field.field.select![value] ?? ""; + case "multiSelect": + // 多选框值还原:value 可为 List(如 ['S1','S2'])或单值字符串 + field.field.select = {}; + for (var lookup in field.lookups ?? []) { + field.field.select![lookup.value] = lookup.text ?? ""; + } + List> selectedItems = []; + if (value is List) { + for (var v in value) { + final key = v.toString(); + if (field.field.select!.containsKey(key)) { + selectedItems.add({key: field.field.select![key]!}); + } + } + } else if (value is String && value.isNotEmpty) { + if (field.field.select!.containsKey(value)) { + selectedItems.add({value: field.field.select![value]!}); + } + } + field.field.defaultData.value = selectedItems; + return selectedItems.map((m) => m.values.first).join('、'); case "upload": field.field.defaultData.value = value != null ? FileItemModel.fromJson(value) : null; return field.field.defaultData.value.name; + case "mapLocation": + case "mapRegion": + case "mapTrack": + case "space": + // 地图型/空间型属性值为 JSON 字符串,直接存储 + field.field.defaultData.value = value?.toString(); + return value?.toString() ?? ""; + case "sensor": + // 感知型属性:value 为传感器 ID,从 lookups 查找显示文本 + field.field.lookups = field.lookups; + field.field.defaultData.value = value?.toString(); + final sensorId = value?.toString() ?? ''; + if (sensorId.isNotEmpty) { + final lookup = field.lookups?.firstWhereOrNull( + (l) => l.value == sensorId || l.id == sensorId); + return lookup?.text ?? sensorId; + } + return ""; default: field.field.defaultData.value = value; if (field.field.type == "selectTimeRange" || diff --git a/lib/components/ProcessDetailsWidget/components/WorkNetWork.dart b/lib/components/ProcessDetailsWidget/components/WorkNetWork.dart index 24b02b45acb5e5785e53fcd9d8d81922a7dfaa73..42abbda312f25465facde4f305d0d19d4e888a64 100644 --- a/lib/components/ProcessDetailsWidget/components/WorkNetWork.dart +++ b/lib/components/ProcessDetailsWidget/components/WorkNetWork.dart @@ -16,10 +16,11 @@ class WorkNetWork { try { bool success = await todo.approvalTask( status, - comment: comment /*, gatewayData: gatewayData*/, + comment: comment, backId: backId, isSkip: isSkip, executors: executors, + gatewayData: gatewayData, ); if (success) { ToastUtils.showMsg(msg: "操作成功"); diff --git a/lib/components/StandardComponents/standard_widgets.dart b/lib/components/StandardComponents/standard_widgets.dart index 5e4efc23416ba02940e53c095461d616d3bd1426..d666118f65741e88902361810b743fdb974d1f4c 100644 --- a/lib/components/StandardComponents/standard_widgets.dart +++ b/lib/components/StandardComponents/standard_widgets.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:orginone/components/XImage/XImage.dart'; +import 'package:orginone/config/oip_tokens.dart'; import 'package:orginone/config/theme/unified_style.dart'; enum LoadingState { @@ -289,7 +290,7 @@ class StandardErrorWidget extends StatelessWidget { ElevatedButton( onPressed: onRetry, style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.primary, + backgroundColor: OipBrand.primary, foregroundColor: Colors.white, padding: EdgeInsets.symmetric(horizontal: 60.w, vertical: 20.h), shape: RoundedRectangleBorder( @@ -298,7 +299,7 @@ class StandardErrorWidget extends StatelessWidget { ), child: Text( '重新加载', - style: TextStyle(fontSize: 28.sp), + style: XFonts.labelLarge.copyWith(color: Colors.white), ), ), if (showDetails && error != null) ...[ diff --git a/lib/components/TabContainerWidget/components/TabWidget.dart b/lib/components/TabContainerWidget/components/TabWidget.dart index d9ff110d204fa8fcf583bdb28138485481b67f2d..033069a30d904594dbe303d0a8d76f691cf2f970 100644 --- a/lib/components/TabContainerWidget/components/TabWidget.dart +++ b/lib/components/TabContainerWidget/components/TabWidget.dart @@ -106,8 +106,9 @@ class _TabPageState extends State { // backgroundColor: Colors.white, appBar: _tabBar(widget.iTabModel.tabItems), body: ExtendedTabBarView( - children: - widget.iTabModel.tabItems.map((tab) => _content(tab)).toList()), + children: widget.iTabModel.tabItems + .map((tab) => _content(tab, key: ValueKey(tab.title))) + .toList()), ), ); } @@ -135,8 +136,8 @@ class _TabPageState extends State { labelPadding: tabItems[0].icon != 'p' ? const EdgeInsets.only(left: 5, right: 5, bottom: 5) : const EdgeInsets.only(left: 10, right: 10, bottom: 10), - labelStyle: const TextStyle(fontSize: 14), - unselectedLabelStyle: const TextStyle(fontSize: 14), + labelStyle: XFonts.tabLabel, + unselectedLabelStyle: XFonts.tabUnselected, indicator: UnderlineTabIndicator( borderSide: const BorderSide( width: 2.0, color: OipBrand.primary), @@ -235,9 +236,8 @@ class _TabPageState extends State { // : Colors.blue; // }, // ), - labelStyle: - const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), - unselectedLabelStyle: const TextStyle(fontSize: 14), + labelStyle: XFonts.tabLabel, + unselectedLabelStyle: XFonts.tabUnselected, indicator: const UnderlineTabIndicator(), // indicator: UnderlineTabIndicator( // borderRadius: BorderRadius.circular(4.w), @@ -251,24 +251,27 @@ class _TabPageState extends State { } ///页签内容 - Widget _content(TabItemsModel item) { - return _hasSubTabPage(item) ? _subTabContent(item) : _tabContent(item); + Widget _content(TabItemsModel item, {Key? key}) { + return _hasSubTabPage(item) + ? _subTabContent(item, key: key) + : _tabContent(item, key: key); } - Widget _subTabContent(TabItemsModel item) { - return TabWidget(item); + Widget _subTabContent(TabItemsModel item, {Key? key}) { + return TabWidget(item, key: key); } - Widget _tabContent(TabItemsModel item) { - return __tabContent(item) ?? _tabDefContent(item); + Widget _tabContent(TabItemsModel item, {Key? key}) { + return __tabContent(item, key: key) ?? _tabDefContent(item); } - Widget? __tabContent(TabItemsModel item) { + Widget? __tabContent(TabItemsModel item, {Key? key}) { return null != item.content ? Column( + key: key, children: [ const NetworkTipWidget(), - Expanded(child: item.content!) + Expanded(child: _KeepAliveTabContent(child: item.content!)), ], ) : null; @@ -278,3 +281,24 @@ class _TabPageState extends State { return Center(child: Text(item.title)); } } + +/// Tab 内容保持器:确保 Tab 切换时内容不被销毁重建 +class _KeepAliveTabContent extends StatefulWidget { + final Widget child; + const _KeepAliveTabContent({required this.child}); + + @override + State<_KeepAliveTabContent> createState() => _KeepAliveTabContentState(); +} + +class _KeepAliveTabContentState extends State<_KeepAliveTabContent> + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + return widget.child; + } +} diff --git a/lib/components/XConsumer/XConsumer.dart b/lib/components/XConsumer/XConsumer.dart index bb194f10557e2a0fc95a790b30807732c1b98a62..878d21c837c6295a9a1d5ff19f30f5871f4df49d 100644 --- a/lib/components/XConsumer/XConsumer.dart +++ b/lib/components/XConsumer/XConsumer.dart @@ -15,14 +15,17 @@ class XConsumer extends Consumer { @override Widget buildWithChild(BuildContext context, Widget? child) { ///判断是否当前路由 - if (isCurrentRoute()) { + ///当前路由:正常构建并更新缓存 + ///非当前路由但已有缓存:返回缓存(冻结状态,避免非活跃 Tab 重复构建) + ///非当前路由且无缓存:仍构建一次以填充缓存,避免页面白屏 + if (isCurrentRoute() || _cacheWidget == null) { _cacheWidget = builder( context, Provider.of(context), child, ); } - return _cacheWidget ?? const SizedBox.shrink(); + return _cacheWidget!; } String get currentRoutePath { diff --git a/lib/components/XDialogs/dialog_utils.dart b/lib/components/XDialogs/dialog_utils.dart index 0bebb122a6636ae9ac881616e880c187152f0c98..dc6f21d9ae49e71df673e62c5a3fc469171a5fea 100644 --- a/lib/components/XDialogs/dialog_utils.dart +++ b/lib/components/XDialogs/dialog_utils.dart @@ -116,6 +116,96 @@ class PickerUtils { }, doneTitle: doneTitle); } + /// 多选弹框(对齐 oiocns-react MultiSelectBox) + /// 显示带 checkbox 的列表,用户可多选,点击"确定"返回选中项 + static void showMultiSelectPicker( + BuildContext context, { + required List titles, + required List selectedTitles, + required void Function(List selected) callback, + String doneTitle = "确定", + }) { + final selected = List.from(selectedTitles); + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (ctx) { + return StatefulBuilder( + builder: (ctx, setState) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('取消'), + ), + Text(doneTitle, + style: const TextStyle( + fontSize: 16, fontWeight: FontWeight.w600)), + TextButton( + onPressed: () { + Navigator.pop(ctx); + callback(selected); + }, + child: const Text('确定'), + ), + ], + ), + ), + const Divider(height: 1), + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(ctx).size.height * 0.5, + ), + child: ListView.builder( + shrinkWrap: true, + itemCount: titles.length, + itemBuilder: (ctx, index) { + final title = titles[index]; + final isSelected = selected.contains(title); + return ListTile( + onTap: () { + setState(() { + if (isSelected) { + selected.remove(title); + } else { + selected.add(title); + } + }); + }, + leading: Icon( + isSelected + ? Icons.check_box + : Icons.check_box_outline_blank, + color: isSelected + ? Theme.of(ctx).primaryColor + : Colors.grey, + ), + title: Text(title), + ); + }, + ), + ), + ], + ), + ); + }, + ); + }, + ); + } + static void showTestPicker(BuildContext context) { showCupertinoPicker( context, diff --git a/lib/components/XLazy/XLazy.dart b/lib/components/XLazy/XLazy.dart index 7e9a2886cb63105e5497972f84292e4ca5f3653b..0305d3e3e2a8b476dd06dbdf3a4b2d08f64f99d7 100644 --- a/lib/components/XLazy/XLazy.dart +++ b/lib/components/XLazy/XLazy.dart @@ -5,7 +5,7 @@ import 'package:flutter/material.dart'; import 'package:orginone/components/EmptyWidget/EmptyWidget.dart'; /// 懒加载数据 -class XLazy extends StatelessWidget with EmptyMixin { +class XLazy extends StatefulWidget { /// 初始化数据 final List initDatas; @@ -30,52 +30,49 @@ class XLazy extends StatelessWidget with EmptyMixin { this.scrollController}) : super(key: key); + @override + State> createState() => _XLazyState(); +} + +class _XLazyState extends State> with EmptyMixin { + /// 缓存首次加载的 Future,避免每次 build 都创建新 Future 触发重复请求 + Future>? _initFuture; + + @override + void initState() { + super.initState(); + if (widget.onInitLoad != null) { + _initFuture = Future(widget.onInitLoad as FutureOr> Function()); + } + } + @override Widget build(BuildContext context) { - return null != onLoad + return null != widget.onLoad ? _buildMoreLazyWidget(context) : _buildWidget(context); } Widget _buildMoreLazyWidget(BuildContext context) { return EasyRefresh( - // footer: const ClassicFooter( - // // triggerOffset: 110, - // dragText: '上拉加载', - // processingText: '正在加载', - // noMoreText: '', - // // noMoreText: '--没有更多--', - // readyText: '准备加载', - // armedText: '正在加载', - // // processedText: '--没有更多--', - // processedText: '', - // failedText: '加载失败', - // // messageText: '最新加载: %T', - // messageText: '', - // noMoreIcon: null, - // iconDimension: 0, - // iconTheme: null, - // failedIcon: null, - // succeededIcon: null - // ), - // footer: const NotLoadFooter(), footer: const MaterialFooter(), - refreshOnStart: initDatas.isEmpty, + refreshOnStart: widget.initDatas.isEmpty, notLoadFooter: const NotLoadFooter(), - onLoad: onLoad, - scrollController: scrollController, - child: buildEmptyWidget(initDatas.isEmpty, _buildItem(context, null))); + onLoad: widget.onLoad, + scrollController: widget.scrollController, + child: buildEmptyWidget( + widget.initDatas.isEmpty, _buildItem(context, null))); } Widget _buildWidget(BuildContext context) { - return null != onInitLoad + return null != widget.onInitLoad ? _buildLazyWidget(context) - : _buildContent(context, initDatas); + : _buildContent(context, widget.initDatas); } Widget _buildLazyWidget(BuildContext context) { return FutureBuilder>( - future: Future(onInitLoad as FutureOr> Function()), + future: _initFuture, builder: (context, sho) { if (sho.connectionState == ConnectionState.done) { return _buildContent(context, sho.data); @@ -88,11 +85,11 @@ class XLazy extends StatelessWidget with EmptyMixin { Widget _buildContent(BuildContext context, List? datas) { return buildEmptyWidget( - datas?.isEmpty ?? initDatas.isEmpty, _buildItem(context, datas)); + datas?.isEmpty ?? widget.initDatas.isEmpty, _buildItem(context, datas)); } /// 构建懒加载项 Widget _buildItem(BuildContext context, List? datas) { - return builder(context, datas ?? initDatas); + return widget.builder(context, datas ?? widget.initDatas); } } diff --git a/lib/components/XScaffold/XScaffold.dart b/lib/components/XScaffold/XScaffold.dart index ea382566d953fa1617cea7ca138543b87da9fd04..23c884d2637c3778409c93711fcc08d1d9cc368c 100644 --- a/lib/components/XScaffold/XScaffold.dart +++ b/lib/components/XScaffold/XScaffold.dart @@ -541,7 +541,8 @@ class _XScaffoldState extends State title: const Text('创建新空间'), onTap: () { Navigator.pop(ctx); - RoutePages.to(path: '/createCompany'); + // '/createCompany' 路由未注册,改用统一的 showAddFeatures 弹窗流程 + relationCtrl.showAddFeatures(relationCtrl.menuItems[2]); }, ), ], @@ -582,16 +583,16 @@ class _XScaffoldState extends State Widget _buildLogoButton() { Widget logoChild = XImage.platformNavButton( iconPath: XImage.logoNotBg, - iconSize: 30, + iconSize: 32, ); return GestureDetector( onTap: _openLogoDrawer, child: Container( - width: 44.w, - height: 44.w, + width: 52.w, + height: 52.w, decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14.r), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.06), @@ -606,7 +607,7 @@ class _XScaffoldState extends State ), ), child: ClipRRect( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14.r), child: logoChild, ), ), diff --git a/lib/components/XStatefulWidget/XStatefulWidget.dart b/lib/components/XStatefulWidget/XStatefulWidget.dart index 425ec0058b5bd1734940a42b00cb1a0cb12b5d9e..fb83bbdaf06f1da14298282c74ad0547f459c1f6 100644 --- a/lib/components/XStatefulWidget/XStatefulWidget.dart +++ b/lib/components/XStatefulWidget/XStatefulWidget.dart @@ -123,7 +123,7 @@ mixin _Controller

{ if (routeData == null) { return PopScope( canPop: true, - onPopInvoked: (didPop) { + onPopInvokedWithResult: (didPop, result) { if (!didPop) { Navigator.pop(context); } diff --git a/lib/components/XText/XText.dart b/lib/components/XText/XText.dart index e35d6096b3ab610c8889eed23308e4e06e96b05c..92aa5be8ee8fb8cc9f4f70d5ff2a9c284ba151a3 100644 --- a/lib/components/XText/XText.dart +++ b/lib/components/XText/XText.dart @@ -141,7 +141,7 @@ class XText extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 1, horizontal: 3), textStyle: TextStyle( color: highlight ? XColors.fontErrorColor : OipBrand.primary, - fontSize: 14.sp, + fontSize: 16.sp, ), borderColor: highlight ? XColors.fontErrorColor : OipAi.accent, ); diff --git a/lib/components/XTextField/XTextField.dart b/lib/components/XTextField/XTextField.dart index 4c879e846e332c99ebad210ca0282157c2629e8e..47ac8bd21be2ef61f31ff3460154be4b2d5ce468 100644 --- a/lib/components/XTextField/XTextField.dart +++ b/lib/components/XTextField/XTextField.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:orginone/config/oip_tokens.dart'; import '../XImage/XImage.dart'; import '../XText/XText.dart'; @@ -28,6 +29,7 @@ class XTextField extends StatelessWidget { final int? maxLines; final bool? enabled; final String? content; + final TextInputType? keyboardType; const XTextField( {super.key, this.controller, @@ -48,7 +50,8 @@ class XTextField extends StatelessWidget { this.required = false, this.enabled, this.content, - this.maxLines = 1}); + this.maxLines = 1, + this.keyboardType}); //搜索框 const XTextField.search({ @@ -71,7 +74,8 @@ class XTextField extends StatelessWidget { required = false, maxLines = null, content = null, - enabled = null; + enabled = null, + keyboardType = null; /// 输入框 XTextField.input( @@ -87,10 +91,11 @@ class XTextField extends StatelessWidget { this.enabled, this.showLine = false, this.obscureText = false, - this.maxLines = 1}) - : inputFormatters = [ - FilteringTextInputFormatter.singleLineFormatter, - ], + this.maxLines = 1, + this.keyboardType, + List? inputFormatters}) + : inputFormatters = inputFormatters ?? + [FilteringTextInputFormatter.singleLineFormatter], autofocus = false, backgroundColor = null, margin = null, @@ -103,15 +108,14 @@ class XTextField extends StatelessWidget { var searchC = searchColor ?? Theme.of(context).primaryColor; //Colors.blue.withOpacity(0.1); return Container( - // width: 343, - height: 42, - margin: const EdgeInsets.symmetric(vertical: 5), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + height: 52.h, + margin: EdgeInsets.symmetric(vertical: 6.h), + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 8.h), clipBehavior: Clip.antiAlias, decoration: ShapeDecoration( shape: RoundedRectangleBorder( - side: const BorderSide(width: 1, color: Color(0xFFE7E8EB)), - borderRadius: BorderRadius.circular(12), + side: BorderSide(width: 1, color: OipBorder.defaultBorder), + borderRadius: BorderRadius.circular(OipRadius.md), ), ), child: Row( @@ -132,30 +136,34 @@ class XTextField extends StatelessWidget { if (null != icon || null != title) const SizedBox(width: 8), Expanded( child: TextField( - maxLines: maxLines, - minLines: 1, - enabled: enabled, - controller: controller ?? - TextEditingController(text: content), - onSubmitted: onSubmitted, - onChanged: onChanged, - autofocus: autofocus, - obscureText: obscureText, - inputFormatters: inputFormatters, - style: TextStyle(fontSize: 15.sp), - decoration: InputDecoration( - isCollapsed: true, - hintText: hint, - contentPadding: EdgeInsets.zero, - hintStyle: TextStyle( - color: Colors.grey.shade400, fontSize: 15.sp), - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - errorBorder: InputBorder.none, - disabledBorder: InputBorder.none, - focusedErrorBorder: InputBorder.none), - )), + maxLines: maxLines, + minLines: 1, + enabled: enabled, + keyboardType: keyboardType, + controller: + controller ?? TextEditingController(text: content), + onSubmitted: onSubmitted, + onChanged: onChanged, + autofocus: autofocus, + obscureText: obscureText, + inputFormatters: inputFormatters, + style: TextStyle( + fontSize: 20.sp, + color: OipText.primary, + ), + decoration: InputDecoration( + isCollapsed: true, + hintText: hint, + contentPadding: EdgeInsets.zero, + hintStyle: + TextStyle(color: OipText.tertiary, fontSize: 20.sp), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + focusedErrorBorder: InputBorder.none), + )), if (null != action) action ?? Container(), ], ), diff --git a/lib/components/base/oip_feedback.dart b/lib/components/base/oip_feedback.dart index 0af2dc7c1f3fd27403d6ecd58f150811fc76a130..b2f419cffffb23fd37a3fd51284c2d18ea13c68f 100644 --- a/lib/components/base/oip_feedback.dart +++ b/lib/components/base/oip_feedback.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:orginone/components/base/oip_component_state.dart'; +import 'package:orginone/config/oip_tokens.dart'; +import 'package:orginone/config/theme/unified_style.dart'; /// OIP 反馈与状态系统(实施方案 §5.4,HCI 架构 §7) /// @@ -105,14 +107,14 @@ class _EmptyView extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(config.icon, size: 48, color: const Color(0xFF94A3B8)), + Icon(config.icon, size: 48, color: OipText.tertiary), const SizedBox(height: 12), - Text(config.title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), + Text(config.title, style: XFonts.labelMedium), if (config.subtitle != null) ...[ const SizedBox(height: 4), Text( config.subtitle!, - style: const TextStyle(fontSize: 12, color: Color(0xFF64748B)), + style: XFonts.labelSmall.copyWith(color: OipText.secondary), textAlign: TextAlign.center, ), ], @@ -143,11 +145,11 @@ class _ErrorView extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.error_outline, size: 40, color: Color(0xFFF43F5E)), + const Icon(Icons.error_outline, size: 40, color: OipState.error), const SizedBox(height: 12), Text( message, - style: const TextStyle(fontSize: 13, color: Color(0xFF475569)), + style: XFonts.labelSmall.copyWith(color: OipText.secondary), textAlign: TextAlign.center, ), if (onRetry != null) ...[ @@ -202,13 +204,13 @@ class OipFeedback { Icon( success ? Icons.check_circle : Icons.error_outline, size: 18, - color: const Color(0xFFFFFFFF), + color: OipText.inverse, ), const SizedBox(width: 8), Expanded(child: Text(message)), ], ), - backgroundColor: success ? const Color(0xFF10B981) : const Color(0xFFF43F5E), + backgroundColor: success ? OipState.success : OipState.error, duration: const Duration(seconds: 2), behavior: SnackBarBehavior.floating, ), @@ -301,7 +303,7 @@ class OipNotification { builder: (ctx) => AlertDialog( title: Row( children: [ - const Icon(Icons.warning, color: Color(0xFFF43F5E)), + const Icon(Icons.warning, color: OipState.error), const SizedBox(width: 8), Expanded(child: Text(title)), ], diff --git a/lib/components/base/vet_guard.dart b/lib/components/base/vet_guard.dart index 1ff7f6e7eb6eb466ac09c97078b661e94c81beb2..5278ae3bbf2c27575607412a1373fdb4079d46cf 100644 --- a/lib/components/base/vet_guard.dart +++ b/lib/components/base/vet_guard.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:orginone/components/Tip/ToastUtils.dart'; +import 'package:orginone/config/oip_tokens.dart'; +import 'package:orginone/config/theme/unified_style.dart'; import 'package:orginone/dart/core/oip/state_provider.dart'; import 'package:orginone/dart/core/oip/models/vet.dart'; import 'package:orginone/main.dart'; @@ -92,8 +94,8 @@ class VetPermissionIndicator extends StatelessWidget { hasPermission ? Icons.verified_user : Icons.lock_outline, size: 16, color: hasPermission - ? const Color(0xFF15A877) // OipState.success - : const Color(0xFF8C8F99), // OipSurface.mutedFg + ? OipState.success + : OipSurface.mutedFg, ); }, ); @@ -175,8 +177,8 @@ class _RequestAccessButton extends StatelessWidget { icon: const Icon(Icons.lock_outline, size: 14), label: const Text('申请权限'), style: TextButton.styleFrom( - foregroundColor: const Color(0xFF366EF4), // OipBrand.primary - textStyle: const TextStyle(fontSize: 12), + foregroundColor: OipBrand.primary, + textStyle: XFonts.labelSmall, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), minimumSize: const Size(0, 28), ), diff --git a/lib/components/form/components/XSelect/XSelect.dart b/lib/components/form/components/XSelect/XSelect.dart index 44168008df415afb9a457328bc7f52a4af2e82d7..c360b7ae78576adab5168014435619d19ba8f3bc 100644 --- a/lib/components/form/components/XSelect/XSelect.dart +++ b/lib/components/form/components/XSelect/XSelect.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:orginone/config/oip_tokens.dart'; +import 'package:orginone/config/theme/unified_style.dart'; class XSelect extends StatelessWidget { final String title; @@ -34,16 +36,16 @@ class XSelect extends StatelessWidget { return Stack( children: [ Container( - color: Colors.white, + color: OipSurface.card, width: double.infinity, - padding: EdgeInsets.symmetric(vertical: 10.w, horizontal: 16.h), + padding: EdgeInsets.symmetric(vertical: 10.h, horizontal: 16.w), child: Container( decoration: BoxDecoration( - color: Colors.white, + color: OipSurface.card, border: showLine ? Border( bottom: - BorderSide(color: Colors.grey.shade200, width: 0.5)) + BorderSide(color: OipBorder.divider, width: 0.5)) : null, ), child: Column( @@ -51,12 +53,11 @@ class XSelect extends StatelessWidget { children: [ Text( title, - style: TextStyle( - color: Colors.black, - fontSize: textStyle?.fontSize ?? 18.sp), + style: textStyle ?? XFonts.bodyLarge, ), GestureDetector( onTap: onTap, + behavior: HitTestBehavior.opaque, child: Padding( padding: EdgeInsets.symmetric(vertical: 10.h), child: Row( @@ -65,20 +66,18 @@ class XSelect extends StatelessWidget { child: content.isEmpty ? Text( hint ?? "请选择", - style: TextStyle( - color: Colors.grey.shade300, - fontSize: textStyle?.fontSize ?? 18.sp), + style: XFonts.bodyLarge + .copyWith(color: OipText.tertiary), ) : Text( content, - style: textStyle ?? - TextStyle( - color: Colors.black, fontSize: 18.sp), + style: textStyle ?? XFonts.bodyLarge, ), ), Icon( - Icons.chevron_right, - size: 32.w, + Icons.chevron_right_rounded, + size: 22.w, + color: OipText.tertiary, ), ], ), @@ -92,9 +91,9 @@ class XSelect extends StatelessWidget { top: 5.h, left: 10.w, child: required - ? const Text( + ? Text( "*", - style: TextStyle(color: Colors.red), + style: XFonts.labelMedium.copyWith(color: OipState.error), ) : Container(), ) diff --git a/lib/components/form/form_data_list_page/form_data_list_page.dart b/lib/components/form/form_data_list_page/form_data_list_page.dart index 60d32ab1a063e847f2c4e0c143f37846f5c91410..907b85d6b1465a760d5fae8da440d32fe12eb1ad 100644 --- a/lib/components/form/form_data_list_page/form_data_list_page.dart +++ b/lib/components/form/form_data_list_page/form_data_list_page.dart @@ -2,12 +2,14 @@ import 'package:flutter/material.dart' hide Form; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:orginone/components/form/widgets/form_datas_view.dart'; +import 'package:orginone/config/oip_tokens.dart'; +import 'package:orginone/config/theme/unified_style.dart'; import 'package:orginone/dart/base/model.dart'; import 'package:orginone/dart/base/schema.dart'; import 'package:orginone/dart/core/public/map_service.dart'; import 'package:orginone/dart/core/thing/standard/form.dart'; import 'package:orginone/dart/base/storages/storage.dart'; -import '../../XStatefulWidget/XStatefulWidget.dart'; +import '../../../components/XStatefulWidget/XStatefulWidget.dart'; /// 表单数据列表页 /// 支持三视图切换:列表 / 地图 / 图表(对齐 AGENTS.md §12.3) @@ -63,10 +65,10 @@ class _FormDataListPageState extends XStatefulState { child: ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: selected - ? Theme.of(context).primaryColor - : Colors.grey.shade200, + ? OipBrand.primary + : OipSurface.muted, foregroundColor: - selected ? Colors.white : Colors.black54, + selected ? Colors.white : OipText.secondary, elevation: 0, padding: const EdgeInsets.symmetric(vertical: 8), ), @@ -74,7 +76,12 @@ class _FormDataListPageState extends XStatefulState { setState(() => _viewIndex = i); _pageController.jumpToPage(i); }, - child: Text(_viewLabels[i]), + child: Text( + _viewLabels[i], + style: selected + ? XFonts.labelLarge.copyWith(color: Colors.white) + : XFonts.labelLarge.copyWith(color: Colors.black54), + ), ), ), ); @@ -103,8 +110,8 @@ class _FormDataListPageState extends XStatefulState { } final records = snapshot.data ?? []; if (records.isEmpty) { - return const Center( - child: Text('暂无含地图型属性的数据'), + return Center( + child: Text('暂无含地图型属性的数据', style: XFonts.bodySmall), ); } return _MapRecordsView(records: records); @@ -122,7 +129,7 @@ class _FormDataListPageState extends XStatefulState { } final stats = snapshot.data ?? {}; if (stats.isEmpty) { - return const Center(child: Text('暂无统计数据')); + return Center(child: Text('暂无统计数据', style: XFonts.bodySmall)); } return _SimpleStatsView(stats: stats); }, @@ -255,7 +262,7 @@ class _MapRecordsViewState extends State<_MapRecordsView> { Icons.location_on, color: _selected?.id == r.id ? Colors.red - : Theme.of(context).primaryColor, + : OipBrand.primary, size: 36, ), ), @@ -297,26 +304,20 @@ class _MapRecordsViewState extends State<_MapRecordsView> { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text( - _selected!.title, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), + Text(_selected!.title, style: XFonts.titleSmall), if (_selected!.mapData.address != null) Padding( padding: const EdgeInsets.only(top: 4), child: Text( _selected!.mapData.address!, - style: const TextStyle(fontSize: 12, color: Colors.grey), + style: XFonts.caption, ), ), Padding( padding: const EdgeInsets.only(top: 4), child: Text( '经纬度: ${_selected!.mapData.longitude.toStringAsFixed(6)}, ${_selected!.mapData.latitude.toStringAsFixed(6)}', - style: const TextStyle(fontSize: 12, color: Colors.grey), + style: XFonts.caption, ), ), ], @@ -352,7 +353,7 @@ class _SimpleStatsView extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(e.key, style: const TextStyle(fontSize: 13)), + Text(e.key, style: XFonts.bodySmall), const SizedBox(height: 4), Row( children: [ @@ -361,7 +362,7 @@ class _SimpleStatsView extends StatelessWidget { child: Container( height: 20, decoration: BoxDecoration( - color: Theme.of(context).primaryColor, + color: OipBrand.primary, borderRadius: BorderRadius.circular(4), ), ), @@ -371,10 +372,7 @@ class _SimpleStatsView extends StatelessWidget { child: const SizedBox(height: 20), ), const SizedBox(width: 8), - Text( - '${e.value}', - style: const TextStyle(fontWeight: FontWeight.bold), - ), + Text('${e.value}', style: XFonts.numberSmall), ], ), ], diff --git a/lib/components/form/form_widget/form_tool.dart b/lib/components/form/form_widget/form_tool.dart index 0493940e47c21e5b0244cf4ffd13bcc0bca596a8..324d5cb50885fc60f39316e4b1264c73bdf1b268 100644 --- a/lib/components/form/form_widget/form_tool.dart +++ b/lib/components/form/form_widget/form_tool.dart @@ -398,6 +398,31 @@ class FormTool { field.field.defaultData.value = value ?? ""; } return field.field.select![value] ?? ""; + case "multiSelect": + // 多选框:value 可能为 List(如 ['S1','S2'])或单值字符串 + // 转换为 List 格式存储,与 mappingMultiSelectBoxWidget 一致 + field.field.select = {}; + for (var lookup in field.lookups ?? []) { + field.field.select![lookup.value] = lookup.text ?? ""; + } + List> selectedItems = []; + if (value is List) { + for (var v in value) { + final key = v.toString(); + if (field.field.select!.containsKey(key)) { + selectedItems.add({key: field.field.select![key]!}); + } + } + } else if (value is String && value.isNotEmpty) { + // 兼容旧版单值字符串 + if (field.field.select!.containsKey(value)) { + selectedItems.add({value: field.field.select![value]!}); + } + } + field.field.defaultData.value = selectedItems; + return selectedItems + .map((m) => m.values.first) + .join('、'); case "upload": // LogUtil.d("file"); @@ -451,19 +476,50 @@ class FormTool { String? regx; Map select = {}; Map rule = jsonDecode(field.rule ?? "{}"); - String widget = rule['widget'] ?? ""; + String widget = rule['widget'] ?? field.widget ?? ""; + + // 对齐 oiocns-react:在 initFields 阶段就把 lookups 填充到 select, + // 避免新建表单(value 为空、loadMainFieldData 返回 false)时下拉框为空 + // React 端在 FormItem 渲染时通过 field.lookups 直接渲染选项, + // Flutter 端 mappingComponents 通过 data.select 渲染选项 + if (field.lookups != null && field.lookups!.isNotEmpty) { + for (var lookup in field.lookups!) { + select[lookup.value] = lookup.text ?? ""; + } + } + // 对齐 oiocns-react getWidget:优先使用 field.widget,再按 valueType 兜底 + // React 端 valueType→widget 映射见 src/components/DataStandard/WorkForm/Utils/index.tsx switch (field.valueType) { case "描述型": - type = "input"; + // React: 文本框/多行文本框/富文本框/超链接框 + if (widget == '多行文本框' || widget == 'textarea') { + type = "input"; + } else { + type = "input"; + } break; case "数值型": - regx = r'[0-9]'; + case "货币型": + // React: 数字框。Flutter 用 input + regx 限制输入 + regx = r'[0-9.]'; type = "input"; break; case "选择型": + // React: 单选框/多选框 + if (widget == 'switch' || widget == '单选框') { + type = "switch"; + } else if (widget == '多选框') { + type = "multiSelect"; + } else { + type = "select"; + } + break; case "分类型": + // React: 多级选择框。Flutter 用 select 兜底,未来可扩展为 treeSelect if (widget == 'switch') { type = "switch"; + } else if (widget == '多选框') { + type = "multiSelect"; } else { type = "select"; } @@ -481,18 +537,32 @@ class FormTool { } break; case "用户型": - if (widget.isEmpty) { + // 对齐 oiocns-react 用户型 widget 列表: + // 操作人/操作组织/成员选择框/内部机构选择框/人员搜索框/单位搜索框/组织群搜索框/群组搜索框 + if (widget.isEmpty || widget == '人员搜索框' || widget == '成员选择框') { type = "selectPerson"; - } else if (widget == 'group') { + } else if (widget == '群组搜索框' || widget == 'group') { type = "selectGroup"; - } else if (widget == 'dept') { + } else if (widget == '内部机构选择框' || + widget == 'dept' || + widget == '操作组织') { type = "selectDepartment"; + } else if (widget == '单位搜索框') { + // 单位搜索:复用 selectGroup(从 parentTarget 取单位列表) + type = "selectGroup"; + } else if (widget == '组织群搜索框') { + type = "selectGroup"; + } else if (widget == '操作人') { + type = "selectPerson"; + } else { + type = "selectPerson"; } break; case '附件型': type = "upload"; break; case '引用型': + // React: 文本框/引用选择框 type = "reference"; break; case '地图型': @@ -529,7 +599,13 @@ class FormTool { select: select, router: router, regx: regx, + // 对齐 oiocns-react:从 FieldModel 读取必填项配置 + // React 端通过 RenderRule(isRequired) 动态控制,此处先读取静态配置 + required: field.options?.isRequired ?? false, readOnly: true, + // 非持久化字段:传递 lookups 和 speciesId 给 sensor/space 组件使用 + lookups: field.lookups, + speciesId: field.speciesId, ); } @@ -771,6 +847,10 @@ class FormTool { isChangeTarget: attr.property?.isChangeTarget, isChangeSource: attr.property?.isChangeSource, isCombination: attr.property?.isCombination, + // 对齐 oiocns-react:从 XAttributeProps 读取必填项配置 + // React 端通过 RenderRule(isRequired) 动态控制,此处先读取静态配置, + // 动态 isRequired 规则由 WorkFormRules 在 running 触发器中处理 + isRequired: attr.options?.isRequired ?? false, ); if (attr.property != null && attr.property!.species != null && diff --git a/lib/components/form/map_property_fields.dart b/lib/components/form/map_property_fields.dart index 7a3bae574c333d8a917aad2a5cb174a1a45dce07..d45089932552d1ecac7f518d30a53537922b9457 100644 --- a/lib/components/form/map_property_fields.dart +++ b/lib/components/form/map_property_fields.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:orginone/config/oip_tokens.dart'; +import 'package:orginone/config/theme/unified_style.dart'; import 'package:orginone/dart/base/schema.dart'; import 'package:orginone/dart/core/public/enums.dart'; import 'package:orginone/dart/core/public/map_service.dart'; @@ -78,7 +79,7 @@ class _MapLocationFieldState extends State { ? LatLng(_data!.latitude, _data!.longitude) : null; final result = await LocationPickerPage.show(context, initialPosition: initial); - if (result != null) { + if (result != null && mounted) { widget.onChanged(result); } } @@ -97,14 +98,14 @@ class _MapLocationFieldState extends State { : const Icon(Icons.location_on, size: 18), ), child: _data == null || (_data!.latitude == 0 && _data!.longitude == 0) - ? const Text('点击选择位置', style: TextStyle(color: Color(0xFF94A3B8))) + ? Text('点击选择位置', style: XFonts.caption.copyWith(color: OipText.tertiary)) : Text( _data!.address?.isNotEmpty == true ? _data!.address! : (_data!.name.isNotEmpty ? _data!.name : '${_data!.latitude.toStringAsFixed(4)}, ${_data!.longitude.toStringAsFixed(4)}'), - style: const TextStyle(fontSize: 13), + style: XFonts.caption, ), ), ); @@ -190,7 +191,7 @@ class _MapRegionFieldState extends State { Future _pick() async { final result = await MapRegionPicker.show(context, initialData: _data); - if (result != null) { + if (result != null && mounted) { widget.onChanged(result); } } @@ -209,10 +210,10 @@ class _MapRegionFieldState extends State { : const Icon(Icons.crop_square, size: 18), ), child: _data == null || _data!.polygon == null || _data!.polygon!.isEmpty - ? const Text('点击选择区域', style: TextStyle(color: Color(0xFF94A3B8))) + ? Text('点击选择区域', style: XFonts.caption.copyWith(color: OipText.tertiary)) : Text( _formatRegion(_data!.polygon!), - style: const TextStyle(fontSize: 12, fontFamily: 'monospace'), + style: XFonts.caption.copyWith(fontFamily: 'monospace'), ), ), ); @@ -337,9 +338,8 @@ class _MapTrackFieldState extends State { : const Icon(Icons.timeline, size: 18), ), child: _path.isEmpty - ? const Text('暂无轨迹', style: TextStyle(color: Color(0xFF94A3B8))) - : Text('${_path.length} 个轨迹点 · 点击查看', - style: const TextStyle(fontSize: 13)), + ? Text('暂无轨迹', style: XFonts.caption.copyWith(color: OipText.tertiary)) + : Text('${_path.length} 个轨迹点 · 点击查看', style: XFonts.caption), ), ); } @@ -559,7 +559,7 @@ class _MapRegionPickerState extends State { : _vertices.length < 3 ? '已添加 ${_vertices.length} 个顶点,还需 ${3 - _vertices.length} 个' : '已添加 ${_vertices.length} 个顶点,点击确定完成', - style: const TextStyle(fontSize: 13), + style: XFonts.caption, ), ), ), diff --git a/lib/components/form/mapping_components.dart b/lib/components/form/mapping_components.dart index 08dbe754bac7fd90a210377a234dd0dd4a51d5a1..efbb15b4a4cf7b4f400e3bea3d25a73859c1a184 100644 --- a/lib/components/form/mapping_components.dart +++ b/lib/components/form/mapping_components.dart @@ -2,9 +2,9 @@ import 'dart:convert'; import 'package:orginone/config/oip_tokens.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:orginone/pages/common/space_select_page.dart'; import 'package:orginone/dart/core/space/space_tree_builder.dart'; -// import 'package:flutter/services.dart'; import 'package:flutter_datetime_picker_plus/flutter_datetime_picker_plus.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; @@ -36,6 +36,7 @@ Map mappingComponents = { "text": mappingTextWidget, "input": mappingInputWidget, "select": mappingSelectBoxWidget, + "multiSelect": mappingMultiSelectBoxWidget, "selectTime": mappingSelectTimeBoxWidget, "selectTimeRange": mappingSelectTimeRangeBoxWidget, "selectDate": mappingSelectDateBoxWidget, @@ -104,7 +105,7 @@ MappingComponentsCallback mappingTextWidget = (Fields data, ITarget target) { required: data.required ?? false, enabled: !(data.readOnly ?? false), showLine: true, - // textStyle: XFonts.size22Black0 + // textStyle: XFonts.bodyLarge ), ); }); @@ -131,20 +132,31 @@ MappingComponentsCallback mappingReferenceTextWidget = required: data.required ?? false, enabled: !(data.readOnly ?? false), showLine: true, - // textStyle: XFonts.size22Black0 + // textStyle: XFonts.bodyLarge ), ); }; MappingComponentsCallback mappingInputWidget = (Fields data, ITarget target) { - // List? inputFormatters; - // if (data.regx != null) { - // inputFormatters = [ - // FilteringTextInputFormatter.allow(RegExp(data.regx!)), - // ]; - // } if (data.hidden ?? false) { return Container(); } + // 对齐 oiocns-react:数值型/货币型字段使用数字键盘 + regx 限制输入 + // React 端 NumberBox 自动处理,Flutter 端通过 keyboardType + inputFormatters 实现 + List? inputFormatters; + TextInputType? keyboardType; + if (data.regx != null && data.regx!.isNotEmpty) { + try { + inputFormatters = [ + FilteringTextInputFormatter.allow(RegExp(data.regx!)), + ]; + // 数值型 regx 包含数字/小数点,使用数字键盘 + if (data.regx!.contains('0-9') || data.regx!.contains('0-9.')) { + keyboardType = const TextInputType.numberWithOptions(decimal: true); + } + } catch (_) { + // regx 解析失败时静默忽略 + } + } return Container( margin: EdgeInsets.only( left: (data.marginLeft ?? 0).h, @@ -160,8 +172,8 @@ MappingComponentsCallback mappingInputWidget = (Fields data, ITarget target) { // data.defaultData.value = str; }, required: data.required ?? false, - // textStyle: XFonts.size22Black0, - // inputFormatters: inputFormatters, + inputFormatters: inputFormatters, + keyboardType: keyboardType, enabled: !(data.readOnly ?? false), showLine: true), ); @@ -191,7 +203,7 @@ MappingComponentsCallback mappingSelectBoxWidget = data.title ?? "", content, onTap: () { - if (!(data.readOnly ?? false)) { + if (!(data.readOnly ?? false) && data.select != null && data.select!.isNotEmpty) { PickerUtils.showListStringPicker(navigatorKey.currentState!.context, titles: data.select!.values.toList(), callback: (str) { int index = data.select!.values.toList().indexOf(str); @@ -202,7 +214,68 @@ MappingComponentsCallback mappingSelectBoxWidget = }, showLine: true, required: data.required ?? false, - textStyle: XFonts.size22Black0, + textStyle: XFonts.bodyLarge, + ), + ); + }); +}; + +/// 多选框组件(对齐 oiocns-react MultiSelectBox) +/// 选择型字段 widget='多选框' 时使用,支持多选,结果以逗号分隔展示 +MappingComponentsCallback mappingMultiSelectBoxWidget = + (Fields data, ITarget target) { + if (data.hidden ?? false) { + return Container(); + } + return Obx(() { + // 多选值存储格式:List>,如 [{key1:value1},{key2:value2}] + // 兼容字符串数组格式 + List selectedValues = []; + if (data.defaultData.value is List) { + for (var item in data.defaultData.value as List) { + if (item is Map) { + selectedValues.add(item.values.first?.toString() ?? ''); + } else { + selectedValues.add(item.toString()); + } + } + } else if (data.defaultData.value is Map) { + selectedValues.add(data.defaultData.value.values.first?.toString() ?? ''); + } + String content = selectedValues.join('、'); + return Container( + margin: EdgeInsets.only( + left: (data.marginLeft ?? 0).h, + right: (data.marginRight ?? 0).h, + top: (data.marginTop ?? 0).h, + bottom: (data.marginBottom ?? 0).h), + child: XSelect.dialog( + data.title ?? "", + content, + onTap: () { + if (!(data.readOnly ?? false) && data.select != null && data.select!.isNotEmpty) { + PickerUtils.showMultiSelectPicker( + navigatorKey.currentState!.context, + titles: data.select!.values.toList(), + selectedTitles: selectedValues, + callback: (List selected) { + // 转换为 List 格式存储 + List> result = []; + for (var str in selected) { + int index = data.select!.values.toList().indexOf(str); + if (index >= 0) { + dynamic key = data.select!.keys.toList()[index]; + result.add({key: str}); + } + } + data.defaultData.value = result; + }, + ); + } + }, + showLine: true, + required: data.required ?? false, + textStyle: XFonts.bodyLarge, ), ); }); @@ -236,7 +309,7 @@ MappingComponentsCallback mappingSelectTimeBoxWidget = }, showLine: true, required: data.required ?? false, - textStyle: XFonts.size22Black0, + textStyle: XFonts.bodyLarge, ), ); }); @@ -274,7 +347,7 @@ MappingComponentsCallback mappingSelectTimeRangeBoxWidget = }, showLine: true, required: data.required ?? false, - textStyle: XFonts.size22Black0, + textStyle: XFonts.bodyLarge, ), ); }); @@ -308,7 +381,7 @@ MappingComponentsCallback mappingSelectDateBoxWidget = }, showLine: true, required: data.required ?? false, - textStyle: XFonts.size22Black0, + textStyle: XFonts.bodyLarge, ), ); }); @@ -346,7 +419,7 @@ MappingComponentsCallback mappingSelectDateRangeBoxWidget = }, showLine: true, required: data.required ?? false, - textStyle: XFonts.size22Black0, + textStyle: XFonts.bodyLarge, ), ); }); @@ -381,7 +454,7 @@ MappingComponentsCallback mappingSelectPersonBoxWidget = }, showLine: true, required: data.required ?? false, - textStyle: XFonts.size22Black0, + textStyle: XFonts.bodyLarge, ), ); }); @@ -431,7 +504,7 @@ MappingComponentsCallback mappingSelectDepartmentBoxWidget = }, showLine: true, required: data.required ?? false, - textStyle: XFonts.size22Black0, + textStyle: XFonts.bodyLarge, ), ); }); @@ -468,7 +541,7 @@ MappingComponentsCallback mappingSelectGroupBoxWidget = }, showLine: true, required: data.required ?? false, - textStyle: XFonts.size22Black0, + textStyle: XFonts.bodyLarge, ), ); }); @@ -492,26 +565,26 @@ MappingComponentsCallback mappingSwitchWidget = (Fields data, ITarget target) { if (data.hidden ?? false) { return Container(); } - if (data.select!.isEmpty) { + if (data.select == null || data.select!.isEmpty) { return mappingTextWidget(data, target); } return Stack( children: [ Container( - color: Colors.white, + color: OipSurface.card, width: double.infinity, padding: EdgeInsets.symmetric(vertical: 10.h, horizontal: 16.w), child: Container( - decoration: BoxDecoration( - color: Colors.white, + decoration: const BoxDecoration( + color: OipSurface.card, border: Border( - bottom: BorderSide(color: Colors.grey.shade200, width: 0.5))), + bottom: BorderSide(color: OipBorder.divider, width: 0.5))), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( data.title ?? '', - style: XFonts.size22Black0, + style: XFonts.bodyLarge, ), data.select!.isEmpty ? const SizedBox() @@ -539,7 +612,7 @@ MappingComponentsCallback mappingSwitchWidget = (Fields data, ITarget target) { child: (data.required ?? false) ? const Text( "*", - style: TextStyle(color: Colors.red), + style: TextStyle(color: OipState.error), ) : Container(), ) @@ -586,7 +659,7 @@ MappingComponentsCallback mappingUploadWidget = (Fields data, ITarget target) { ).paddingVertical(AppSpace.button) ] .toColumn(crossAxisAlignment: CrossAxisAlignment.start) - .border(bottom: 0.5, color: Colors.grey.shade200) + .border(bottom: 0.5, color: OipSurface.border) .marginSymmetric(horizontal: AppSpace.page.w); } else { return Container(); @@ -616,7 +689,7 @@ MappingComponentsCallback mappingUploadWidget = (Fields data, ITarget target) { // } // }, // showLine: true, - // textStyle: XFonts.size22Black0, + // textStyle: XFonts.bodyLarge, // ); }); }; @@ -704,7 +777,7 @@ MappingComponentsCallback mappingMapTrackWidget = // ============== 感知型属性组件(对齐 oiocns-react sensorItem)============== /// 感知型属性 - 传感器选择器(sensor) -/// lookups 已由 Form.loadSensorLookups() 填充 +/// lookups 已由 Form.loadSensorLookups() 填充到 data.lookups /// /// 增强:传感器类型分组 + 实时状态图标(对齐 React 传感器选择框) MappingComponentsCallback mappingSensorWidget = @@ -713,19 +786,14 @@ MappingComponentsCallback mappingSensorWidget = return Container(); } return Obx(() { - final fieldModel = data.defaultData.value; + // 传感器 ID 存储在 defaultData.value(字符串) + final sensorId = data.defaultData.value as String?; + final lookups = data.lookups ?? []; String content = ''; - List lookups = []; - if (fieldModel is FieldModel) { - lookups = fieldModel.lookups ?? []; - final sensorId = fieldModel.value ?? ''; - if (sensorId.isNotEmpty) { - final lookup = lookups.firstWhereOrNull( - (l) => l.value == sensorId || l.id == sensorId); - content = lookup?.text ?? sensorId; - } - } else if (fieldModel is String) { - content = fieldModel; + if (sensorId != null && sensorId.isNotEmpty) { + final lookup = lookups.firstWhereOrNull( + (l) => l.value == sensorId || l.id == sensorId); + content = lookup?.text ?? sensorId; } return Container( margin: EdgeInsets.only( @@ -737,13 +805,13 @@ MappingComponentsCallback mappingSensorWidget = data.title ?? "", content, onTap: () { - if (!(data.readOnly ?? false) && fieldModel is FieldModel) { - _showSensorSelect(data, fieldModel, lookups); + if (!(data.readOnly ?? false) && lookups.isNotEmpty) { + _showSensorSelect(data, lookups); } }, showLine: true, required: data.required ?? false, - textStyle: XFonts.size22Black0, + textStyle: XFonts.bodyLarge, ), ); }); @@ -751,7 +819,7 @@ MappingComponentsCallback mappingSensorWidget = /// 传感器选择弹窗(分组展示 + 状态图标) Future _showSensorSelect( - Fields data, FieldModel fieldModel, List lookups) async { + Fields data, List lookups) async { // 按 remark 字段分组(FiledLookup 无 type 字段) final groups = >{}; for (final l in lookups) { @@ -778,8 +846,8 @@ Future _showSensorSelect( ); if (selected != null && selected.lookup != null) { - fieldModel.value = selected.lookup!.value; - data.defaultData.value = fieldModel; + // 存储传感器 ID(字符串),对齐 select 字段的值格式 + data.defaultData.value = selected.lookup!.value; } } @@ -813,7 +881,7 @@ class _SensorSelectSheet extends StatelessWidget { // 标题栏 Container( padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h), - decoration: BoxDecoration( + decoration: const BoxDecoration( border: Border( bottom: BorderSide(color: OipSurface.border, width: 0.5)), ), @@ -822,9 +890,7 @@ class _SensorSelectSheet extends StatelessWidget { Icon(Icons.sensors, size: 20.w, color: OipBrand.primary), SizedBox(width: 8.w), Expanded( - child: Text(title, - style: TextStyle( - fontSize: 16.sp, fontWeight: FontWeight.w600)), + child: Text(title, style: XFonts.labelMedium), ), GestureDetector( onTap: () => Navigator.pop(context), @@ -847,11 +913,7 @@ class _SensorSelectSheet extends StatelessWidget { color: OipSurface.muted, child: Text( item.headerText!, - style: TextStyle( - fontSize: 12.sp, - fontWeight: FontWeight.w600, - color: OipText.secondary, - ), + style: XFonts.labelMedium.copyWith(color: OipText.secondary), ), ); } @@ -863,11 +925,10 @@ class _SensorSelectSheet extends StatelessWidget { color: OipBrand.primary, ), title: Text(lookup.text ?? '', - style: TextStyle(fontSize: 14.sp)), + style: XFonts.caption), subtitle: lookup.code != null && lookup.code!.isNotEmpty ? Text(lookup.code!, - style: TextStyle( - fontSize: 11.sp, color: OipText.tertiary)) + style: XFonts.caption.copyWith(color: OipText.tertiary)) : null, trailing: Icon(Icons.chevron_right, size: 20.w, color: OipText.tertiary), @@ -943,7 +1004,7 @@ MappingComponentsCallback mappingSpaceWidget = }, showLine: true, required: data.required ?? false, - textStyle: XFonts.size22Black0, + textStyle: XFonts.bodyLarge, ), ); }); @@ -951,12 +1012,8 @@ MappingComponentsCallback mappingSpaceWidget = /// 弹出空间选择页(对齐 oiocns-react SpaceSelectItem) Future _showSpaceSelect(Fields data, ITarget target) async { - // 从 FieldModel 获取 speciesId(Fields 类无 property 字段) - String? speciesId; - final fieldModel = data.defaultData.value; - if (fieldModel is FieldModel) { - speciesId = fieldModel.speciesId; - } + // speciesId 在 initFields 阶段已从 FieldModel 传递到 data.speciesId + final speciesId = data.speciesId; // 解析当前选中的 ID String? selectedId; final currentValue = data.defaultData.value as String?; diff --git a/lib/components/form/view_preview_page.dart b/lib/components/form/view_preview_page.dart index e0b7329f7106df1b0beca169d8dfede876e38994..7f880e2228dfb118bb1ac38a478069e5d002bd1f 100644 --- a/lib/components/form/view_preview_page.dart +++ b/lib/components/form/view_preview_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart' hide Form; import 'package:orginone/config/oip_tokens.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:orginone/config/theme/unified_style.dart'; import 'package:orginone/components/XScaffold/XScaffold.dart'; import 'package:orginone/components/XStatefulWidget/XStatefulWidget.dart'; import 'package:orginone/components/form/widgets/form_datas_view.dart'; @@ -46,7 +47,7 @@ class _ViewPreviewPageState extends XStatefulState { ), decoration: BoxDecoration( gradient: LinearGradient( - colors: [Colors.green.shade600, Colors.green.shade400], + colors: [OipBrand.primary, OipBrand.primaryLight], begin: Alignment.topLeft, end: Alignment.bottomRight, ), @@ -56,11 +57,9 @@ class _ViewPreviewPageState extends XStatefulState { children: [ Text( viewForm?.name ?? '视图', - style: TextStyle( - fontSize: _isWideScreen ? 28.sp : 24.sp, - fontWeight: FontWeight.bold, - color: Colors.white, - ), + style: _isWideScreen + ? XFonts.headlineLarge.copyWith(color: OipText.inverse) + : XFonts.whiteLarge, ), if (viewForm?.metadata.remark != null && viewForm!.metadata.remark!.isNotEmpty) @@ -68,10 +67,7 @@ class _ViewPreviewPageState extends XStatefulState { padding: const EdgeInsets.only(top: 8), child: Text( viewForm!.metadata.remark!, - style: TextStyle( - fontSize: _isWideScreen ? 15.sp : 14.sp, - color: Colors.white70, - ), + style: XFonts.whiteSmall.copyWith(color: OipText.inverse.withValues(alpha: 0.7)), maxLines: 2, overflow: TextOverflow.ellipsis, ), @@ -87,17 +83,10 @@ class _ViewPreviewPageState extends XStatefulState { padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(16), - ), - child: Text( - tag, - style: const TextStyle( - fontSize: 12, - color: Colors.white, - fontWeight: FontWeight.w500, - ), + color: OipText.inverse.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(OipRadius.full), ), + child: Text(tag, style: XFonts.whiteSmall), ); }).toList(), ), @@ -115,15 +104,9 @@ class _ViewPreviewPageState extends XStatefulState { ), padding: EdgeInsets.all(_isWideScreen ? 20.w : 16.w), decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], + color: OipSurface.card, + borderRadius: BorderRadius.circular(OipRadius.lg), + boxShadow: OipShadow.sm, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -134,11 +117,7 @@ class _ViewPreviewPageState extends XStatefulState { const SizedBox(width: 8), Text( '视图信息', - style: TextStyle( - fontSize: _isWideScreen ? 18.sp : 16.sp, - fontWeight: FontWeight.bold, - color: Colors.black87, - ), + style: XFonts.titleSmall.copyWith(color: OipText.primary), ), ], ), @@ -155,21 +134,11 @@ class _ViewPreviewPageState extends XStatefulState { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - label, - style: TextStyle( - fontSize: _isWideScreen ? 15.sp : 14.sp, - color: Colors.grey, - ), - ), + Text(label, style: XFonts.caption), Flexible( child: Text( value, - style: TextStyle( - fontSize: _isWideScreen ? 15.sp : 14.sp, - color: Colors.black87, - fontWeight: FontWeight.w500, - ), + style: XFonts.labelMedium.copyWith(color: OipText.primary), overflow: TextOverflow.ellipsis, ), ), @@ -183,15 +152,9 @@ class _ViewPreviewPageState extends XStatefulState { margin: EdgeInsets.symmetric(horizontal: _isWideScreen ? 24.w : 16.w), padding: EdgeInsets.all(_isWideScreen ? 20.w : 16.w), decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], + color: OipSurface.card, + borderRadius: BorderRadius.circular(OipRadius.lg), + boxShadow: OipShadow.sm, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -202,11 +165,7 @@ class _ViewPreviewPageState extends XStatefulState { const SizedBox(width: 8), Text( '数据预览', - style: TextStyle( - fontSize: _isWideScreen ? 18.sp : 16.sp, - fontWeight: FontWeight.bold, - color: Colors.black87, - ), + style: XFonts.titleSmall.copyWith(color: OipText.primary), ), ], ), @@ -228,14 +187,11 @@ class _ViewPreviewPageState extends XStatefulState { child: Column( children: [ Icon(Icons.visibility_off, - size: 48, color: Colors.grey.shade400), + size: 48, color: OipText.tertiary), const SizedBox(height: 16), Text( '暂不支持此视图预览', - style: TextStyle( - fontSize: 14, - color: Colors.grey.shade600, - ), + style: XFonts.bodySmall.copyWith(color: OipText.tertiary), ), ], ), @@ -263,20 +219,15 @@ class _ViewPreviewPageState extends XStatefulState { } }, style: ElevatedButton.styleFrom( - backgroundColor: Colors.green.shade600, + backgroundColor: OipBrand.primary, foregroundColor: Colors.white, padding: EdgeInsets.symmetric(vertical: _isWideScreen ? 16.h : 14.h), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(OipRadius.md), ), ), icon: const Icon(Icons.list), - label: Text( - '查看完整数据', - style: TextStyle( - fontSize: _isWideScreen ? 17.sp : 16.sp, - fontWeight: FontWeight.w600), - ), + label: Text('查看完整数据', style: XFonts.labelLarge.copyWith(color: Colors.white)), ), ), ], @@ -293,21 +244,18 @@ class _ViewPreviewPageState extends XStatefulState { return XScaffold( titleName: viewForm?.name ?? '视图预览', - backgroundColor: Colors.grey.shade50, + backgroundColor: OipSurface.background, body: viewForm == null ? Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.error_outline, - size: 64, color: Colors.grey.shade400), + size: 64, color: OipText.tertiary), const SizedBox(height: 16), Text( '视图数据不存在', - style: TextStyle( - fontSize: 16, - color: Colors.grey.shade600, - ), + style: XFonts.bodySmall.copyWith(color: OipText.tertiary), ), ], ), diff --git a/lib/components/form/widgets/form_datas_view.dart b/lib/components/form/widgets/form_datas_view.dart index 3265926adc236527b2b29d1b4295dfc6ad6e8182..2b64f3ac993312bf63adddf16e91658148f643b8 100644 --- a/lib/components/form/widgets/form_datas_view.dart +++ b/lib/components/form/widgets/form_datas_view.dart @@ -68,10 +68,9 @@ class _FormDataViewState extends State { _formDataView(XForm xform) { return "three" == widget.showType - ? ( - /*? EmptyWidget(heightva: 260,) - : */ - (_listDatas.isNotEmpty && 1 == _listDatas.length) + ? (_listDatas.isEmpty + ? const EmptyWidget() + : (_listDatas.isNotEmpty && 1 == _listDatas.length) ? Column( children: [ FormDataItem( diff --git a/lib/components/iot/thing_model_view.dart b/lib/components/iot/thing_model_view.dart index 140cb8bdafa29646898c49d9535dc8b4997dc09c..f183875305243dd4517df7210131f766e5e4e58a 100644 --- a/lib/components/iot/thing_model_view.dart +++ b/lib/components/iot/thing_model_view.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:orginone/components/base/oip_a11y.dart'; import 'package:orginone/components/base/oip_component_state.dart'; import 'package:orginone/components/base/oip_feedback.dart'; +import 'package:orginone/config/oip_tokens.dart'; +import 'package:orginone/config/theme/unified_style.dart'; import 'package:orginone/dart/core/iot/command_service.dart'; import 'package:orginone/dart/core/iot/device.dart'; import 'package:orginone/dart/core/iot/thing_model.dart'; @@ -89,9 +92,9 @@ class _ModelHeader extends StatelessWidget { return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: const Color(0xFFF8FAFC), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: const Color(0xFFE2E8F0)), + color: OipSurface.background, + borderRadius: BorderRadius.circular(OipRadius.md), + border: Border.all(color: OipSurface.border), ), child: Row( children: [ @@ -103,12 +106,13 @@ class _ModelHeader extends StatelessWidget { children: [ Text( device.name, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + style: XFonts.titleSmall, ), const SizedBox(height: 2), Text( '${model.name} · ${model.version}', - style: const TextStyle(fontSize: 12, color: Color(0xFF64748B)), + style: XFonts.captionSmall.copyWith( + fontSize: 16.sp, color: OipText.tertiary), ), ], ), @@ -117,11 +121,14 @@ class _ModelHeader extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: statusColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(OipRadius.xs), ), child: Text( device.status.name, - style: TextStyle(fontSize: 11, color: statusColor, fontWeight: FontWeight.w500), + style: XFonts.captionSmall.copyWith( + fontSize: 16.sp, + color: statusColor, + fontWeight: FontWeight.w500), ), ), ], @@ -131,12 +138,12 @@ class _ModelHeader extends StatelessWidget { } Color _statusColor(DeviceStatus status) => switch (status) { - DeviceStatus.online => const Color(0xFF10B981), - DeviceStatus.offline => const Color(0xFF94A3B8), - DeviceStatus.error => const Color(0xFFF43F5E), - DeviceStatus.maintenance => const Color(0xFFF59E0B), - DeviceStatus.unactivated => const Color(0xFF94A3B8), -}; + DeviceStatus.online => OipState.success, + DeviceStatus.offline => OipSurface.mutedFg, + DeviceStatus.error => OipState.error, + DeviceStatus.maintenance => OipState.warning, + DeviceStatus.unactivated => OipSurface.mutedFg, + }; // ============== 属性网格 ============== @@ -184,7 +191,8 @@ class PropertyRow extends StatelessWidget { flex: 2, child: Text( property.name, - style: const TextStyle(fontSize: 13, color: Color(0xFF64748B)), + style: XFonts.captionSmall + .copyWith(fontSize: 16.sp, color: OipText.tertiary), ), ), const SizedBox(width: 8), @@ -217,12 +225,16 @@ class _ValueWidget extends StatelessWidget { case PropertyDataType.timestamp: return Text( _formatTimestamp(value), - style: const TextStyle(fontSize: 13, color: Color(0xFF1E293B)), + style: XFonts.captionSmall + .copyWith(fontSize: 16.sp, color: OipText.primary), ); case PropertyDataType.object: return Text( value?.toString() ?? '—', - style: const TextStyle(fontSize: 12, fontFamily: 'monospace', color: Color(0xFF475569)), + style: XFonts.captionSmall.copyWith( + fontSize: 16.sp, + fontFamily: 'monospace', + color: OipText.secondary), maxLines: 3, overflow: TextOverflow.ellipsis, ); @@ -231,7 +243,8 @@ class _ValueWidget extends StatelessWidget { case PropertyDataType.text: return Text( property.format(value), - style: const TextStyle(fontSize: 13, color: Color(0xFF1E293B)), + style: XFonts.captionSmall + .copyWith(fontSize: 16.sp, color: OipText.primary), ); } } @@ -259,12 +272,14 @@ class _BooleanValue extends StatelessWidget { Icon( v ? Icons.check_circle : Icons.cancel, size: 16, - color: v ? const Color(0xFF10B981) : const Color(0xFF94A3B8), + color: v ? OipState.success : OipSurface.mutedFg, ), const SizedBox(width: 4), Text( v ? '是' : '否', - style: TextStyle(fontSize: 13, color: v ? const Color(0xFF10B981) : const Color(0xFF94A3B8)), + style: XFonts.captionSmall.copyWith( + fontSize: 16.sp, + color: v ? OipState.success : OipSurface.mutedFg), ), ], ); @@ -282,17 +297,20 @@ class _EnumValue extends StatelessWidget { final key = value?.toString() ?? ''; final label = property.enumOptions?[key] ?? key; if (label.isEmpty) { - return const Text('—', style: TextStyle(fontSize: 13, color: Color(0xFF94A3B8))); + return Text('—', + style: XFonts.captionSmall.copyWith( + fontSize: 16.sp, color: OipSurface.mutedFg)); } return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( - color: const Color(0xFF3B82F6).withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(4), + color: OipBrand.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(OipRadius.xs), ), child: Text( label, - style: const TextStyle(fontSize: 12, color: Color(0xFF3B82F6)), + style: XFonts.captionSmall.copyWith( + fontSize: 16.sp, color: OipBrand.primary), ), ); } @@ -306,13 +324,18 @@ class _LocationValue extends StatelessWidget { @override Widget build(BuildContext context) { if (value is! Map) { - return const Text('—', style: TextStyle(fontSize: 13, color: Color(0xFF94A3B8))); + return Text('—', + style: XFonts.captionSmall.copyWith( + fontSize: 16.sp, color: OipSurface.mutedFg)); } final lat = value['lat'] ?? value['latitude']; final lng = value['lng'] ?? value['longitude']; return Text( '$lat, $lng', - style: const TextStyle(fontSize: 12, fontFamily: 'monospace', color: Color(0xFF475569)), + style: XFonts.captionSmall.copyWith( + fontSize: 16.sp, + fontFamily: 'monospace', + color: OipText.secondary), ); } } @@ -439,8 +462,8 @@ class _ServiceButtonState extends State { label: Text(widget.service.name), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - foregroundColor: const Color(0xFF3B82F6), - side: const BorderSide(color: Color(0xFF3B82F6)), + foregroundColor: OipBrand.primary, + side: const BorderSide(color: OipBrand.primary), ), ), ); @@ -641,7 +664,7 @@ class EventHistoryTile extends StatelessWidget { padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: color.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(6), + borderRadius: BorderRadius.circular(OipRadius.sm), border: Border(left: BorderSide(color: color, width: 3)), ), child: Row( @@ -657,12 +680,16 @@ class EventHistoryTile extends StatelessWidget { children: [ Text( event.eventType, - style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: color), + style: XFonts.captionSmall.copyWith( + fontSize: 16.sp, + fontWeight: FontWeight.w500, + color: color), ), const SizedBox(width: 6), Text( _formatTime(event.timestamp), - style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), + style: XFonts.captionSmall.copyWith( + fontSize: 16.sp, color: OipSurface.mutedFg), ), ], ), @@ -670,7 +697,10 @@ class EventHistoryTile extends StatelessWidget { const SizedBox(height: 4), Text( event.data.toString(), - style: const TextStyle(fontSize: 11, color: Color(0xFF475569), fontFamily: 'monospace'), + style: XFonts.captionSmall.copyWith( + fontSize: 16.sp, + color: OipText.secondary, + fontFamily: 'monospace'), maxLines: 2, overflow: TextOverflow.ellipsis, ), @@ -686,7 +716,9 @@ class EventHistoryTile extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 8), minimumSize: const Size(44, 32), ), - child: Text('确认', style: TextStyle(fontSize: 12, color: color)), + child: Text('确认', + style: XFonts.captionSmall + .copyWith(fontSize: 16.sp, color: color)), ), ), ], @@ -701,12 +733,12 @@ class EventHistoryTile extends StatelessWidget { } Color _levelColor(ThingEventLevel level) => switch (level) { - ThingEventLevel.critical => const Color(0xFFDC2626), - ThingEventLevel.error => const Color(0xFFF43F5E), - ThingEventLevel.warning => const Color(0xFFF59E0B), - ThingEventLevel.info => const Color(0xFF3B82F6), - ThingEventLevel.debug => const Color(0xFF94A3B8), -}; + ThingEventLevel.critical => OipState.error, + ThingEventLevel.error => OipState.error, + ThingEventLevel.warning => OipState.warning, + ThingEventLevel.info => OipState.info, + ThingEventLevel.debug => OipSurface.mutedFg, + }; IconData _levelIcon(ThingEventLevel level) => switch (level) { ThingEventLevel.critical => Icons.error, @@ -728,11 +760,12 @@ class _SectionTitle extends StatelessWidget { Widget build(BuildContext context) { return Row( children: [ - Icon(icon, size: 16, color: const Color(0xFF64748B)), + Icon(icon, size: 16, color: OipText.tertiary), const SizedBox(width: 6), Text( title, - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF1E293B)), + style: XFonts.labelSmall.copyWith( + fontWeight: FontWeight.w600, color: OipText.primary), ), ], ); diff --git a/lib/config/theme/unified_style.dart b/lib/config/theme/unified_style.dart index 2a475c46a7c0597c2424f0f6f3c085276944feb2..22f8470abaec215d6a1bf7e97485a80f9c4b0ec9 100644 --- a/lib/config/theme/unified_style.dart +++ b/lib/config/theme/unified_style.dart @@ -1,3 +1,4 @@ +import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; @@ -187,25 +188,222 @@ class XFonts { ///沟通 //沟通会话信息 static TextStyle get chatSMInfo => size24Black0; //文本信息 - static get chatSMSysTip => size14Black0; //系统提示文本信息 - static get chatSMTimeTip => - TextStyle(color: Colors.grey, fontSize: 18.sp); //聊天时间提示信息 - - /// 门户/设置页字体 - static TextStyle get portalHeroTitle => size22Black0W700; - static TextStyle get portalPageTitle => size22Black0; - static TextStyle get portalListTitle => size20Black3; - static TextStyle get portalListSubtitle => size18Black6; - static TextStyle get portalListMeta => size20Black3; - static TextStyle get portalSectionTitle => size14Black9W700; - static TextStyle get portalActionLabel => size20Black3W700; - static TextStyle get portalDeptLabel => size22Black3W700; - static TextStyle get portalBadge => size14Black9; + static get chatSMSysTip => size16Black0; //系统提示文本信息 + static TextStyle get chatSMTimeTip => + captionSmall.copyWith(color: OipText.tertiary); //聊天时间提示信息 + + /// ============================================================ + /// v6.0 语义化字体层级(App 级统一 Typography 规范) + /// + /// 设计稿基准:540×1170 → ScreenUtil 自动缩放 + /// 视觉层级:display > headline > title > body > label > caption + /// 字重规范:w700=标题/强调,w600=列表标题/按钮,w500=正文,w400=辅助文字 + /// ============================================================ + + // --- Display / 超大标题(页面顶部大标题、欢迎语)--- + static TextStyle get displayLarge => TextStyle( + fontSize: 36.sp, + fontWeight: FontWeight.w700, + color: OipText.primary, + height: 1.25, + ); + static TextStyle get displayMedium => TextStyle( + fontSize: 32.sp, + fontWeight: FontWeight.w700, + color: OipText.primary, + height: 1.25, + ); + + // --- Headline / 页面标题(AppBar、Tab标题)--- + static TextStyle get headlineLarge => TextStyle( + fontSize: 28.sp, + fontWeight: FontWeight.w700, + color: OipText.primary, + height: 1.3, + ); + static TextStyle get headlineMedium => TextStyle( + fontSize: 26.sp, + fontWeight: FontWeight.w700, + color: OipText.primary, + height: 1.3, + ); + static TextStyle get headlineSmall => TextStyle( + fontSize: 24.sp, + fontWeight: FontWeight.w600, + color: OipText.primary, + height: 1.3, + ); + + // --- Title / 区域标题(Section Header、卡片标题)--- + /// 区块主标题(如"最近应用""消息""待办") + static TextStyle get titleLarge => TextStyle( + fontSize: 24.sp, + fontWeight: FontWeight.w700, + color: OipText.primary, + height: 1.3, + ); + static TextStyle get titleMedium => TextStyle( + fontSize: 22.sp, + fontWeight: FontWeight.w600, + color: OipText.primary, + height: 1.3, + ); + static TextStyle get titleSmall => TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.w600, + color: OipText.primary, + height: 1.35, + ); + + // --- Body / 正文(列表项主标题、内容文本)--- + /// 列表项主标题(联系人名、应用名、标题) + static TextStyle get bodyLarge => TextStyle( + fontSize: 22.sp, + fontWeight: FontWeight.w600, + color: OipText.primary, + height: 1.4, + ); + static TextStyle get bodyMedium => TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.w500, + color: OipText.secondary, + height: 1.45, + ); + static TextStyle get bodySmall => TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w400, + color: OipText.tertiary, + height: 1.5, + ); + + // --- Label / 标签/按钮(Badge、标签、操作按钮文字)--- + static TextStyle get labelLarge => TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w600, + color: OipText.primary, + height: 1.3, + ); + static TextStyle get labelMedium => TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w500, + color: OipText.secondary, + height: 1.3, + ); + static TextStyle get labelSmall => TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w500, + color: OipText.tertiary, + height: 1.3, + ); + + // --- TabBar 专用(统一 18sp,对齐各页面 Tab 页签字体)--- + static TextStyle get tabLabel => TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w600, + color: OipText.primary, + height: 1.3, + ); + static TextStyle get tabUnselected => TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w500, + color: OipText.tertiary, + height: 1.3, + ); + + // --- Caption / 辅助说明(时间、数量、提示文字)--- + static TextStyle get caption => TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w400, + color: OipText.tertiary, + height: 1.3, + ); + static TextStyle get captionSmall => TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w400, + color: OipText.disabled, + height: 1.3, + ); + + // --- 数字/统计样式 --- + static TextStyle get numberLarge => TextStyle( + fontSize: 32.sp, + fontWeight: FontWeight.w700, + color: OipText.primary, + height: 1.2, + fontFeatures: const [FontFeature.tabularFigures()], + ); + static TextStyle get numberMedium => TextStyle( + fontSize: 24.sp, + fontWeight: FontWeight.w700, + color: OipText.primary, + height: 1.2, + fontFeatures: const [FontFeature.tabularFigures()], + ); + static TextStyle get numberSmall => TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.w700, + color: OipText.primary, + height: 1.2, + fontFeatures: const [FontFeature.tabularFigures()], + ); + + // --- 主题色文字(链接/品牌强调)--- + static TextStyle get brandLarge => TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.w600, + color: OipBrand.primary, + height: 1.3, + ); + static TextStyle get brandMedium => TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w600, + color: OipBrand.primary, + height: 1.3, + ); + static TextStyle get brandSmall => TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w500, + color: OipBrand.primary, + height: 1.3, + ); + + // --- 白色文字(深色背景/渐变按钮上)--- + static TextStyle get whiteLarge => TextStyle( + fontSize: 24.sp, + fontWeight: FontWeight.w700, + color: Colors.white, + height: 1.3, + ); + static TextStyle get whiteMedium => TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.w600, + color: Colors.white, + height: 1.3, + ); + static TextStyle get whiteSmall => TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w500, + color: Colors.white, + height: 1.3, + ); + + /// 门户/设置页字体(v6.0 升级:使用语义化层级替换过小字号) + static TextStyle get portalHeroTitle => headlineMedium; // 大标题 + static TextStyle get portalPageTitle => headlineSmall; // 页面标题 + static TextStyle get portalListTitle => titleSmall; // 列表标题 + static TextStyle get portalListSubtitle => bodySmall; // 列表副标题 + static TextStyle get portalListMeta => caption; // 列表元信息 + static TextStyle get portalSectionTitle => titleMedium; // 区块标题(原size14Black9W700太小) + static TextStyle get portalActionLabel => labelLarge.copyWith( + color: OipBrand.primary, + ); // "更多""查看全部" + static TextStyle get portalDeptLabel => titleMedium; // 部门名 + static TextStyle get portalBadge => labelSmall; // Badge文字 /// 列表字体别名(供 master 分支代码使用) - static TextStyle get listTitle => portalListTitle; - static TextStyle get listSubtitle => portalListSubtitle; - static TextStyle get listSection => portalSectionTitle; + static TextStyle get listTitle => bodyLarge; // 列表主标题(升级到22sp) + static TextStyle get listSubtitle => bodyMedium; // 列表副标题(升级到20sp) + static TextStyle get listSection => titleMedium; // 列表区块标题 ///动态模块 ///动态子标题样式 @@ -213,53 +411,53 @@ class XFonts { static get activityListTitle => size22Black0; //标题 static get activitListContent => size24Black0; //摘要 - ///通用字体颜色 + ///通用字体颜色(v6.1 统一最小字号 16sp,原 12/14/15sp 全部升级到 16sp) static get size12Black0 { - return TextStyle(fontSize: 12.sp, color: XColors.black); + return TextStyle(fontSize: 16.sp, color: XColors.black); } static get size12Black3 { - return TextStyle(fontSize: 12.sp, color: XColors.black3); + return TextStyle(fontSize: 16.sp, color: XColors.black3); } static get size12Black6 { - return TextStyle(fontSize: 12.sp, color: XColors.black6); + return TextStyle(fontSize: 16.sp, color: XColors.black6); } static get size12Black9 { - return TextStyle(fontSize: 12.sp, color: XColors.black9); + return TextStyle(fontSize: 16.sp, color: XColors.black9); } static get size14Black0 { - return TextStyle(fontSize: 14.sp, color: XColors.black); + return TextStyle(fontSize: 16.sp, color: XColors.black); } static get size14Black3 { - return TextStyle(fontSize: 14.sp, color: XColors.black3); + return TextStyle(fontSize: 16.sp, color: XColors.black3); } static get size14Black6 { - return TextStyle(fontSize: 14.sp, color: XColors.black6); + return TextStyle(fontSize: 16.sp, color: XColors.black6); } static get size14Black9 { - return TextStyle(fontSize: 14.sp, color: XColors.black9); + return TextStyle(fontSize: 16.sp, color: XColors.black9); } static get size15Black0 { - return TextStyle(fontSize: 15.sp, color: XColors.black); + return TextStyle(fontSize: 16.sp, color: XColors.black); } static get size15Black3 { - return TextStyle(fontSize: 15.sp, color: XColors.black3); + return TextStyle(fontSize: 16.sp, color: XColors.black3); } static get size15Black6 { - return TextStyle(fontSize: 15.sp, color: XColors.black6); + return TextStyle(fontSize: 16.sp, color: XColors.black6); } static get size15Black9 { - return TextStyle(fontSize: 15.sp, color: XColors.black9); + return TextStyle(fontSize: 16.sp, color: XColors.black9); } static get size16Black0 { @@ -375,11 +573,11 @@ class XFonts { } static get size12Theme { - return TextStyle(fontSize: 12.sp, color: OipBrand.primary); + return TextStyle(fontSize: 16.sp, color: OipBrand.primary); } static get size14Theme { - return TextStyle(fontSize: 14.sp, color: OipBrand.primary); + return TextStyle(fontSize: 16.sp, color: OipBrand.primary); } static get size16Theme { @@ -411,11 +609,11 @@ class XFonts { } static get size12White { - return TextStyle(fontSize: 12.sp, color: XColors.white); + return TextStyle(fontSize: 16.sp, color: XColors.white); } static get size14White { - return TextStyle(fontSize: 14.sp, color: XColors.white); + return TextStyle(fontSize: 16.sp, color: XColors.white); } static get size16White { @@ -449,35 +647,35 @@ class XFonts { static var w700 = FontWeight.w700; static get size12Black0W700 { - return TextStyle(fontSize: 12.sp, color: XColors.black, fontWeight: w700); + return TextStyle(fontSize: 16.sp, color: XColors.black, fontWeight: w700); } static get size12Black3W700 { - return TextStyle(fontSize: 12.sp, color: XColors.black3, fontWeight: w700); + return TextStyle(fontSize: 16.sp, color: XColors.black3, fontWeight: w700); } static get size12Black6W700 { - return TextStyle(fontSize: 12.sp, color: XColors.black6, fontWeight: w700); + return TextStyle(fontSize: 16.sp, color: XColors.black6, fontWeight: w700); } static get size12Black9W700 { - return TextStyle(fontSize: 12.sp, color: XColors.black9, fontWeight: w700); + return TextStyle(fontSize: 16.sp, color: XColors.black9, fontWeight: w700); } static get size14Black0W700 { - return TextStyle(fontSize: 14.sp, color: XColors.black, fontWeight: w700); + return TextStyle(fontSize: 16.sp, color: XColors.black, fontWeight: w700); } static get size14Black3W700 { - return TextStyle(fontSize: 14.sp, color: XColors.black3, fontWeight: w700); + return TextStyle(fontSize: 16.sp, color: XColors.black3, fontWeight: w700); } static get size14Black6W700 { - return TextStyle(fontSize: 14.sp, color: XColors.black6, fontWeight: w700); + return TextStyle(fontSize: 16.sp, color: XColors.black6, fontWeight: w700); } static get size14Black9W700 { - return TextStyle(fontSize: 14.sp, color: XColors.black9, fontWeight: w700); + return TextStyle(fontSize: 16.sp, color: XColors.black9, fontWeight: w700); } static get size16Black0W700 { @@ -593,11 +791,11 @@ class XFonts { } static get size12WhiteW700 { - return TextStyle(fontSize: 12.sp, color: XColors.white, fontWeight: w700); + return TextStyle(fontSize: 16.sp, color: XColors.white, fontWeight: w700); } static get size14WhiteW700 { - return TextStyle(fontSize: 14.sp, color: XColors.white, fontWeight: w700); + return TextStyle(fontSize: 16.sp, color: XColors.white, fontWeight: w700); } static get size16WhiteW700 { @@ -630,12 +828,12 @@ class XFonts { static get size12ThemeW700 { var theme = OipBrand.primary; - return TextStyle(fontSize: 12.sp, color: theme, fontWeight: w700); + return TextStyle(fontSize: 16.sp, color: theme, fontWeight: w700); } static get size14ThemeW700 { var theme = OipBrand.primary; - return TextStyle(fontSize: 14.sp, color: theme, fontWeight: w700); + return TextStyle(fontSize: 16.sp, color: theme, fontWeight: w700); } static get size16ThemeW700 { @@ -673,3 +871,468 @@ class XFonts { return TextStyle(fontSize: 28.sp, color: theme, fontWeight: w700); } } + +/// ============================================================ +/// XUi — v6.0 统一组件样式帮助类 +/// +/// 所有页面使用此帮助类创建统一风格的卡片、按钮、徽章、间距等, +/// 确保圆角、阴影、边距、内边距、颜色在全App一致。 +/// ============================================================ +class XUi { + XUi._(); + + // --- 卡片装饰(统一圆角、阴影、边框)--- + + /// 标准卡片装饰:白色底 + 12.r圆角 + 轻阴影 + 细边框 + static BoxDecoration cardDecoration({ + Color? color, + double? radius, + bool shadow = true, + bool border = false, + Color? borderColor, + }) { + return BoxDecoration( + color: color ?? OipSurface.card, + borderRadius: BorderRadius.circular((radius ?? OipRadius.lg).w), + boxShadow: shadow ? OipShadow.sm : null, + border: border + ? Border.all(color: borderColor ?? OipBorder.defaultBorder, width: 0.8) + : null, + ); + } + + /// 浅色填充卡片(灰色底,无边框无阴影,用于分组容器) + static BoxDecoration cardMuted({double? radius}) { + return BoxDecoration( + color: OipSurface.muted, + borderRadius: BorderRadius.circular((radius ?? OipRadius.lg).w), + ); + } + + /// 品牌色渐变按钮/卡片背景 + static BoxDecoration brandGradient({ + double? radius, + List? colors, + }) { + return BoxDecoration( + gradient: LinearGradient( + colors: colors ?? [OipBrand.primary, OipBrand.primaryDark], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular((radius ?? OipRadius.lg).w), + boxShadow: [ + BoxShadow( + color: OipBrand.primary.withValues(alpha: 0.3), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ); + } + + /// 圆角矩形装饰(通用容器) + static BoxDecoration rounded({ + required Color color, + double radius = OipRadius.md, + Color? borderColor, + double borderWidth = 0.8, + }) { + return BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(radius.w), + border: borderColor != null + ? Border.all(color: borderColor, width: borderWidth) + : null, + ); + } + + // --- 徽章/标签样式 --- + + /// 填充式标签(品牌色/自定义色背景白字) + static BoxDecoration filledBadge(Color color, {double? radius}) { + return BoxDecoration( + color: color, + borderRadius: BorderRadius.circular((radius ?? OipRadius.sm).w), + ); + } + + /// 浅色描边标签(10%透明度底色 + 35%透明度边框) + static BoxDecoration softBadge(Color color, {double? radius}) { + return BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular((radius ?? OipRadius.sm).w), + border: Border.all(color: color.withValues(alpha: 0.35), width: 0.8), + ); + } + + // --- 毛玻璃 / 玻璃态 --- + + /// 毛玻璃卡片装饰:半透明白底 + 细描边 + 轻阴影(搭配 [frostedGlass] 包装使用) + static BoxDecoration frostedDecoration({ + double? radius, + Color? tint, + double alpha = 0.72, + bool border = true, + }) { + return BoxDecoration( + color: (tint ?? OipSurface.card).withValues(alpha: alpha), + borderRadius: BorderRadius.circular((radius ?? OipRadius.lg).w), + border: border + ? Border.all( + color: OipText.inverse.withValues(alpha: 0.4), + width: 0.8, + ) + : null, + boxShadow: OipShadow.sm, + ); + } + + /// 毛玻璃容器:BackdropFilter 模糊 + 半透明装饰 + /// 用于在渐变/复杂背景上叠加毛玻璃卡片效果 + static Widget frostedGlass({ + required Widget child, + double sigma = 12.0, + double? radius, + Color? tint, + double alpha = 0.72, + bool border = true, + EdgeInsets padding = EdgeInsets.zero, + }) { + final decoration = frostedDecoration( + radius: radius, + tint: tint, + alpha: alpha, + border: border, + ); + return ClipRRect( + borderRadius: BorderRadius.circular((radius ?? OipRadius.lg).w), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma), + child: Container( + decoration: decoration, + padding: padding, + child: child, + ), + ), + ); + } + + // --- 统一间距快捷方法 --- + + static SizedBox hGap(double size) => SizedBox(height: size.h); + static SizedBox wGap(double size) => SizedBox(width: size.w); + + /// 标准内容区水平边距 + static EdgeInsets get pagePadding => EdgeInsets.symmetric(horizontal: 20.w); + + /// 卡片内边距 + static EdgeInsets get cardPadding => + EdgeInsets.symmetric(horizontal: 20.w, vertical: 16.h); + + /// 列表项内边距 + static EdgeInsets get listItemPadding => + EdgeInsets.symmetric(horizontal: 20.w, vertical: 14.h); + + // --- 统一Section Header构建器 --- + + /// 构建统一的区块标题栏(标题 + 右侧"更多"按钮) + static Widget sectionHeader({ + required String title, + String? actionLabel, + VoidCallback? onActionTap, + Widget? trailing, + Color? titleColor, + double? bottomPadding, + }) { + return Padding( + padding: EdgeInsets.fromLTRB(20.w, 24.h, 20.w, (bottomPadding ?? 12).h), + child: Row( + children: [ + Expanded( + child: Text( + title, + style: XFonts.titleMedium.copyWith( + color: titleColor ?? OipText.primary, + ), + ), + ), + if (trailing != null) + trailing + else if (actionLabel != null && onActionTap != null) + GestureDetector( + onTap: onActionTap, + behavior: HitTestBehavior.opaque, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + actionLabel, + style: XFonts.brandMedium, + ), + SizedBox(width: 3.w), + Icon( + Icons.chevron_right_rounded, + size: 20.w, + color: OipBrand.primary, + ), + ], + ), + ), + ], + ), + ); + } + + // --- 统一列表项构建器 --- + + /// 构建统一的列表项(图标容器 + 标题/副标题 + 右侧箭头/自定义) + static Widget listItem({ + Widget? leading, + IconData? leadingIcon, + Color? leadingIconColor, + Color? leadingBgColor, + double? leadingSize, + required String title, + String? subtitle, + Widget? trailing, + bool showArrow = false, + VoidCallback? onTap, + Color? titleColor, + bool border = false, + EdgeInsetsGeometry? padding, + }) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Container( + padding: padding ?? listItemPadding, + decoration: BoxDecoration( + color: OipSurface.card, + border: border + ? const Border( + bottom: BorderSide(color: OipBorder.divider, width: 0.5), + ) + : null, + ), + child: Row( + children: [ + if (leading != null) ...[ + leading, + SizedBox(width: 14.w), + ] else if (leadingIcon != null) ...[ + Container( + width: (leadingSize ?? 44).w, + height: (leadingSize ?? 44).w, + decoration: BoxDecoration( + color: leadingBgColor ?? OipBrand.primaryBg, + borderRadius: BorderRadius.circular(OipRadius.md.w), + ), + child: Icon( + leadingIcon, + size: 22.w, + color: leadingIconColor ?? OipBrand.primary, + ), + ), + SizedBox(width: 14.w), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: XFonts.bodyLarge.copyWith( + color: titleColor ?? OipText.primary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (subtitle != null) ...[ + SizedBox(height: 4.h), + Text( + subtitle, + style: XFonts.bodyMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + if (trailing != null) ...[ + SizedBox(width: 12.w), + trailing, + ], + if (showArrow) ...[ + SizedBox(width: 8.w), + Icon( + Icons.chevron_right_rounded, + size: 22.w, + color: OipText.disabled, + ), + ], + ], + ), + ), + ); + } + + // --- 统一主按钮 --- + + /// 主行动按钮(品牌蓝渐变,圆角lg) + static Widget primaryButton({ + required String label, + required VoidCallback onTap, + double? width, + double? height, + double? fontSize, + IconData? icon, + bool disabled = false, + }) { + final h = (height ?? 48).h; + return GestureDetector( + onTap: disabled ? null : onTap, + behavior: HitTestBehavior.opaque, + child: Opacity( + opacity: disabled ? 0.5 : 1.0, + child: Container( + width: width?.w ?? double.infinity, + height: h, + decoration: brandGradient(radius: OipRadius.lg), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 20.w, color: Colors.white), + SizedBox(width: 8.w), + ], + Text( + label, + style: XFonts.whiteMedium.copyWith( + fontSize: (fontSize ?? 20).sp, + height: 1.2, + ), + ), + ], + ), + ), + ), + ); + } + + /// 次要按钮(白底蓝边蓝字) + static Widget secondaryButton({ + required String label, + required VoidCallback onTap, + double? width, + double? height, + }) { + final h = (height ?? 48).h; + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Container( + width: width?.w ?? double.infinity, + height: h, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(OipRadius.lg.w), + border: Border.all(color: OipBrand.primaryBorder, width: 1.2), + ), + child: Center( + child: Text( + label, + style: XFonts.brandMedium.copyWith(height: 1.2), + ), + ), + ), + ); + } + + /// 空状态提示组件 + static Widget emptyState({ + IconData icon = Icons.inbox_outlined, + String? title, + String message = '暂无内容', + String? actionLabel, + VoidCallback? onAction, + }) { + return Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 48.h), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 56.w, color: OipText.disabled), + SizedBox(height: 16.h), + if (title != null) ...[ + Text(title, style: XFonts.titleSmall), + SizedBox(height: 6.h), + ], + Text(message, style: XFonts.bodySmall), + if (actionLabel != null && onAction != null) ...[ + SizedBox(height: 16.h), + GestureDetector( + onTap: onAction, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 10.h), + decoration: BoxDecoration( + color: OipBrand.primaryBg, + borderRadius: BorderRadius.circular(OipRadius.md.w), + ), + child: Text(actionLabel, style: XFonts.brandMedium), + ), + ), + ], + ], + ), + ), + ); + } + + /// 数据更新中顶部指示条(已有数据后台刷新时使用) + static Widget refreshingBanner({String message = '数据更新中'}) { + return Container( + width: double.infinity, + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), + color: OipState.warningBg, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 14.w, + height: 14.w, + child: const CircularProgressIndicator( + strokeWidth: 1.8, + valueColor: AlwaysStoppedAnimation(OipState.warning), + ), + ), + SizedBox(width: 8.w), + Text( + message, + style: XFonts.labelSmall.copyWith(color: OipState.warning), + ), + ], + ), + ); + } + + /// 数据加载中全屏占位(首次加载时使用) + static Widget loadingView({String message = '数据加载中'}) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator( + color: OipBrand.primary, + strokeWidth: 2.5, + ), + SizedBox(height: 12.h), + Text(message, style: XFonts.captionSmall), + ], + ), + ); + } +} diff --git a/lib/dart/base/api/http_util.dart b/lib/dart/base/api/http_util.dart index 9154612d1a1cf272f7a56906bc48bf42e05d59f6..ddb5c05f1a5bf7046228b0dfe1f4cd77a4fd5438 100644 --- a/lib/dart/base/api/http_util.dart +++ b/lib/dart/base/api/http_util.dart @@ -2,8 +2,10 @@ import 'package:dio/dio.dart'; import 'package:logging/logging.dart'; import 'package:orginone/components/Tip/ToastUtils.dart'; import 'package:orginone/config/constant.dart'; +import 'package:orginone/dart/base/common/systemError.dart'; import 'package:orginone/dart/base/model.dart'; import 'package:orginone/dart/core/provider/auth.dart'; +import 'package:orginone/utils/log/log_util.dart'; import 'package:orginone/main.dart'; // import 'package:pretty_dio_logger/pretty_dio_logger.dart'; import 'package:uuid/uuid.dart'; @@ -33,13 +35,23 @@ class HttpUtil with AuthMixin implements StoreHubHttpClient { late final Dio _dio; final Logger log = Logger('HttpLogger'); + /// 408 超时 Toast 节流,避免政务服务器慢响应时频繁弹提示 + static DateTime? _last408ToastAt; + static const Duration _408ToastThrottle = Duration(seconds: 15); + String get baseUrl => _dio.options.baseUrl; BaseOptions _buildOptions() { return BaseOptions( baseUrl: Constant.host, - connectTimeout: const Duration(seconds: 15), - receiveTimeout: const Duration(seconds: 30), + // 连接超时 60s:政务服务器(assetcloud.zj.gov.cn)响应较慢,15s 会导致大量 408 超时 + // 与 receiveTimeout 保持一致,避免连接建立后等待响应阶段又超时 + connectTimeout: const Duration(seconds: 60), + // 接收超时保持 60s:政务服务器(assetcloud.zj.gov.cn)部分请求响应时间 > 30s + // 30s 会导致 HTTP 408 超时错误(见错误日志 http_timeout_fallback 路径) + // 最坏情况 20s SignalR + 60s HTTP = 80s,但拿到数据优于 50s 拿到错误 + // 正常情况 SignalR 命中则不触发 HTTP 超时;本地缓存兜底仍生效 + receiveTimeout: const Duration(seconds: 60), headers: {}, contentType: 'application/json; charset=utf-8', responseType: ResponseType.json, @@ -59,8 +71,7 @@ class HttpUtil with AuthMixin implements StoreHubHttpClient { ..add(InterceptorsWrapper(onRequest: (options, handler) { // dio 在 path 为完整 URL 时不会拼接 baseUrl,直接使用 path。 // 这里用 options.uri 显示真实请求 URL,避免误导性的"双拼"日志。 - log.warning( - 'HTTP Request: ${options.method} ${options.uri}'); + log.warning('HTTP Request: ${options.method} ${options.uri}'); return handler.next(options); }, onResponse: (response, handler) { return handler.next(response); @@ -70,8 +81,9 @@ class HttpUtil with AuthMixin implements StoreHubHttpClient { } void init() { - if (_dio.options.baseUrl != Constant.host) { - _dio.options = _buildOptions(); + // 始终更新 options(确保 timeout 等配置变更生效) + _dio.options = _buildOptions(); + if (_dio.options.baseUrl != Constant.host || _dio.interceptors.isEmpty) { _setupInterceptors(); } } @@ -202,8 +214,10 @@ class HttpUtil with AuthMixin implements StoreHubHttpClient { }) { final cost = DateTime.now().millisecondsSinceEpoch - startedAt; final message = error.toString(); - log.warning( - '[$transportTag][$requestId] $method $path failed in ${cost}ms: $message'); + final logMsg = + '[$transportTag][$requestId] $method $path failed in ${cost}ms: $message'; + XLogUtil.e(logMsg); + SystemLog.err(logMsg, 'HTTP异常'); const friendlyMessage = '网络异常,请检查网络连接'; if (showToast) { ToastUtils.showMsg(msg: friendlyMessage); @@ -222,17 +236,49 @@ class HttpUtil with AuthMixin implements StoreHubHttpClient { }) async { final cost = DateTime.now().millisecondsSinceEpoch - startedAt; final response = error.response; - final statusCode = response?.statusCode ?? 500; + // 关键修复:超时(receiveTimeout/connectionTimeout/sendTimeout)时 response 为 null, + // 不能用 response?.statusCode ?? 500 错误归类为 500(误导排查,且会走 500 的 toast 提示)。 + // 超时应归 408,让上层(StoreHub/缓存兜底逻辑)能正确识别为网络问题而非服务端错误。 + final int statusCode; + if (error.type == DioExceptionType.connectionTimeout || + error.type == DioExceptionType.sendTimeout || + error.type == DioExceptionType.receiveTimeout) { + statusCode = 408; + } else if (error.type == DioExceptionType.connectionError) { + // connectionError 含 DNS 解析失败(host lookup)、连接被拒、连接重置等客户端网络问题, + // 与服务端 502 Bad Gateway 完全不同。归为 499 以便上层识别为"客户端网络异常", + // 友好提示"网络连接异常",避免误导用户以为是网关故障。 + statusCode = 499; + } else if (error.type == DioExceptionType.cancel) { + statusCode = 499; + } else { + statusCode = response?.statusCode ?? 500; + } final message = response?.data is Map ? ResultType.fromJson(response!.data as Map).msg : response?.statusMessage ?? error.message ?? '请求失败'; - log.warning( - '[$transportTag][$requestId] $method $path failed with $statusCode in ${cost}ms: $message'); + final logMsg = + '[$transportTag][$requestId] $method $path failed with $statusCode in ${cost}ms: $message'; + if (statusCode == 408) { + // 408 超时是政务服务器慢响应的常见情况,降级为 warning,不写入错误日志 + XLogUtil.w(logMsg); + } else { + XLogUtil.e(logMsg); + SystemLog.err(logMsg, 'HTTP错误[$statusCode]'); + } if (statusCode == 401) { // 401 时去重触发一次 refreshToken 并 await 完成,确保后续请求拿到新 token await _ensureRefreshToken(); - } else if (showToast) { + } else if (showToast && statusCode != 408) { ToastUtils.showMsg(msg: _friendlyMessage(statusCode, error)); + } else if (showToast && statusCode == 408) { + // 408 超时 Toast 节流,15 秒内只弹一次 + final now = DateTime.now(); + if (_last408ToastAt == null || + now.difference(_last408ToastAt!) > _408ToastThrottle) { + _last408ToastAt = now; + ToastUtils.showMsg(msg: _friendlyMessage(statusCode, error)); + } } if (response?.data is Map) { return ResultType.fromJson(response!.data as Map); @@ -244,8 +290,10 @@ class HttpUtil with AuthMixin implements StoreHubHttpClient { Future _ensureRefreshToken() async { try { await relationCtrl.refreshToken(); - } catch (e) { - log.warning('refreshToken 失败: $e'); + } catch (e, s) { + final msg = 'refreshToken 失败: $e\n$s'; + XLogUtil.e(msg); + SystemLog.err(msg, 'Token刷新'); } } @@ -289,8 +337,8 @@ class HttpUtil with AuthMixin implements StoreHubHttpClient { required Function progressCallback, }) async { final options = await addTokenHeader(null); - return _dio.download(url, savePath, - options: options, onReceiveProgress: (received, total) { + return _dio.download(url, savePath, options: options, + onReceiveProgress: (received, total) { progressCallback(received, total); }); } diff --git a/lib/dart/base/api/kernelapi.dart b/lib/dart/base/api/kernelapi.dart index 4b13c682f8d1782a96e589c5d61270a81f895349..501fd19ffcfaf8c7a9e907a7009577b1549b4f41 100644 --- a/lib/dart/base/api/kernelapi.dart +++ b/lib/dart/base/api/kernelapi.dart @@ -47,7 +47,7 @@ class KernelApi with AuthMixin { KernelApi._(String url, {StoreHub? storeHub, bool autoStart = true}) : _methods = {}, _subMethods = {}, - _storeHub = storeHub ?? StoreHub(url, protocol: 'json') { + _storeHub = storeHub ?? StoreHub('$url?requestOnly=0', protocol: 'json') { _bindStoreHub(autoStart: autoStart); } @@ -140,6 +140,13 @@ class KernelApi with AuthMixin { return await _storeHub.checkOnline(); } + /// 连接是否僵死(后台唤起后心跳停止但状态仍为 Connected) + bool get isStale => _storeHub.isStale; + + /// 检测并恢复 stale 连接(后台唤起场景) + /// 返回 true 表示发生了强制重启,调用方应等待连接恢复后再刷新数据 + Future reviveIfStale() => _storeHub.reviveIfStale(); + Future restart() async { await _storeHub.restart(); } @@ -262,10 +269,9 @@ class KernelApi with AuthMixin { exitLogin(false); return false; } else { - XLogUtil.e('连接到内核失败!'); - XLogUtil.e( - "请求时间:${DateTime.now().toDateString(format: 'yyyy-MM-dd hh:mm:ss.S')}"); - XLogUtil.w(jsonEncode(result)); + // 连接波动导致的 TokenAuth 失败使用 warning 级别,避免错误日志刷屏 + XLogUtil.w( + '连接到内核失败! time=${DateTime.now().toDateString(format: 'yyyy-MM-dd hh:mm:ss.S')}, result=${jsonEncode(result)}'); } } return false; @@ -932,6 +938,7 @@ class KernelApi with AuthMixin { .map((item) => XWorkTask.fromJson(item as Map)) .toList(); } catch (e) { + XLogUtil.e('XWorkTask.fromJson 解析失败: $e'); return []; } } @@ -1608,11 +1615,34 @@ class KernelApi with AuthMixin { if (req.ignoreSelf) { req.ignoreConnectionId = _storeHub.connectionId; } - ResultType res = await _storeHub.invoke('DataNotify', args: [req]); + // 预序列化为可 JSON 编码的 Map,避免 SignalR JsonHubProtocol + // 不自动调用 toJson 导致 "Converting object to an encodable object failed" + final payload = _deepEncode(req.toJson()); + ResultType res = await _storeHub.invoke('DataNotify', args: [payload]); return ResultType.fromObj( res, res.data == null ? false : res.data as bool); } + /// 递归将 Map/List 中的自定义对象通过 toJson 转为可 JSON 编码的值 + static dynamic _deepEncode(dynamic value) { + if (value == null || value is String || value is num || value is bool) { + return value; + } + if (value is Map) { + return value.map((k, v) => MapEntry(k, _deepEncode(v))); + } + if (value is List) { + return value.map(_deepEncode).toList(); + } + try { + final toJsonFn = value.toJson; + if (toJsonFn is Function) { + return _deepEncode(toJsonFn()); + } + } catch (_) {} + return value.toString(); + } + /// 请求一个内核方法 /// @param {ReqestType} reqs 请求体 /// @returns 异步结果 diff --git a/lib/dart/base/api/storehub.dart b/lib/dart/base/api/storehub.dart index 1ba7ae8d41ed229c725fd10874a0b7cae3ca6e7c..1ebb3fe8a47e1395b738cc622ac1c081e437e9d5 100644 --- a/lib/dart/base/api/storehub.dart +++ b/lib/dart/base/api/storehub.dart @@ -137,6 +137,7 @@ class StoreHub with AuthMixin, RequestStatisticsMixin { int _consecutiveHeartbeatFailures = 0; DateTime? _lastConnectedAt; DateTime? _lastHeartbeatAt; + DateTime? _lastReviveAt; DateTime? _enteredDegradedAt; String _lastTransportReason = ''; @@ -174,7 +175,18 @@ class StoreHub with AuthMixin, RequestStatisticsMixin { _connection = connection ?? SignalRStoreHubConnection( HubConnectionBuilder() - .withUrl(url, options: HttpConnectionOptions()) + .withUrl( + url, + options: HttpConnectionOptions( + // ⚠️ signalr_netcore 默认 requestTimeout=2000ms, + // 移动网络下 negotiate 几乎必超时,提升到 30s + requestTimeout: 30000, + // 注入 accessToken,原生 App 无 Cookie 机制 + accessTokenFactory: () async { + return Storage.getString('accessToken'); + }, + ), + ) .withAutomaticReconnect(retryDelays: [ 0, 2000, @@ -281,11 +293,76 @@ class StoreHub with AuthMixin, RequestStatisticsMixin { return '其他'; } + /// 心跳 stale 阈值:超过该时长未收到心跳响应,认为连接已僵死。 + /// 对齐 oiocns-react serverTimeout(10s) + keepAlive(2s) 的快速检测语义, + /// 但移动端考虑电量,心跳间隔 15s,因此 stale 阈值设为 45s(3 次心跳周期)。 + static const Duration _staleHeartbeatThreshold = Duration(seconds: 45); + + /// 判断当前连接是否僵死(stale) + /// + /// 满足以下任一条件即认为 stale: + /// 1. 连接状态为 Connected,但从未收到过心跳(_lastHeartbeatAt == null) + /// 且距离上次连接建立已超过 _staleHeartbeatThreshold + /// 2. 连接状态为 Connected,但距离上次心跳已超过 _staleHeartbeatThreshold + /// + /// 后台唤起场景:app 在后台时 Timer 被系统挂起,心跳停止, + /// 但 _connection.state 可能仍为 Connected(TCP 层未感知断开), + /// 此时需要主动检测 stale 并强制重启。 + bool get isStale { + if (!_isConnected) return false; + final now = DateTime.now(); + if (_lastHeartbeatAt == null) { + // 连接建立后从未心跳过:检查距离连接建立的时间 + if (_lastConnectedAt != null && + now.difference(_lastConnectedAt!) > _staleHeartbeatThreshold) { + return true; + } + return false; + } + return now.difference(_lastHeartbeatAt!) > _staleHeartbeatThreshold; + } + + /// 检测 stale 连接并强制重启(用于后台唤起等场景) + /// + /// 返回 true 表示发生了强制重启(调用方应等待连接恢复后再刷新数据), + /// false 表示连接正常或已在重连中。 + Future reviveIfStale() async { + if (_disposed) return false; + if (!isStale) return false; + // 退避:距离上次 stale 重启不到 30 秒时跳过,避免循环重启 + final now = DateTime.now(); + if (_lastReviveAt != null && + now.difference(_lastReviveAt!) < const Duration(seconds: 30)) { + return false; + } + _lastReviveAt = now; + XLogUtil.w( + 'StoreHub: 检测到 stale 连接(lastHeartbeat=${_lastHeartbeatAt?.toIso8601String()}),强制重启'); + // 强制重启:绕过状态机直接 stop + start + _stopHeartbeat(); + _cancelReconnectTimer(); + try { + if (_connection.state == HubConnectionState.Connected) { + await _connection.stop(); + } + } catch (e) { + XLogUtil.w('StoreHub: stale 重启 stop 异常: $e'); + } + // 重置状态,触发重连 + _reconnectAttempts = 0; + _consecutiveHeartbeatFailures = 0; + await _starting(reason: 'revive_stale'); + return true; + } + Future checkOnline() async { if (_disposed) { return false; } - if (_isConnected) { + // stale 检测:后台唤起后连接可能已僵死,先尝试 revive + if (isStale) { + await reviveIfStale(); + } else if (_isConnected) { return true; } if (isDegraded) { @@ -294,7 +371,8 @@ class StoreHub with AuthMixin, RequestStatisticsMixin { !_isStarting) { await _starting(reason: 'check_online'); } - return _waitForLongLink(const Duration(seconds: 2)); + // 等待长连接恢复:从 5s 提升到 8s,适应后台唤起后重连较慢的场景 + return _waitForLongLink(const Duration(seconds: 8)); } void _setTransportState({ @@ -339,6 +417,8 @@ class StoreHub with AuthMixin, RequestStatisticsMixin { _reconnectAttempts = 0; _consecutiveHeartbeatFailures = 0; _lastConnectedAt = DateTime.now(); + // 连接成功后重置 stale 退避,允许下次 stale 检测立即生效 + _lastReviveAt = null; // 每次新连接必须重新 TokenAuth,重置栅栏 _authGate = Completer(); _setTransportState( @@ -675,12 +755,30 @@ class StoreHub with AuthMixin, RequestStatisticsMixin { } if (usedLongLink) { - resObj = await _connection - .invoke(methodName, args: safeArgs) - .timeout(const Duration(seconds: 30), onTimeout: () { - throw TimeoutException('SignalR invoke 超时: $methodName', - const Duration(seconds: 30)); - }); + try { + // SignalR invoke 超时 30s:政务服务器响应较慢,20s 会导致频繁降级到 HTTP。 + // 30s 足够覆盖大部分慢请求,减少不必要的 HTTP 降级(HTTP receiveTimeout=60s 兜底)。 + resObj = await _connection + .invoke(methodName, args: safeArgs) + .timeout(const Duration(seconds: 30), onTimeout: () { + throw TimeoutException( + 'SignalR invoke 超时: $methodName', const Duration(seconds: 30)); + }); + } on TimeoutException catch (_) { + // SignalR 超时:降级到 HTTP fallback,避免数据完全不加载 + if (!allowHttpFallback) { + rethrow; + } + XLogUtil.w('SignalR invoke 超时,降级到 HTTP fallback: $methodName'); + requestMethod = 'http_timeout_fallback'; + final payload = safeArgs.isNotEmpty ? safeArgs.first : {}; + resObj = await _restRequest( + 'post', + '${Constant.rest}/${methodName.toLowerCase()}', + payload, + transportTag: requestMethod, + ); + } } else { requestMethod = isDegraded ? 'http_degraded' : 'http_fallback'; if (!allowHttpFallback) { @@ -719,7 +817,7 @@ class StoreHub with AuthMixin, RequestStatisticsMixin { 'args': safeArgs.isNotEmpty ? jsonEncode(safeArgs.first) : '{}' }); } catch (e) { - XLogUtil.e('请求统计记录失败: $e'); + XLogUtil.w('请求统计记录失败: $e'); } } } @@ -757,8 +855,10 @@ class StoreHub with AuthMixin, RequestStatisticsMixin { if (res.code == 401) { _errorNoAuthLogout(); } else if (res.msg.isNotEmpty && !res.msg.contains('不在线')) { - _logDeduped('$method|${res.code}|${res.msg}', - () => XLogUtil.w('StoreHub 请求失败: $method code=${res.code} msg=${res.msg}')); + _logDeduped( + '$method|${res.code}|${res.msg}', + () => XLogUtil.w( + 'StoreHub 请求失败: $method code=${res.code} msg=${res.msg}')); } } else if (resObj is ResultType) { res = resObj; @@ -766,13 +866,19 @@ class StoreHub with AuthMixin, RequestStatisticsMixin { if (err != null) { msg += err is String ? err : err.toString(); } - _logDeduped('$method|err|$msg', - () => XLogUtil.e('StoreHub 请求异常: $method err=$msg' + // 连接被关闭/调用取消等非关键异常使用 warning 级别,不写入错误日志页面 + final isConnError = msg.contains('connection being closed') || + msg.contains('Invocation canceled'); + _logDeduped( + '$method|err|$msg', + () => (isConnError + ? XLogUtil.w + : XLogUtil.e)('StoreHub 请求异常: $method err=$msg' '${s != null ? "\nstack=$s" : ""}')); res = getRequest(msg); } } catch (e, st) { - XLogUtil.e('StoreHub._error 内部异常: $e\n$st'); + XLogUtil.w('StoreHub._error 内部异常: $e\n$st'); } return res; } diff --git a/lib/dart/base/common/commands.dart b/lib/dart/base/common/commands.dart index 45379af3c91fc86b54970ce15485aa65fcd8217e..c7cb5edd69036f5c14b8af58311adc97d3ae7aa7 100644 --- a/lib/dart/base/common/commands.dart +++ b/lib/dart/base/common/commands.dart @@ -1,4 +1,5 @@ import 'package:uuid/uuid.dart'; +import 'package:orginone/utils/log/log_util.dart'; typedef CmdType = void Function(String type, String cmd, dynamic args); @@ -34,9 +35,9 @@ class Command { callbacks.remove(id); //XLogUtil.dd(">>>>>>command 注销 $id ${callbacks.length}"); } else if (id is List) { - for (var _id in id) { - callbacks.remove(_id); - //XLogUtil.dd(">>>>>>command 注销 $_id ${callbacks.length}"); + for (var idItem in id) { + callbacks.remove(idItem); + //XLogUtil.dd(">>>>>>command 注销 $idItem ${callbacks.length}"); } } } @@ -82,8 +83,7 @@ class Command { try { flagCallbacks[id]?.call(args); } catch (e) { - // ignore: avoid_print - print('emitterFlag callback error: $e'); + XLogUtil.e('emitterFlag callback error: $e'); } } } diff --git a/lib/dart/base/common/encryption.dart b/lib/dart/base/common/encryption.dart index 09c36dcf8dc71f696cdfa42add91776b9fe43b54..5fcf85e82714f6b0780feaedfd176a53cec9f62e 100644 --- a/lib/dart/base/common/encryption.dart +++ b/lib/dart/base/common/encryption.dart @@ -1,4 +1,5 @@ import 'package:encrypt/encrypt.dart'; +import 'package:orginone/utils/log/log_util.dart'; /// AES-CBC 加密(升级自 ECB,每次随机 IV) /// 返回格式:IV.base64:密文.base64 @@ -20,7 +21,8 @@ String decrypt(String password, String word) { final encrypter = Encrypter(AES(key, mode: AESMode.cbc)); return encrypter.decrypt64(parts[1], iv: iv); } else { - // 兼容旧版 ECB 格式 + // 兼容旧版 ECB 格式(安全弱点,仅用于解密历史数据) + XLogUtil.w('[Encryption] 检测到旧版 ECB 格式数据,建议迁移至 CBC'); final iv = IV.fromLength(16); final encrypter = Encrypter(AES(key, mode: AESMode.ecb)); return encrypter.decrypt64(word, iv: iv); diff --git a/lib/dart/base/common/systemError.dart b/lib/dart/base/common/systemError.dart index 374c3b5acd7cd0311f2fca24e579a3bef4eaabee..e0845ea0ec035eec30dde079fb1ab7233447039e 100644 --- a/lib/dart/base/common/systemError.dart +++ b/lib/dart/base/common/systemError.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:core'; @@ -10,30 +11,79 @@ import '../storages/storage.dart'; class LogInfo { late final String title; late final String content; - - LogInfo({this.title = '', this.content = ''}); - - //通过JSON构造 + /// 最近一次发生时间(毫秒),作为排序键 + late final int timestamp; + /// 首次发生时间(毫秒) + late final int firstTimestamp; + /// 重复发生次数(合并输出用) + late final int count; + + LogInfo({ + this.title = '', + this.content = '', + int? timestamp, + int? firstTimestamp, + this.count = 1, + }) : timestamp = timestamp ?? DateTime.now().millisecondsSinceEpoch, + firstTimestamp = firstTimestamp ?? timestamp ?? DateTime.now().millisecondsSinceEpoch; + + ///通过JSON构造 LogInfo.fromJson(Map json) { title = json["t"] ?? ''; content = json["errorText"] ?? ''; + final ts = json["ts"]; + if (ts is int && ts > 0) { + timestamp = ts; + } else { + // 旧数据兼容:尝试从 title 解析时间,失败则用 0(排在最后) + final parsed = DateUtil.getDateTime(title); + timestamp = parsed?.millisecondsSinceEpoch ?? 0; + } + firstTimestamp = json["fts"] as int? ?? timestamp; + count = json["cnt"] as int? ?? 1; } - //转成JSON + ///转成JSON Map toJson() { - Map json = {}; + final json = {}; json["t"] = title; json["errorText"] = content; + json["ts"] = timestamp; + json["fts"] = firstTimestamp; + json["cnt"] = count; return json; } + + /// 去重键:内容前 200 字符 + title + String get dedupeKey => '$title::${content.length > 200 ? content.substring(0, 200) : content}'; + + /// 格式化时间戳为可读字符串 + String get timeStr { + final dt = DateTime.fromMillisecondsSinceEpoch(timestamp); + return DateUtil.formatDate(dt, format: 'MM-dd HH:mm:ss'); + } + + /// 格式化首次时间 + String get firstTimeStr { + final dt = DateTime.fromMillisecondsSinceEpoch(firstTimestamp); + return DateUtil.formatDate(dt, format: 'MM-dd HH:mm:ss'); + } + + /// 显示用的摘要标题(含重复次数) + String get displayTitle => count > 1 ? '$title (×$count)' : title; } /// 系统日志 class SystemLog { // 单例 static SystemLog? _instance; - List _errors; - // void Function(FlutterErrorDetails)? _onError; + final List _errors; + // 内容去重索引:dedupeKey -> 在 _errors 中的索引 + final Map _dedupeIndex = {}; + // 异步落盘去重:避免高频错误每条都触发 Storage.setJson + Timer? _flushTimer; + // 容量上限 + static const int _maxCapacity = 200; late bool _inited; factory SystemLog() { @@ -42,10 +92,7 @@ class SystemLog { } SystemLog._() : _errors = [] { - _errors = []; _inited = false; - - // _onError = FlutterError.onError; //先将 onerror 保存起来 FlutterError.onError = (FlutterErrorDetails details) { try { err('${details.exceptionAsString()}\r\n${details.stack}'); @@ -56,12 +103,21 @@ class SystemLog { } void init() { - var json = Storage.getString('work_page_error'); + final json = Storage.getString('work_page_error'); if (json != '') { - _errors.addAll( - (jsonDecode(json) as List).map((e) => LogInfo.fromJson(e))); + try { + final list = (jsonDecode(json) as List) + .map((e) => LogInfo.fromJson(e as Map)) + .toList(); + _errors.addAll(list); + // 重建去重索引 + for (var i = 0; i < list.length; i++) { + _dedupeIndex[list[i].dedupeKey] = i; + } + } catch (e) { + XLogUtil.e('SystemLog.init 解析失败: $e'); + } } - _inited = true; } @@ -70,33 +126,103 @@ class SystemLog { format: "yyyy-MM-dd HH:mm:ss.SSS"); } + /// 错误列表:按时间戳倒序(最近在前) static List get errors { - return _instance?._errors.reversed.toList() ?? []; + final list = _instance?._errors.toList() ?? []; + list.sort((a, b) => b.timestamp.compareTo(a.timestamp)); + return list; } static void clear() { Storage.remove('work_page_error'); _instance?._errors.clear(); + _instance?._dedupeIndex.clear(); + _instance?._flushTimer?.cancel(); + _instance?._flushTimer = null; + } + + /// 登出清理:与 clear 等价,应在 exitLogin 流程调用 + static void clearForLogout() { + clear(); } static void err([dynamic content = '', String? title]) { - title ??= _instance?.getDefTitle() ?? ''; - if (content is! String) { - content = jsonDecode(content); + // 确保单例存在,避免 Global.init 之前调用丢失错误(G9 修复) + _instance ??= SystemLog._(); + _instance!._append(content, title); + } + + /// 追加错误日志,相同错误合并输出(累加 count,更新最近时间) + void _append(dynamic content, String? title) { + // 固定默认 title,避免每次为时间戳导致 dedupeKey 失效(G2 修复) + title ??= '系统错误'; + String contentStr; + if (content is String) { + contentStr = content; + } else { + try { + contentStr = jsonEncode(content); + } catch (_) { + contentStr = '$content'; + } + } + + final now = DateTime.now().millisecondsSinceEpoch; + final dedupeKey = '$title::${contentStr.length > 200 ? contentStr.substring(0, 200) : contentStr}'; + + // 合并相同错误:累加 count,更新最近时间,保留首次时间和原始内容 + final existingIdx = _dedupeIndex[dedupeKey]; + if (existingIdx != null && existingIdx < _errors.length) { + final existing = _errors[existingIdx]; + _errors[existingIdx] = LogInfo( + title: existing.title, + content: existing.content, + timestamp: now, + firstTimestamp: existing.firstTimestamp, + count: existing.count + 1, + ); + _scheduleFlush(); + return; } - _instance?._errors.add(LogInfo(title: title, content: content)); - _instance?._save(); + // 新错误 + final info = LogInfo( + title: title, + content: contentStr, + timestamp: now, + firstTimestamp: now, + count: 1, + ); + _errors.add(info); + _dedupeIndex[dedupeKey] = _errors.length - 1; + _evictIfNeeded(); + _scheduleFlush(); } - void _save() { - if (_errors.length > 100) { - _errors.removeAt(0); - } - List> tmpError = - _errors.map((e) => e.toJson()).toList(); - if (_inited) { - Storage.setJson('work_page_error', tmpError); + void _evictIfNeeded() { + while (_errors.length > _maxCapacity) { + final removed = _errors.removeAt(0); + _dedupeIndex.remove(removed.dedupeKey); + // 重建索引(因为 removeAt(0) 导致所有索引前移) + _dedupeIndex.clear(); + for (var i = 0; i < _errors.length; i++) { + _dedupeIndex[_errors[i].dedupeKey] = i; + } } } + + /// 异步批量落盘:500ms 内多次 err 只写一次 Storage + void _scheduleFlush() { + if (!_inited) return; + _flushTimer?.cancel(); + _flushTimer = Timer(const Duration(milliseconds: 500), () { + _flush(); + }); + } + + void _flush() { + if (!_inited) return; + final tmpError = _errors.map((e) => e.toJson()).toList(); + Storage.setJson('work_page_error', tmpError); + } } diff --git a/lib/dart/base/model.dart b/lib/dart/base/model.dart index 4d5fa83df62f1f6b35268a795f0ab4cf0acee3d9..21b046393cab3e39e742f8bfdb23ac1eab3a4511 100644 --- a/lib/dart/base/model.dart +++ b/lib/dart/base/model.dart @@ -348,12 +348,10 @@ class ResultType { success = json.success; } - ResultType.success(T? data) { - this.data = data; - code = 200; - msg = ""; - success = true; - } + ResultType.success(this.data) + : code = 200, + msg = "", + success = true; Map toJson() { return { diff --git a/lib/dart/base/oip/hub_connection_manager.dart b/lib/dart/base/oip/hub_connection_manager.dart index 96cc244f14a541fd269f67013e29fda1f53b9614..200f4c170eb3d313142f82a04fe6743539b38834 100644 --- a/lib/dart/base/oip/hub_connection_manager.dart +++ b/lib/dart/base/oip/hub_connection_manager.dart @@ -27,6 +27,10 @@ class HubConnectionManager { static const int _maxReconnectAttempts = 5; static const Duration _heartbeatInterval = Duration(seconds: 15); + /// 离线消息队列(断线期间消息排队,重连后补发) + static final List<_PendingMessage> _pendingQueue = []; + static const int _maxPendingQueueSize = 200; + /// 状态变更流 static final StreamController _stateController = StreamController.broadcast(); @@ -82,6 +86,7 @@ class HubConnectionManager { _setState(HubConnectionState.connected); _reconnectAttempts = 0; _startHeartbeat(); + _flushPendingQueue(); return ResultType.success(_connectionId ?? ''); } catch (e) { @@ -183,6 +188,32 @@ class HubConnectionManager { _stateController.add(newState); } + /// 将消息加入离线队列(断线期间调用,重连后自动补发) + static void enqueueMessage(String method, dynamic args) { + if (_pendingQueue.length >= _maxPendingQueueSize) { + _pendingQueue.removeAt(0); + } + _pendingQueue.add(_PendingMessage(method: method, args: args)); + } + + /// 重连成功后补发排队消息 + static Future _flushPendingQueue() async { + if (_pendingQueue.isEmpty) return; + final queue = List<_PendingMessage>.from(_pendingQueue); + _pendingQueue.clear(); + for (final msg in queue) { + try { + await invoke(msg.method, args: msg.args); + } catch (e) { + // 补发失败,重新入队等待下次重连 + if (_pendingQueue.length < _maxPendingQueueSize) { + _pendingQueue.add(msg); + } + break; + } + } + } + /// 是否支持指定 Hub 方法(基于 bootstrap 协商) static bool supportsMethod(String method, BootstrapResponse? bootstrap) { if (bootstrap == null) return true; // 未协商时默认支持 @@ -199,6 +230,19 @@ enum HubConnectionState { error, } +/// 离线消息队列项 +class _PendingMessage { + final String method; + final dynamic args; + final DateTime enqueuedAt; + + _PendingMessage({ + required this.method, + required this.args, + DateTime? enqueuedAt, + }) : enqueuedAt = enqueuedAt ?? DateTime.now(); +} + /// Hub 消息 class HubMessage { final String target; // 消息目标(DataNotify / QrAuth / Online 等) diff --git a/lib/dart/base/oip/push_notification_service.dart b/lib/dart/base/oip/push_notification_service.dart index 8632cf6aa54bf4ecc907af9588c3e440a2d5022f..73d3f78e6e6e64a0b9394bf0db32b9826503b482 100644 --- a/lib/dart/base/oip/push_notification_service.dart +++ b/lib/dart/base/oip/push_notification_service.dart @@ -61,13 +61,7 @@ class PushNotificationService { _foregroundController.stream; /// 应用前后台状态 - bool _isForeground = true; - - bool get isForeground => _isForeground; - - set isForeground(bool value) { - _isForeground = value; - } + bool isForeground = true; /// 当前通知偏好 Map get preferences => @@ -141,7 +135,7 @@ class PushNotificationService { } // 4. 前台时展示通知(后台由系统通知栏处理) - if (_isForeground) { + if (isForeground) { final notification = OipNotification( event: event, vetEventType: vetEventType, diff --git a/lib/dart/base/storages/business_database.dart b/lib/dart/base/storages/business_database.dart new file mode 100644 index 0000000000000000000000000000000000000000..915d9e4f345e4232251860ede3a7459eb0c46f5f --- /dev/null +++ b/lib/dart/base/storages/business_database.dart @@ -0,0 +1,747 @@ +import 'dart:isolate'; + +import 'package:path/path.dart' as p; +import 'package:sqflite/sqflite.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:uuid/uuid.dart'; + +import 'package:orginone/dart/base/common/encryption.dart'; +import 'package:orginone/dart/base/common/systemError.dart'; +import 'package:orginone/utils/log/log_util.dart'; + +/// 业务数据 SQLite 全量持久化(AGENTS.md §6.2) +/// +/// 覆盖:sessions / messages / activities / work_tasks / relations / storages / agents +/// 写入策略:upsert(INSERT OR REPLACE),批量写入用事务 +/// 登出清空:clearAll() +class BusinessDatabase { + BusinessDatabase._(); + static final BusinessDatabase _instance = BusinessDatabase._(); + static BusinessDatabase get instance => _instance; + + Database? _db; + static const int _schemaVersion = 2; + static const String _dbName = 'oiocns_business.db'; + + bool get isInitialized => _db != null && _db!.isOpen; + + /// 获取数据库实例 + /// + /// P0 修复:初始化失败时抛出明确异常,调用方必须 catch 并降级至在线 + /// 不要返回 null(Dart 类型签名不允许),让调用方显式处理失败 + Future get database async { + if (_db != null && _db!.isOpen) return _db!; + await _init(); + if (_db == null || !_db!.isOpen) { + throw StateError('[BusinessDatabase] 数据库未初始化,需降级至在线获取'); + } + return _db!; + } + + Future _init() async { + try { + final dir = await getApplicationDocumentsDirectory(); + final path = p.join(dir.path, _dbName); + _db = await openDatabase( + path, + version: _schemaVersion, + onCreate: _onCreate, + onUpgrade: _onUpgrade, + ); + XLogUtil.i( + '[CacheTracker] BusinessDatabase 初始化成功 thread=${Isolate.current.debugName} path=$path'); + } catch (e, s) { + // P0 修复:初始化失败必须记录完整堆栈,并标记降级状态 + // 业务层读取时会检测 isInitialized,走在线降级路径 + XLogUtil.e('[BusinessDatabase] 初始化失败(降级至在线模式): $e\n$s'); + _db = null; + SystemLog.err('SQLite初始化失败: $e', 'SQLite读取'); + } + } + + Future _onCreate(Database db, int version) async { + final batch = db.batch(); + + // 会话表 + batch.execute(''' + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + belong_id TEXT NOT NULL, + space_id TEXT, + type_name TEXT, + name TEXT, + avatar TEXT, + last_msg_time INTEGER DEFAULT 0, + last_message TEXT, + no_read_count INTEGER DEFAULT 0, + update_time INTEGER DEFAULT 0 + ) + '''); + batch.execute( + 'CREATE INDEX IF NOT EXISTS idx_sessions_belong_id ON sessions(belong_id)'); + batch.execute( + 'CREATE INDEX IF NOT EXISTS idx_sessions_space_id ON sessions(space_id)'); + batch.execute( + 'CREATE INDEX IF NOT EXISTS idx_sessions_last_msg_time ON sessions(last_msg_time)'); + + // 消息表 + batch.execute(''' + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + belong_id TEXT, + msg_type TEXT, + content TEXT, + from_id TEXT, + from_name TEXT, + create_time INTEGER DEFAULT 0, + update_time INTEGER DEFAULT 0 + ) + '''); + batch.execute( + 'CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id)'); + batch.execute( + 'CREATE INDEX IF NOT EXISTS idx_messages_create_time ON messages(create_time)'); + + // 动态表 + batch.execute(''' + CREATE TABLE IF NOT EXISTS activities ( + id TEXT PRIMARY KEY, + belong_id TEXT NOT NULL, + space_id TEXT, + type_name TEXT, + content TEXT, + from_id TEXT, + from_name TEXT, + create_time INTEGER DEFAULT 0, + update_time INTEGER DEFAULT 0 + ) + '''); + batch.execute( + 'CREATE INDEX IF NOT EXISTS idx_activities_belong_id ON activities(belong_id)'); + batch.execute( + 'CREATE INDEX IF NOT EXISTS idx_activities_space_id ON activities(space_id)'); + batch.execute( + 'CREATE INDEX IF NOT EXISTS idx_activities_update_time ON activities(update_time)'); + + // 办事任务表 + batch.execute(''' + CREATE TABLE IF NOT EXISTS work_tasks ( + id TEXT PRIMARY KEY, + belong_id TEXT NOT NULL, + space_id TEXT, + task_type TEXT, + title TEXT, + content TEXT, + status TEXT, + create_time INTEGER DEFAULT 0, + update_time INTEGER DEFAULT 0 + ) + '''); + batch.execute( + 'CREATE INDEX IF NOT EXISTS idx_work_tasks_belong_id ON work_tasks(belong_id)'); + batch.execute( + 'CREATE INDEX IF NOT EXISTS idx_work_tasks_task_type ON work_tasks(task_type)'); + batch.execute( + 'CREATE INDEX IF NOT EXISTS idx_work_tasks_update_time ON work_tasks(update_time)'); + + // 关系树表 + batch.execute(''' + CREATE TABLE IF NOT EXISTS relations ( + id TEXT PRIMARY KEY, + belong_id TEXT NOT NULL, + space_id TEXT, + type_name TEXT, + name TEXT, + avatar TEXT, + parent_id TEXT, + data_json TEXT, + update_time INTEGER DEFAULT 0 + ) + '''); + batch.execute( + 'CREATE INDEX IF NOT EXISTS idx_relations_belong_id ON relations(belong_id)'); + batch.execute( + 'CREATE INDEX IF NOT EXISTS idx_relations_space_id ON relations(space_id)'); + batch.execute( + 'CREATE INDEX IF NOT EXISTS idx_relations_parent_id ON relations(parent_id)'); + + // 存储资源表 + batch.execute(''' + CREATE TABLE IF NOT EXISTS storages ( + id TEXT PRIMARY KEY, + belong_id TEXT NOT NULL, + space_id TEXT, + name TEXT, + type_name TEXT, + data_json TEXT, + update_time INTEGER DEFAULT 0 + ) + '''); + batch.execute( + 'CREATE INDEX IF NOT EXISTS idx_storages_belong_id ON storages(belong_id)'); + batch.execute( + 'CREATE INDEX IF NOT EXISTS idx_storages_space_id ON storages(space_id)'); + + // 智能体表 + batch.execute(''' + CREATE TABLE IF NOT EXISTS agents ( + id TEXT PRIMARY KEY, + belong_id TEXT NOT NULL, + space_id TEXT, + name TEXT, + type_name TEXT, + data_json TEXT, + update_time INTEGER DEFAULT 0 + ) + '''); + batch.execute( + 'CREATE INDEX IF NOT EXISTS idx_agents_belong_id ON agents(belong_id)'); + batch.execute( + 'CREATE INDEX IF NOT EXISTS idx_agents_space_id ON agents(space_id)'); + + _createFlowTables(batch); + + await batch.commit(noResult: true); + } + + /// 流程相关三级缓存表(applications / forms / work_nodes) + /// + /// 设计原则: + /// - 元数据入 SQLite(小-中型 JSON,索引友好) + /// - 节点定义等大文档存文件系统,本表只存文件路径引用 + hash + /// - content_hash 用于变更检测,避免无变化写盘 + void _createFlowTables(Batch batch) { + // 应用元数据表 + batch.execute(''' + CREATE TABLE IF NOT EXISTS applications ( + id TEXT PRIMARY KEY, + belong_id TEXT NOT NULL, + space_id TEXT, + name TEXT, + type_name TEXT, + data_json TEXT, + content_hash TEXT, + update_time INTEGER DEFAULT 0 + ) + '''); + batch.execute( + 'CREATE INDEX IF NOT EXISTS idx_applications_belong_id ON applications(belong_id)'); + batch.execute( + 'CREATE INDEX IF NOT EXISTS idx_applications_space_id ON applications(space_id)'); + + // 表单/视图元数据表(含字段定义 fields_json) + batch.execute(''' + CREATE TABLE IF NOT EXISTS forms ( + id TEXT PRIMARY KEY, + belong_id TEXT NOT NULL, + space_id TEXT, + application_id TEXT, + name TEXT, + type_name TEXT, + data_json TEXT, + fields_json TEXT, + content_hash TEXT, + update_time INTEGER DEFAULT 0 + ) + '''); + batch.execute( + 'CREATE INDEX IF NOT EXISTS idx_forms_belong_id ON forms(belong_id)'); + batch.execute( + 'CREATE INDEX IF NOT EXISTS idx_forms_space_id ON forms(space_id)'); + batch.execute( + 'CREATE INDEX IF NOT EXISTS idx_forms_application_id ON forms(application_id)'); + + // 办事节点定义索引表(节点 JSON 存文件系统) + batch.execute(''' + CREATE TABLE IF NOT EXISTS work_nodes ( + id TEXT PRIMARY KEY, + belong_id TEXT NOT NULL, + space_id TEXT, + work_id TEXT NOT NULL, + file_path TEXT, + content_hash TEXT, + update_time INTEGER DEFAULT 0 + ) + '''); + batch.execute( + 'CREATE INDEX IF NOT EXISTS idx_work_nodes_belong_id ON work_nodes(belong_id)'); + batch.execute( + 'CREATE INDEX IF NOT EXISTS idx_work_nodes_space_id ON work_nodes(space_id)'); + batch.execute( + 'CREATE INDEX IF NOT EXISTS idx_work_nodes_work_id ON work_nodes(work_id)'); + } + + Future _onUpgrade(Database db, int oldVersion, int newVersion) async { + // P0 修复:增量迁移必须包裹 try-catch,失败时降级重建表结构 + // 禁止 DROP 全表(AGENTS.md §6.3),但允许 CREATE TABLE IF NOT EXISTS 兜底 + final sw = Stopwatch()..start(); + try { + XLogUtil.i( + '[CacheTracker] _onUpgrade 开始 oldVersion=$oldVersion newVersion=$newVersion thread=${Isolate.current.debugName}'); + if (oldVersion < 2) { + final batch = db.batch(); + _createFlowTables(batch); + await batch.commit(noResult: true); + } + XLogUtil.i('[CacheTracker] _onUpgrade 成功 耗时=${sw.elapsedMilliseconds}ms'); + } catch (e, s) { + // 迁移失败兜底:尝试逐表 CREATE TABLE IF NOT EXISTS(幂等,不会丢数据) + // 若仍失败,记录错误日志,业务层走在线降级 + XLogUtil.e('[BusinessDatabase] _onUpgrade 迁移失败,尝试兜底重建: $e\n$s'); + SystemLog.err('SQLite迁移失败: $e', 'SQLite读取'); + try { + final batch = db.batch(); + _createFlowTables(batch); + await batch.commit(noResult: true); + XLogUtil.i( + '[CacheTracker] _onUpgrade 兜底重建成功 耗时=${sw.elapsedMilliseconds}ms'); + } catch (e2, s2) { + // 兜底也失败:记录严重错误,但不抛出异常,避免 App 启动崩溃 + // 后续读写操作会因表不存在而抛异常,由各 provider 的 catch 块降级至在线 + XLogUtil.e('[BusinessDatabase] _onUpgrade 兜底重建也失败: $e2\n$s2'); + SystemLog.err('SQLite迁移兜底失败: $e2', 'SQLite读取'); + } + } finally { + sw.stop(); + } + } + + // ============ Sessions ============ + + Future upsertSession(Map row) async { + final db = await database; + await db.insert('sessions', row, + conflictAlgorithm: ConflictAlgorithm.replace); + } + + Future upsertSessions(List> rows) async { + if (rows.isEmpty) return; + await _getEncKey(); + final db = await database; + final batch = db.batch(); + for (final row in rows) { + final encRow = Map.from(row); + if (encRow['last_message'] is String) { + encRow['last_message'] = _encryptField(encRow['last_message']); + } + batch.insert('sessions', encRow, + conflictAlgorithm: ConflictAlgorithm.replace); + } + await batch.commit(noResult: true); + } + + Future>> querySessions({ + String? belongId, + String? spaceId, + int limit = 200, + }) async { + await _getEncKey(); + final db = await database; + final where = []; + final args = []; + if (belongId != null) { + where.add('belong_id = ?'); + args.add(belongId); + } + if (spaceId != null) { + where.add('space_id = ?'); + args.add(spaceId); + } + final results = await db.query('sessions', + where: where.isNotEmpty ? where.join(' AND ') : null, + whereArgs: args.isNotEmpty ? args : null, + orderBy: 'last_msg_time DESC', + limit: limit); + return results.map((row) { + final decRow = Map.from(row); + if (decRow['last_message'] is String) { + decRow['last_message'] = _decryptField(decRow['last_message']); + } + return decRow; + }).toList(); + } + + // ============ Messages ============ + + Future upsertMessages(List> rows) async { + if (rows.isEmpty) return; + await _getEncKey(); + final db = await database; + final batch = db.batch(); + for (final row in rows) { + final encRow = Map.from(row); + if (encRow['content'] is String) { + encRow['content'] = _encryptField(encRow['content']); + } + batch.insert('messages', encRow, + conflictAlgorithm: ConflictAlgorithm.replace); + } + await batch.commit(noResult: true); + } + + Future>> queryMessages( + String sessionId, int limit) async { + await _getEncKey(); + final db = await database; + final results = await db.query('messages', + where: 'session_id = ?', + whereArgs: [sessionId], + orderBy: 'create_time DESC', + limit: limit); + return results.map((row) { + final decRow = Map.from(row); + if (decRow['content'] is String) { + decRow['content'] = _decryptField(decRow['content']); + } + return decRow; + }).toList(); + } + + // ============ Activities ============ + + Future upsertActivities(List> rows) async { + if (rows.isEmpty) return; + final db = await database; + final batch = db.batch(); + for (final row in rows) { + batch.insert('activities', row, + conflictAlgorithm: ConflictAlgorithm.replace); + } + await batch.commit(noResult: true); + } + + Future>> queryActivities({ + String? belongId, + String? spaceId, + int limit = 200, + }) async { + final db = await database; + final where = []; + final args = []; + if (belongId != null) { + where.add('belong_id = ?'); + args.add(belongId); + } + if (spaceId != null) { + where.add('space_id = ?'); + args.add(spaceId); + } + return db.query('activities', + where: where.isNotEmpty ? where.join(' AND ') : null, + whereArgs: args.isNotEmpty ? args : null, + orderBy: 'update_time DESC', + limit: limit); + } + + // ============ Work Tasks ============ + + Future upsertWorkTasks(List> rows) async { + if (rows.isEmpty) return; + final db = await database; + final batch = db.batch(); + for (final row in rows) { + batch.insert('work_tasks', row, + conflictAlgorithm: ConflictAlgorithm.replace); + } + await batch.commit(noResult: true); + } + + Future>> queryWorkTasks({ + String? belongId, + String? taskType, + int limit = 200, + }) async { + final db = await database; + final where = []; + final args = []; + if (belongId != null) { + where.add('belong_id = ?'); + args.add(belongId); + } + if (taskType != null) { + where.add('task_type = ?'); + args.add(taskType); + } + return db.query('work_tasks', + where: where.isNotEmpty ? where.join(' AND ') : null, + whereArgs: args.isNotEmpty ? args : null, + orderBy: 'update_time DESC', + limit: limit); + } + + // ============ Relations ============ + + Future upsertRelations(List> rows) async { + if (rows.isEmpty) return; + final db = await database; + final batch = db.batch(); + for (final row in rows) { + batch.insert('relations', row, + conflictAlgorithm: ConflictAlgorithm.replace); + } + await batch.commit(noResult: true); + } + + Future>> queryRelations({ + String? belongId, + String? spaceId, + String? parentId, + String? typeName, + }) async { + final db = await database; + final where = []; + final args = []; + if (belongId != null) { + where.add('belong_id = ?'); + args.add(belongId); + } + if (spaceId != null) { + where.add('space_id = ?'); + args.add(spaceId); + } + if (parentId != null) { + where.add('parent_id = ?'); + args.add(parentId); + } + if (typeName != null) { + where.add('type_name = ?'); + args.add(typeName); + } + return db.query('relations', + where: where.isNotEmpty ? where.join(' AND ') : null, + whereArgs: args.isNotEmpty ? args : null, + orderBy: 'update_time DESC'); + } + + // ============ Storages ============ + + Future upsertStorages(List> rows) async { + if (rows.isEmpty) return; + final db = await database; + final batch = db.batch(); + for (final row in rows) { + batch.insert('storages', row, + conflictAlgorithm: ConflictAlgorithm.replace); + } + await batch.commit(noResult: true); + } + + Future>> queryStorages({ + String? belongId, + String? spaceId, + }) async { + final db = await database; + final where = []; + final args = []; + if (belongId != null) { + where.add('belong_id = ?'); + args.add(belongId); + } + if (spaceId != null) { + where.add('space_id = ?'); + args.add(spaceId); + } + return db.query('storages', + where: where.isNotEmpty ? where.join(' AND ') : null, + whereArgs: args.isNotEmpty ? args : null, + orderBy: 'update_time DESC'); + } + + // ============ Agents ============ + + Future upsertAgents(List> rows) async { + if (rows.isEmpty) return; + final db = await database; + final batch = db.batch(); + for (final row in rows) { + batch.insert('agents', row, conflictAlgorithm: ConflictAlgorithm.replace); + } + await batch.commit(noResult: true); + } + + Future>> queryAgents({ + String? belongId, + String? spaceId, + }) async { + final db = await database; + final where = []; + final args = []; + if (belongId != null) { + where.add('belong_id = ?'); + args.add(belongId); + } + if (spaceId != null) { + where.add('space_id = ?'); + args.add(spaceId); + } + return db.query('agents', + where: where.isNotEmpty ? where.join(' AND ') : null, + whereArgs: args.isNotEmpty ? args : null, + orderBy: 'update_time DESC'); + } + + // ============ Applications(流程三级缓存 L1) ============ + + Future upsertApplications(List> rows) async { + if (rows.isEmpty) return; + final db = await database; + final batch = db.batch(); + for (final row in rows) { + batch.insert('applications', row, + conflictAlgorithm: ConflictAlgorithm.replace); + } + await batch.commit(noResult: true); + } + + Future>> queryApplications({ + String? belongId, + String? spaceId, + }) async { + final db = await database; + final where = []; + final args = []; + if (belongId != null) { + where.add('belong_id = ?'); + args.add(belongId); + } + if (spaceId != null) { + where.add('space_id = ?'); + args.add(spaceId); + } + return db.query('applications', + where: where.isNotEmpty ? where.join(' AND ') : null, + whereArgs: args.isNotEmpty ? args : null, + orderBy: 'update_time DESC'); + } + + // ============ Forms(流程三级缓存 L1) ============ + + Future upsertForms(List> rows) async { + if (rows.isEmpty) return; + final db = await database; + final batch = db.batch(); + for (final row in rows) { + batch.insert('forms', row, conflictAlgorithm: ConflictAlgorithm.replace); + } + await batch.commit(noResult: true); + } + + Future>> queryForms({ + String? belongId, + String? spaceId, + String? applicationId, + }) async { + final db = await database; + final where = []; + final args = []; + if (belongId != null) { + where.add('belong_id = ?'); + args.add(belongId); + } + if (spaceId != null) { + where.add('space_id = ?'); + args.add(spaceId); + } + if (applicationId != null) { + where.add('application_id = ?'); + args.add(applicationId); + } + return db.query('forms', + where: where.isNotEmpty ? where.join(' AND ') : null, + whereArgs: args.isNotEmpty ? args : null, + orderBy: 'update_time DESC'); + } + + // ============ Work Nodes(流程三级缓存 L1 索引) ============ + + Future upsertWorkNode(Map row) async { + final db = await database; + await db.insert('work_nodes', row, + conflictAlgorithm: ConflictAlgorithm.replace); + } + + Future?> queryWorkNode(String workId) async { + final db = await database; + final results = await db.query('work_nodes', + where: 'work_id = ?', whereArgs: [workId], limit: 1); + return results.isEmpty ? null : results.first; + } + + Future deleteWorkNodesByUser(String userId) async { + final db = await database; + return db.delete('work_nodes', where: 'belong_id = ?', whereArgs: [userId]); + } + + // ============ 敏感字段加密 ============ + + static String? _encKey; + static const String _encKeyPref = '_bd_enc_key'; + + /// 获取或生成设备级加密密钥(AES-256) + Future _getEncKey() async { + if (_encKey != null) return _encKey!; + final prefs = await SharedPreferences.getInstance(); + _encKey = prefs.getString(_encKeyPref); + if (_encKey == null || _encKey!.length < 32) { + _encKey = const Uuid().v4().replaceAll('-', '').substring(0, 32); + await prefs.setString(_encKeyPref, _encKey!); + } + return _encKey!; + } + + /// 加密敏感字段(null/空值不加密) + String? _encryptField(String? value) { + if (value == null || value.isEmpty) return value; + try { + return encrypt(_encKey!, value); + } catch (e) { + XLogUtil.w('[BusinessDatabase] 字段加密失败: $e'); + return value; + } + } + + /// 解密敏感字段(兼容明文:无 ':' 分隔符视为明文直接返回) + String? _decryptField(String? value) { + if (value == null || value.isEmpty || _encKey == null) return value; + if (!value.contains(':')) return value; // 明文兼容 + try { + return decrypt(_encKey!, value); + } catch (e) { + XLogUtil.w('[BusinessDatabase] 字段解密失败: $e'); + return value; + } + } + + // ============ 通用清理 ============ + + /// 登出清空所有业务表(AGENTS.md §6.2 硬约束) + Future clearAll() async { + if (!isInitialized) return; + try { + final db = _db!; + await db.transaction((txn) async { + await txn.delete('sessions'); + await txn.delete('messages'); + await txn.delete('activities'); + await txn.delete('work_tasks'); + await txn.delete('relations'); + await txn.delete('storages'); + await txn.delete('agents'); + await txn.delete('applications'); + await txn.delete('forms'); + await txn.delete('work_nodes'); + }); + XLogUtil.i('[BusinessDatabase] clearAll 完成'); + } catch (e) { + XLogUtil.e('[BusinessDatabase] clearAll 失败: $e'); + } + } + + /// 关闭数据库 + Future close() async { + await _db?.close(); + _db = null; + } +} diff --git a/lib/dart/base/storages/file_system_cache_repository.dart b/lib/dart/base/storages/file_system_cache_repository.dart new file mode 100644 index 0000000000000000000000000000000000000000..a8e06d73b8fb4aeed0c54e3b46eb0f641fffa3f8 --- /dev/null +++ b/lib/dart/base/storages/file_system_cache_repository.dart @@ -0,0 +1,281 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import 'package:orginone/utils/log/log_util.dart'; + +/// 三级缓存之 L2 文件系统层(AGENTS.md §6.1 L2) +/// +/// 用于存放流程相关的大文档 JSON(WorkNode 节点定义、SpaceContent 快照等)。 +/// SQLite 单字段存大 BLOB 性能差,文件系统按 `{userId}/{spaceId}/{dataType}/{id}.json` +/// 组织,读取只需一次 IO,且支持流式解析。 +/// +/// 设计原则: +/// - 写入前 hash 比对,无变化不写盘(减少 IO) +/// - 写入异步 fire-and-forget,不阻塞 UI +/// - 路径按用户/空间隔离,登出/切空间按前缀清理 +class FileSystemCacheRepository { + FileSystemCacheRepository._(); + static final FileSystemCacheRepository _instance = + FileSystemCacheRepository._(); + static FileSystemCacheRepository get instance => _instance; + + static const String _rootDirName = 'flow_cache'; + + Directory? _rootDir; + bool _initializing = false; + + /// 内存中的内容 hash 索引,避免重复读盘比对 + /// key: '{userId}/{spaceId}/{dataType}/{id}',value: contentHash + final Map _hashIndex = {}; + + Future _ensureRoot() async { + if (_rootDir != null) return _rootDir!; + while (_initializing) { + await Future.delayed(const Duration(milliseconds: 5)); + } + _initializing = true; + try { + final appDir = await getApplicationDocumentsDirectory(); + final dir = Directory(p.join(appDir.path, _rootDirName)); + if (!dir.existsSync()) { + dir.createSync(recursive: true); + } + _rootDir = dir; + return dir; + } catch (e) { + XLogUtil.e('[FileSystemCache] 初始化根目录失败: $e'); + rethrow; + } finally { + _initializing = false; + } + } + + /// 构造文件路径:{root}/{userId}/{spaceId}/{dataType}/{id}.json + Future _buildFile( + String userId, + String spaceId, + String dataType, + String id, + ) async { + final root = await _ensureRoot(); + final dirPath = p.join(root.path, userId, spaceId, dataType); + final dir = Directory(dirPath); + if (!dir.existsSync()) { + dir.createSync(recursive: true); + } + // id 可能含特殊字符,做简单转义 + final safeId = id.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_'); + return File(p.join(dirPath, '$safeId.json')); + } + + String _indexKey(String userId, String spaceId, String dataType, String id) => + '$userId/$spaceId/$dataType/$id'; + + /// 计算 JSON 内容 hash(用于变更检测) + /// + /// P1 修复:原实现 catch块返回时间戳作为hash,导致每次都认为数据变化,频繁写盘 + /// 改为:序列化失败时返回固定标记 'serde_error',避免无意义写盘 + String _computeHash(dynamic data) { + try { + final encoded = jsonEncode(data); + final hash = sha1.convert(utf8.encode(encoded)).toString(); + // CacheTracker 调试日志:记录 hash 计算结果和数据大小 + XLogUtil.d( + '[CacheTracker] _computeHash size=${encoded.length}B hash=$hash'); + return hash; + } catch (e) { + // 序列化失败:返回固定标记,避免每次都写盘 + // 同时记录日志,便于定位数据模型问题 + XLogUtil.w('[CacheTracker] _computeHash 序列化失败,使用固定标记: $e'); + return 'serde_error_${DateTime.now().millisecondsSinceEpoch ~/ 60000}'; + } + } + + /// 读取缓存数据 + /// + /// 返回反序列化后的 Map;不存在或读取失败返回 null。 + Future?> read( + String userId, + String spaceId, + String dataType, + String id, + ) async { + final sw = Stopwatch()..start(); + try { + final file = await _buildFile(userId, spaceId, dataType, id); + if (!file.existsSync()) return null; + final content = await file.readAsString(); + if (content.isEmpty) return null; + final data = jsonDecode(content); + if (data is Map) { + _hashIndex[_indexKey(userId, spaceId, dataType, id)] = + data['__hash'] as String? ?? ''; + XLogUtil.d( + '[CacheTracker] read 命中 $dataType/$id size=${content.length}B 耗时=${sw.elapsedMilliseconds}ms'); + return data; + } + return null; + } catch (e) { + XLogUtil.w('[CacheTracker] read 失败 $dataType/$id: $e'); + return null; + } finally { + sw.stop(); + } + } + + /// 写入缓存数据(hash 比对,无变化不写盘) + /// + /// 返回是否实际写盘。 + Future write( + String userId, + String spaceId, + String dataType, + String id, + Map data, + ) async { + final sw = Stopwatch()..start(); + try { + final hash = _computeHash(data); + final key = _indexKey(userId, spaceId, dataType, id); + if (_hashIndex[key] == hash) { + XLogUtil.d('[CacheTracker] write 跳过(未变化) $dataType/$id'); + return false; + } + final file = await _buildFile(userId, spaceId, dataType, id); + final payload = Map.from(data); + payload['__hash'] = hash; + payload['__updateTime'] = DateTime.now().millisecondsSinceEpoch; + final encoded = jsonEncode(payload); + await file.writeAsString(encoded, flush: true); + _hashIndex[key] = hash; + XLogUtil.i( + '[CacheTracker] write 成功 $dataType/$id size=${encoded.length}B 耗时=${sw.elapsedMilliseconds}ms'); + return true; + } catch (e) { + XLogUtil.w('[CacheTracker] write 失败 $dataType/$id: $e'); + return false; + } finally { + sw.stop(); + } + } + + /// 异步写入(fire-and-forget,不阻塞调用方) + void writeAsync( + String userId, + String spaceId, + String dataType, + String id, + Map data, + ) { + Future(() async { + await write(userId, spaceId, dataType, id, data); + }); + } + + /// 读取某用户某空间下某类型的全部缓存(按 id 索引) + Future>> readAll( + String userId, + String spaceId, + String dataType, + ) async { + final result = >{}; + final sw = Stopwatch()..start(); + int fileCount = 0; + int corruptCount = 0; + try { + final root = await _ensureRoot(); + final dirPath = p.join(root.path, userId, spaceId, dataType); + final dir = Directory(dirPath); + if (!dir.existsSync()) return result; + await for (final entity in dir.list()) { + if (entity is! File) continue; + fileCount++; + try { + final content = await entity.readAsString(); + if (content.isEmpty) continue; + final data = jsonDecode(content); + if (data is Map) { + final id = p.basenameWithoutExtension(entity.path); + result[id] = data; + } + } catch (e) { + // P1 修复:单文件损坏必须记录日志,便于定位数据损坏问题 + corruptCount++; + XLogUtil.w( + '[CacheTracker] readAll 单文件损坏跳过 $dataType/${p.basename(entity.path)}: $e'); + } + } + XLogUtil.i( + '[CacheTracker] readAll 完成 dataType=$dataType files=$fileCount ok=${result.length} corrupt=$corruptCount 耗时=${sw.elapsedMilliseconds}ms'); + } catch (e) { + XLogUtil.w('[FileSystemCache] readAll 失败 $dataType: $e'); + } finally { + sw.stop(); + } + return result; + } + + /// 删除单条缓存 + Future remove( + String userId, + String spaceId, + String dataType, + String id, + ) async { + try { + final file = await _buildFile(userId, spaceId, dataType, id); + if (file.existsSync()) await file.delete(); + _hashIndex.remove(_indexKey(userId, spaceId, dataType, id)); + } catch (e) { + XLogUtil.w('[FileSystemCache] 删除失败 $dataType/$id: $e'); + } + } + + /// 清空某用户的所有缓存(登出时调用) + Future clearByUser(String userId) async { + try { + final root = await _ensureRoot(); + final userDir = Directory(p.join(root.path, userId)); + if (userDir.existsSync()) { + await userDir.delete(recursive: true); + } + _hashIndex.removeWhere((key, _) => key.startsWith('$userId/')); + XLogUtil.i('[FileSystemCache] clearByUser 完成 userId=$userId'); + } catch (e) { + XLogUtil.e('[FileSystemCache] clearByUser 失败: $e'); + } + } + + /// 清空某用户某空间的所有缓存(切空间时调用) + Future clearBySpace(String userId, String spaceId) async { + try { + final root = await _ensureRoot(); + final spaceDir = Directory(p.join(root.path, userId, spaceId)); + if (spaceDir.existsSync()) { + await spaceDir.delete(recursive: true); + } + _hashIndex.removeWhere((key, _) => key.startsWith('$userId/$spaceId/')); + } catch (e) { + XLogUtil.w('[FileSystemCache] clearBySpace 失败: $e'); + } + } + + /// 清空全部缓存(调试/重置用) + Future clearAll() async { + try { + final root = await _ensureRoot(); + if (root.existsSync()) { + await root.delete(recursive: true); + root.createSync(recursive: true); + } + _hashIndex.clear(); + XLogUtil.i('[FileSystemCache] clearAll 完成'); + } catch (e) { + XLogUtil.e('[FileSystemCache] clearAll 失败: $e'); + } + } +} diff --git a/lib/dart/base/storages/local_page_cache_repository.dart b/lib/dart/base/storages/local_page_cache_repository.dart new file mode 100644 index 0000000000000000000000000000000000000000..85686772e6c06994d359ce528ea41dfa1b4f34de --- /dev/null +++ b/lib/dart/base/storages/local_page_cache_repository.dart @@ -0,0 +1,270 @@ +import 'package:hive/hive.dart'; +import 'package:orginone/utils/log/log_util.dart'; + +/// 页面级本地存储仓储(基于 Hive Box) +/// +/// 用于"本地存储优先 + 后台增量更新"策略: +/// - 登录后/数据刷新后自动落盘完整业务数据(JSON 列表) +/// - 各页面加载时优先从本地存储读取渲染首屏 +/// - 后台网络刷新完成后比对数据 hash,无变化不刷新页面 +/// +/// 与 BootstrapSnapshot 的区别: +/// - BootstrapSnapshot:冷启动注水,存的是摘要(前 50 条/前 100 个) +/// - LocalPageCache:完整数据落盘,支持运行时页面级"本地优先"渲染 +/// +/// key 格式:`pageCache_${userId}_${spaceId}_${dataType}` +/// value 格式:`{data: List, hash: String, updateTime: int}` +class LocalPageCacheRepository { + LocalPageCacheRepository._(); + static final LocalPageCacheRepository instance = LocalPageCacheRepository._(); + + static const String _boxName = 'pageCacheBox'; + static const String _keyPrefix = 'pageCache'; + + Box? _box; + bool _initializing = false; + + Future ensureInitialized() async { + if (_box != null && _box!.isOpen) return; + if (_initializing) { + while (_initializing) { + await Future.delayed(const Duration(milliseconds: 20)); + if (_box != null && _box!.isOpen) return; + } + return; + } + _initializing = true; + try { + if (Hive.isBoxOpen(_boxName)) { + _box = Hive.box(_boxName); + } else { + _box = await Hive.openBox(_boxName); + } + } catch (e) { + XLogUtil.w('[LocalPageCache] 初始化失败: $e'); + } finally { + _initializing = false; + } + } + + String _buildKey(String userId, String? spaceId, String dataType) { + // P1 修复:处理 userId 为空或包含特殊字符的情况 + // Hive key 不允许 null,未登录态统一用 'anon' + final uid = (userId.isEmpty) ? 'anon' : userId; + final sid = (spaceId == null || spaceId.isEmpty) ? 'personal' : spaceId; + return '${_keyPrefix}_${uid}_${sid}_$dataType'; + } + + /// 读取本地存储的页面数据 + /// + /// 返回 `PageCacheEntry?`,包含 data 列表和上次更新时间。 + /// 调用方负责将 `List>` 反序列化为具体模型。 + Future read( + String dataType, { + required String userId, + String? spaceId, + }) async { + await ensureInitialized(); + final box = _box; + if (box == null) return null; + try { + final raw = box.get(_buildKey(userId, spaceId, dataType)); + if (raw == null) return null; + if (raw is! Map) return null; + final dataRaw = raw['data']; + final hash = raw['hash']?.toString() ?? ''; + final updateTime = raw['updateTime'] as int? ?? 0; + final List> data = + (dataRaw is List ? dataRaw : []).whereType().map((e) { + final m = {}; + e.forEach((k, v) => m[k.toString()] = v); + return m; + }).toList(); + return PageCacheEntry( + data: data, + hash: hash, + updateTime: updateTime, + ); + } catch (e) { + XLogUtil.w('[LocalPageCache] 读取失败 dataType=$dataType: $e'); + return null; + } + } + + /// 同步读取(仅在 ensureInitialized 之后可用) + PageCacheEntry? readSync( + String dataType, { + required String userId, + String? spaceId, + }) { + final box = _box; + if (box == null || !box.isOpen) return null; + try { + final raw = box.get(_buildKey(userId, spaceId, dataType)); + if (raw == null || raw is! Map) return null; + final dataRaw = raw['data']; + final hash = raw['hash']?.toString() ?? ''; + final updateTime = raw['updateTime'] as int? ?? 0; + final List> data = + (dataRaw is List ? dataRaw : []).whereType().map((e) { + final m = {}; + e.forEach((k, v) => m[k.toString()] = v); + return m; + }).toList(); + return PageCacheEntry( + data: data, + hash: hash, + updateTime: updateTime, + ); + } catch (e) { + // P0 修复:静默吞没异常会导致业务层误判为"无缓存"而跳过网络请求 + // 改为记录日志,让业务层能感知缓存读取失败 + XLogUtil.w( + '[CacheTracker] LocalPageCache readSync 失败 dataType=$dataType key=${_buildKey(userId, spaceId, dataType)}: $e'); + return null; + } + } + + /// 写入本地存储并返回数据是否有变化(基于 hash 比对) + /// + /// 调用方传入 `List>`(已通过 toJson 序列化), + /// 本方法计算 hash 与现有值比对,相同则不写盘,返回 false。 + /// 不同则写盘并返回 true。 + Future write( + String dataType, + List> data, { + required String userId, + String? spaceId, + }) async { + final sw = Stopwatch()..start(); + await ensureInitialized(); + final box = _box; + if (box == null) return false; + try { + final key = _buildKey(userId, spaceId, dataType); + final newHash = _computeHash(data); + final existing = box.get(key); + if (existing is Map && existing['hash'] == newHash) { + // 数据未变化,不写盘 + XLogUtil.d( + '[CacheTracker] LocalPageCache write 跳过(未变化) dataType=$dataType items=${data.length}'); + return false; + } + await box.put(key, { + 'data': data, + 'hash': newHash, + 'updateTime': DateTime.now().millisecondsSinceEpoch, + }); + XLogUtil.i( + '[CacheTracker] LocalPageCache write 成功 dataType=$dataType items=${data.length} hash=$newHash 耗时=${sw.elapsedMilliseconds}ms'); + return true; + } catch (e) { + XLogUtil.w( + '[CacheTracker] LocalPageCache write 失败 dataType=$dataType: $e'); + return false; + } finally { + sw.stop(); + } + } + + /// 计算数据签名(用于增量比对) + /// + /// 取每项的 id + updateTime(若存在)拼接做 hash,避免完整 JSON 序列化开销。 + /// 若 id 缺失则退化为 JSON 编码。 + String _computeHash(List> data) { + if (data.isEmpty) return 'empty'; + final buf = StringBuffer(); + for (final item in data) { + final id = item['id']?.toString() ?? ''; + final updateTime = item['updateTime']?.toString() ?? + item['metadata']?['updateTime']?.toString() ?? + ''; + final name = item['name']?.toString() ?? ''; + buf.write(id); + buf.write('|'); + buf.write(updateTime); + buf.write('|'); + buf.write(name); + buf.write(';'); + } + return buf.toString().hashCode.toRadixString(16); + } + + /// 清理指定用户的所有缓存(登出时调用) + Future clearUser(String userId) async { + await ensureInitialized(); + final box = _box; + if (box == null) return; + final keysToDelete = []; + final prefix = '${_keyPrefix}_${userId}_'; + for (final key in box.keys) { + if (key is String && key.startsWith(prefix)) { + keysToDelete.add(key); + } + } + for (final key in keysToDelete) { + await box.delete(key); + } + } + + /// 清理指定用户+空间的所有缓存(切换单位时可选调用) + Future clearSpace(String userId, String spaceId) async { + await ensureInitialized(); + final box = _box; + if (box == null) return; + final keysToDelete = []; + final prefix = '${_keyPrefix}_${userId}_${spaceId}_'; + for (final key in box.keys) { + if (key is String && key.startsWith(prefix)) { + keysToDelete.add(key); + } + } + for (final key in keysToDelete) { + await box.delete(key); + } + } + + /// 清空所有页面缓存(调试/强制刷新用) + Future clearAll() async { + await ensureInitialized(); + await _box?.clear(); + } +} + +/// 页面缓存条目 +class PageCacheEntry { + final List> data; + final String hash; + final int updateTime; + + PageCacheEntry({ + required this.data, + required this.hash, + required this.updateTime, + }); + + bool get isEmpty => data.isEmpty; + bool get isNotEmpty => data.isNotEmpty; + int get length => data.length; +} + +/// 页面级数据类型常量(与 DataType 对齐,但用独立字符串避免耦合) +class PageCacheType { + static const String chats = 'chats'; + static const String todos = 'todos'; + static const String apps = 'apps'; + static const String relationMembers = 'relationMembers'; + static const String relationCohorts = 'relationCohorts'; + static const String relationStorages = 'relationStorages'; + static const String relationAgents = 'relationAgents'; + static const String relationCompanys = 'relationCompanys'; + static const String storage = 'storage'; + static const String plaza = 'plaza'; + static const String plazaResources = 'plazaResources'; + static const String discoverGroupShare = 'discoverGroupShare'; + static const String discoverVideo = 'discoverVideo'; + static const String discoverNotice = 'discoverNotice'; + static const String discoverMarketGoods = 'discoverMarketGoods'; + static const String discoverDataShare = 'discoverDataShare'; + static const String discoverLive = 'discoverLive'; +} diff --git a/lib/dart/base/storages/local_page_cache_writer.dart b/lib/dart/base/storages/local_page_cache_writer.dart new file mode 100644 index 0000000000000000000000000000000000000000..dea4c7b0f298e3cf6d48d9b144cc43bb4762a4c4 --- /dev/null +++ b/lib/dart/base/storages/local_page_cache_writer.dart @@ -0,0 +1,194 @@ +import 'package:orginone/dart/base/storages/local_page_cache_repository.dart'; +import 'package:orginone/dart/core/target/person.dart'; +import 'package:orginone/utils/log/log_util.dart'; + +/// 本地页面缓存写入器 +/// +/// 将 [IPerson] 内存对象中的核心业务数据序列化为 JSON 列表并写入 +/// [LocalPageCacheRepository],供各页面"本地存储优先 + 后台增量更新"策略读取。 +/// +/// 落盘范围: +/// - chats:当前空间下的会话列表(前 100 条) +/// - todos:待办列表(全量) +/// - apps:当前空间下的应用列表(全量) +/// - relationMembers:个人空间下的好友列表 +/// - relationCohorts:群组列表 +/// - relationStorages:存储列表 +/// - relationAgents:智能体列表 +/// - relationCompanys:单位列表 +class LocalPageCacheWriter { + LocalPageCacheWriter._(); + + /// 一次性写入所有核心数据(按当前空间隔离) + static Future writeAll(IPerson user) async { + final userId = user.id; + final spaceId = user.currentSpace.id; + + // 并行写入各类型数据 + await Future.wait([ + _writeChats(user, userId, spaceId), + _writeTodos(user, userId, spaceId), + _writeApps(user, userId, spaceId), + _writeRelation(user, userId), + ]); + } + + /// 写入会话列表(按空间隔离) + static Future _writeChats( + IPerson user, String userId, String spaceId) async { + try { + final chats = user.currentSpace.chats; + final data = >[]; + for (final c in chats.take(100)) { + try { + final json = { + 'id': c.id, + 'name': c.chatdata.chatName, + 'typeName': c.metadata.typeName ?? '', + 'lastMsgTime': c.chatdata.lastMsgTime, + 'noReadCount': c.noReadCount, + 'spaceId': spaceId, + }; + data.add(json); + } catch (_) {} + } + await LocalPageCacheRepository.instance.write( + PageCacheType.chats, + data, + userId: userId, + spaceId: spaceId, + ); + } catch (e) { + XLogUtil.w('[LocalPageCache] 写入 chats 失败: $e'); + } + } + + /// 写入待办列表(按空间隔离) + static Future _writeTodos( + IPerson user, String userId, String spaceId) async { + try { + // work 在 DataProvider 中,这里通过 relationCtrl 引用 + // 为避免循环依赖,todos 的写入由 DataProvider 直接调用, + // 此处仅写入空间相关的数据 + } catch (e) { + XLogUtil.w('[LocalPageCache] 写入 todos 失败: $e'); + } + } + + /// 写入应用列表(按空间隔离) + static Future _writeApps( + IPerson user, String userId, String spaceId) async { + try { + final apps = await user.currentSpace.directory.loadAllApplication(); + final data = >[]; + for (final app in apps) { + try { + if (app.groupTags.contains('已删除')) continue; + final json = { + 'id': app.id, + 'name': app.name, + 'typeName': app.metadata.typeName, + 'updateTime': app.metadata.updateTime, + 'createTime': app.metadata.createTime, + 'spaceId': spaceId, + }; + data.add(json); + } catch (e) { + XLogUtil.e('[LocalPageCache] app 序列化失败: $e'); + } + } + await LocalPageCacheRepository.instance.write( + PageCacheType.apps, + data, + userId: userId, + spaceId: spaceId, + ); + } catch (e) { + XLogUtil.e('[LocalPageCache] 写入 apps 失败: $e'); + } + } + + /// 写入关系树数据(按用户隔离,不按空间,因为是个人空间下的列表) + static Future _writeRelation(IPerson user, String userId) async { + try { + // members(好友)— user.members 是 List + final members = user.members; + await LocalPageCacheRepository.instance.write( + PageCacheType.relationMembers, + members.map((m) => m.toJson()).toList(), + userId: userId, + spaceId: 'personal', + ); + + // cohorts(群组) + final cohorts = user.cohorts; + await LocalPageCacheRepository.instance.write( + PageCacheType.relationCohorts, + cohorts.map((c) => c.metadata.toJson()).toList(), + userId: userId, + spaceId: 'personal', + ); + + // storages(存储) + final storages = user.storages; + await LocalPageCacheRepository.instance.write( + PageCacheType.relationStorages, + storages.map((s) => s.metadata.toJson()).toList(), + userId: userId, + spaceId: 'personal', + ); + + // agents(智能体) + final agents = user.agents; + await LocalPageCacheRepository.instance.write( + PageCacheType.relationAgents, + agents.map((a) => a.metadata.toJson()).toList(), + userId: userId, + spaceId: 'personal', + ); + + // companys(单位) + final companys = user.companys; + await LocalPageCacheRepository.instance.write( + PageCacheType.relationCompanys, + companys.map((c) => c.metadata.toJson()).toList(), + userId: userId, + spaceId: 'personal', + ); + } catch (e) { + XLogUtil.w('[LocalPageCache] 写入 relation 失败: $e'); + } + } + + /// 单独写入待办(供 DataProvider 直接调用) + static Future writeTodos( + List todos, String userId, String spaceId) async { + try { + final data = >[]; + for (final t in todos) { + try { + // IWorkTask 通过 taskdata/metadata 访问 + final taskdata = (t as dynamic).taskdata; + final metadata = (t as dynamic).metadata; + final json = { + 'id': metadata?.id ?? taskdata?.id, + 'title': taskdata?.title, + 'status': taskdata?.status, + 'belongId': taskdata?.belongId, + 'updateTime': metadata?.updateTime ?? taskdata?.updateTime, + 'createTime': metadata?.createTime ?? taskdata?.createTime, + }; + data.add(json); + } catch (_) {} + } + await LocalPageCacheRepository.instance.write( + PageCacheType.todos, + data, + userId: userId, + spaceId: spaceId, + ); + } catch (e) { + XLogUtil.w('[LocalPageCache] 写入 todos 失败: $e'); + } + } +} diff --git a/lib/dart/base/storages/models/asset_creation_config.dart b/lib/dart/base/storages/models/asset_creation_config.dart index 6788281778cf5ce5c269b9ec5b33c8a260ea4875..9af9780c879f9b5e681a1263ceeeea7db32ed437 100644 --- a/lib/dart/base/storages/models/asset_creation_config.dart +++ b/lib/dart/base/storages/models/asset_creation_config.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:hive/hive.dart'; +import 'package:orginone/dart/base/model.dart'; part 'asset_creation_config.g.dart'; @HiveType(typeId: 4) @@ -116,6 +117,10 @@ class Fields { double? marginRight; @HiveField(14) String? router; + // 非持久化字段:用于 sensor/space 等需要丰富信息的组件 + // 对齐 oiocns-react:Fields 类不直接持有 FieldModel,但需要 lookups/speciesId + List? lookups; + String? speciesId; Fields( {this.title, @@ -127,7 +132,9 @@ class Fields { this.hidden, this.router, this.hint, - this.select}) { + this.select, + this.lookups, + this.speciesId}) { initData(); } diff --git a/lib/dart/controller/app_start_controller.dart b/lib/dart/controller/app_start_controller.dart index 5dfc9cfaa56eac7117c553220d5a018a58ce2f94..19e71fd417198437de68625f489ae7637af46cf5 100644 --- a/lib/dart/controller/app_start_controller.dart +++ b/lib/dart/controller/app_start_controller.dart @@ -45,6 +45,10 @@ class AppStartController with EmitterMixin, AuthMixin { DateTime? _lastAppResumedAt; static const Duration _appResumedThrottle = Duration(seconds: 5); + ///连接错误 Toast 节流,避免断连循环时频繁弹提示 + DateTime? _lastConnErrorToastAt; + static const Duration _connErrorToastThrottle = Duration(seconds: 10); + AppStartController() { _result = ConnectivityResult.none; _appLifecycleState = ""; @@ -56,23 +60,21 @@ class AppStartController with EmitterMixin, AuthMixin { }, false); kernel.onConnectedChanged((isConnected) async { - //XLogUtil.dd("长链接状态:$isConnected mode=${kernel.transportMode.name} health=${kernel.transportHealth.name}"); if (isConnected) { - // 仅在之前有断网/数据延迟事件时才触发刷新,避免计数器无限膨胀 - if (_noneConnectivityCount > 0) { - _noneConnectivityCount += 1; - } + // 长连接恢复:触发 changeCallback → _networkFluctuations → refreshData + // _noneConnectivityCount 由 _networkFluctuations 在刷新完成后清零 changeCallback(); } }); _connectionErrorId = command.subscribe((type, cmd, args) { if (type == 'connection' && cmd == 'error') { - // final errorMsg = args != null && args is List && args.isNotEmpty - // ? args[0].toString() - // : "未知错误"; - //XLogUtil.e("连接错误: $errorMsg"); - ToastUtils.showMsg(msg: "连接服务器失败,正在尝试恢复..."); + final now = DateTime.now(); + if (_lastConnErrorToastAt == null || + now.difference(_lastConnErrorToastAt!) > _connErrorToastThrottle) { + _lastConnErrorToastAt = now; + ToastUtils.showMsg(msg: "连接服务器失败,正在尝试恢复..."); + } } }); @@ -98,7 +100,9 @@ class AppStartController with EmitterMixin, AuthMixin { XLogUtil.w('AppStartController 初始网络状态获取失败: $e'); }); // 监听网络状态变化 - Connectivity().onConnectivityChanged.listen((List results) { + Connectivity() + .onConnectivityChanged + .listen((List results) { ConnectivityResult result = results.isNotEmpty ? results.first : ConnectivityResult.none; connectivityResult = result; @@ -127,15 +131,31 @@ class AppStartController with EmitterMixin, AuthMixin { } set connectivityResult(ConnectivityResult result) { + final previous = _result; + final isNetworkTypeChanged = previous != ConnectivityResult.none && + result != ConnectivityResult.none && + previous != result; _result = result; if (_result == ConnectivityResult.none) { _noneConnectivityCount += 1; ToastUtils.showMsg(msg: "网络不可用,请检查网络设置"); } else { - // 重新连接后清零,允许 cacheChatData 恢复写入 - _noneConnectivityCount = 0; - // 等待长连接恢复后再触发后续刷新逻辑 - unawaited(kernel.checkOnline()); + // 网络恢复:不清零计数器,保留 hasDataDelay=true 标志, + // 等 _networkFluctuations → refreshData → _wakeUp 完成后再清零。 + // 这样纯网络波动(不切前后台)也能触发数据刷新。 + // 网络类型变更(如 WiFi→蜂窝)时,旧连接的 socket 可能已失效, + // 需要强制重启 SignalR 连接,避免长时间挂在上一个网络通道上。 + if (isNetworkTypeChanged && loginStatus) { + XLogUtil.i('网络类型变更: $previous → $result,强制重启长连接'); + unawaited(kernel.restart().then((_) { + // 重启完成后触发数据刷新 + return refreshData(); + }).catchError((e) { + XLogUtil.w('网络类型变更重启失败: $e'); + })); + } else { + unawaited(kernel.checkOnline()); + } } command.emitter('network', 'refresh'); } @@ -157,6 +177,12 @@ class AppStartController with EmitterMixin, AuthMixin { } /// 后台恢复时处理:恢复长连接 + 刷新数据 + /// + /// 后台唤起的关键问题: + /// 1. app 在后台时,Timer 被系统挂起,心跳停止,连接可能已僵死 + /// 但 _connection.state 仍为 Connected(TCP 层未感知断开) + /// 2. 需要先检测 stale 并强制重启,再刷新数据 + /// 3. 后台时间过长时,token 可能已过期,需要先校验 Future _onAppResumed() async { // 节流:避免快速切前后台触发多次刷新 final now = DateTime.now(); @@ -169,6 +195,10 @@ class AppStartController with EmitterMixin, AuthMixin { await kernel.checkOnline(); return; } + + // 后台唤起:先检测 stale 连接并强制重启(checkOnline 内部已处理), + // 再走 refreshData 流程刷新数据。 + // stale 检测发生在 checkOnline() 内部:isStale → reviveIfStale → _starting // refreshData() 内部会调用 kernel.checkOnline() 恢复长连接并刷新数据 await refreshData(); } @@ -178,11 +208,11 @@ class AppStartController with EmitterMixin, AuthMixin { } Future _networkFluctuations() async { - //XLogUtil.dd( - // "hasDataDelay:$hasDataDelay isNetworkConnected:$isNetworkConnected appStartStatus:$appStartStatus"); if ((hasDataDelay && isNetworkConnected) || appStartStatus == ExecuteStatus.failed) { await refreshData(); + // 刷新完成后清零计数器,允许 cacheChatData 恢复写入 + _noneConnectivityCount = 0; } } @@ -200,7 +230,9 @@ class AppStartController with EmitterMixin, AuthMixin { bool res = await kernel.checkOnline(); if (!res && !kernel.isDegraded) { - return; + // 长连接未恢复:不退出,继续用 HTTP fallback 刷新数据 + // kernel.request 内部会自动降级到 http_fallback(allowHttpFallback=true) + XLogUtil.w('唤醒刷新: 长连接未恢复,使用 HTTP fallback 刷新数据'); } if (kernel.user == null) { diff --git a/lib/dart/controller/data_freshness_tracker.dart b/lib/dart/controller/data_freshness_tracker.dart index c235bc598852afb3aa10c3618720942d80c63658..d6adde94f88f8750348559ba626d01080ac0df57 100644 --- a/lib/dart/controller/data_freshness_tracker.dart +++ b/lib/dart/controller/data_freshness_tracker.dart @@ -18,37 +18,50 @@ class DataFreshnessTracker { static const String _keyPrefix = 'dft_'; /// 默认各数据类型的最大有效期(毫秒) - /// - chats: 30s(消息频繁,但长连接会推送 session flag,不必太短) - /// - work: 60s(待办变化由 work flag 推送) - /// - relation: 5min(关系数据相对稳定) - /// - activities: 60s(动态频繁) - /// - storage: 5min(存储列表稳定) - /// - forms: 5min(视图列表稳定) - /// - apps: 10min(应用列表极少变) - /// - plaza: 10min(网站栏目缓存已在 DiscoverService 10min) + /// 策略:除沟通消息外,其他数据类型 TTL 设为 24h(等效"不自动过期"), + /// 进入页面时只用内存缓存渲染,仅下拉刷新时通过 invalidate + ensureDataFresh 强制在线获取。 + /// - chats: 30s(消息频繁,允许进入页面时按需自动刷新) + /// - work/relation/storage/apps/activities 等: 24h(仅下拉刷新才在线获取) static const Map _defaultMaxAge = { DataType.chats: 30 * 1000, - DataType.work: 60 * 1000, - DataType.relation: 5 * 60 * 1000, - DataType.activities: 60 * 1000, - DataType.storage: 5 * 60 * 1000, - DataType.forms: 5 * 60 * 1000, - DataType.apps: 10 * 60 * 1000, - DataType.plaza: 10 * 60 * 1000, - DataType.iotDevices: 60 * 1000, + DataType.work: 24 * 60 * 60 * 1000, + DataType.relation: 24 * 60 * 60 * 1000, + DataType.activities: 24 * 60 * 60 * 1000, + DataType.storage: 24 * 60 * 60 * 1000, + DataType.forms: 24 * 60 * 60 * 1000, + DataType.apps: 24 * 60 * 60 * 1000, + DataType.plaza: 24 * 60 * 60 * 1000, + DataType.iotDevices: 24 * 60 * 60 * 1000, }; + /// 最大存活时间硬上限(7天) + /// P1-4 修复:防止系统时间被修改后缓存永久失效/有效 + /// 即使 TTL 未过期,超过 7 天也强制刷新一次 + static const int _maxLiveTimeMs = 7 * 24 * 60 * 60 * 1000; + /// 判断指定数据类型是否需要刷新 /// /// [dataType] 数据类型标识,见 [DataType] /// [maxAgeMs] 自定义最大有效期(毫秒),为 null 时使用默认值 /// 返回 true 表示需要刷新(已超时或从未加载),false 表示数据仍新鲜 + /// + /// P1-4 修复:增加系统时间倒拨检测 + /// - 若 now < lastTime(系统时间被回拨),按 maxAge 判断 + /// - 若 now - lastTime > _maxLiveTimeMs(超过7天硬上限),强制刷新 bool shouldRefresh(String dataType, {int? maxAgeMs}) { final maxAge = maxAgeMs ?? _defaultMaxAge[dataType] ?? (60 * 1000); final lastTime = _readLastUpdate(dataType); if (lastTime == 0) return true; final now = DateTime.now().millisecondsSinceEpoch; - return (now - lastTime) > maxAge; + final diff = now - lastTime; + // 系统时间倒拨:diff 为负数,按绝对值判断(避免缓存永久有效) + if (diff < 0) { + // 时间倒拨超过 maxAge 也需要刷新 + return (-diff) > maxAge; + } + // 超过最大存活时间硬上限,强制刷新(避免缓存永久有效) + if (diff > _maxLiveTimeMs) return true; + return diff > maxAge; } /// 标记数据类型已完成刷新 diff --git a/lib/dart/core/chat/activity.dart b/lib/dart/core/chat/activity.dart index ef0b67189537d16ac0b4eaee11324d58a1b540ba..d23039d44182b19ad9613a347ca9448b34c15b4f 100644 --- a/lib/dart/core/chat/activity.dart +++ b/lib/dart/core/chat/activity.dart @@ -683,13 +683,13 @@ class GroupActivity extends Entity return super.subscribe(callback); } - @override - void unsubscribe([dynamic id]) { - super.unsubscribe(id); - // for (var activity in subActivitys) { - // activity.unsubscribe(subscribeIds); - // } - } + // @override + // void unsubscribe([dynamic key]) { + // super.unsubscribe(key); + // // for (var activity in subActivitys) { + // // activity.unsubscribe(subscribeIds); + // // } + // } } mixin MDataPreview { diff --git a/lib/dart/core/chat/session.dart b/lib/dart/core/chat/session.dart index 936ab80391dde73140b5b15f32ddd9fce8a3d309..ec0b677a4b2a853299c91e1d5f0016ef4e421251 100644 --- a/lib/dart/core/chat/session.dart +++ b/lib/dart/core/chat/session.dart @@ -425,14 +425,15 @@ class Session extends Entity implements ISession { newMessageHandler.put(Message(msg, this)); } readMessages(messages); - if (chatdata.lastMsgTime == nullTime.millisecondsSinceEpoch) { - chatdata.lastMessage = data[0]; - chatdata.lastMsgTime = - DateTime.parse(data[0].createTime!).millisecondsSinceEpoch; - // if (reload) { - // await target.user?.cacheObj.all(reload: reload); - // await loadCacheChatData(); - // } + // 始终更新 lastMsgTime 为最新消息时间(修复接收消息后不更新导致排序/指纹失效) + final latestCreateTime = data[0].createTime; + if (latestCreateTime != null && latestCreateTime.isNotEmpty) { + final latestMsgTime = + DateTime.parse(latestCreateTime).millisecondsSinceEpoch; + if (latestMsgTime > chatdata.lastMsgTime) { + chatdata.lastMessage = data[0]; + chatdata.lastMsgTime = latestMsgTime; + } } if (skip > pageSize) { emitter('new'); diff --git a/lib/dart/core/iot/device_repository.dart b/lib/dart/core/iot/device_repository.dart index 5f5d390f6e819a401ba2915ae86c5e80965e0502..89508c7ffe114694e0a076bea81dc6444bea9755 100644 --- a/lib/dart/core/iot/device_repository.dart +++ b/lib/dart/core/iot/device_repository.dart @@ -45,8 +45,9 @@ class DeviceRepository extends ChangeNotifier { /// 获取设备列表(分页) /// - /// 离线优先:内存 → SQLite L1 → 远端 - /// 远端成功后同步写入 SQLite L1,下次离线即可从 L1 读取。 + /// 本地优先:内存 → SQLite L1 → 远端 + /// 页面打开时先用本地数据渲染首屏,后台 fire-and-forget 拉取远端最新版本, + /// 远端成功后同步写入 SQLite L1 并刷新页面(有变化才刷新,无变化不重建)。 /// /// [spaceId] 空间过滤,为空则查全部。 /// [status] 状态过滤,为 null 则不过滤。 @@ -57,7 +58,52 @@ class DeviceRepository extends ChangeNotifier { int page = 1, int pageSize = 20, }) async { - // 1. 远端拉取(首次或在线时) + // 1. 内存命中(按 spaceId 过滤) + final memHits = _devices.values + .where((d) => spaceId == null || d.spaceId == spaceId) + .where((d) => status == null || d.status == status) + .where((d) => + keyword == null || + keyword.isEmpty || + d.name.contains(keyword)) + .toList(); + if (memHits.isNotEmpty) { + // 本地有数据,先返回渲染首屏,同时后台拉取远端更新 + unawaited(_refreshFromRemote(spaceId, status, keyword, page, pageSize)); + return memHits; + } + + // 2. SQLite L1 命中 + final local = await _cache.listDevices(spaceId: spaceId, limit: pageSize); + if (local.isNotEmpty) { + var filtered = local; + if (status != null) { + filtered = filtered.where((d) => d.status == status).toList(); + } + if (keyword != null && keyword.isNotEmpty) { + filtered = filtered.where((d) => d.name.contains(keyword)).toList(); + } + for (final device in filtered) { + _devices[device.id] = device; + } + // 本地命中后,后台拉取远端更新(持久化 + 刷新) + unawaited(_refreshFromRemote(spaceId, status, keyword, page, pageSize)); + notifyListeners(); + return filtered; + } + + // 3. 本地无数据:在线拉取 + return _refreshFromRemote(spaceId, status, keyword, page, pageSize); + } + + /// 后台从远端拉取设备列表,成功后写入 SQLite L1 并通知 UI 刷新 + Future> _refreshFromRemote( + String? spaceId, + DeviceStatus? status, + String? keyword, + int page, + int pageSize, + ) async { try { final result = await _kernel.request>( ReqestType( @@ -77,38 +123,32 @@ class DeviceRepository extends ChangeNotifier { ); if (result.success && result.data != null) { - // 不可变更新:替换缓存 - for (final device in result.data!) { + final remote = result.data!; + // 比较远端与本地是否一致,避免无变化时重建 + final remoteIds = remote.map((e) => e.id).toSet(); + final localHits = _devices.values + .where((d) => spaceId == null || d.spaceId == spaceId) + .map((d) => d.id) + .toSet(); + final hasChange = !remoteIds.containsAll(localHits) || + !localHits.containsAll(remoteIds) || + remote.any((r) { + final cached = _devices[r.id]; + return cached == null || + cached.lastSeen != r.lastSeen || + cached.status != r.status; + }); + for (final device in remote) { _devices[device.id] = device; } - // 同步写入 SQLite L1(fire-and-forget) - unawaited(_cache.upsertDevices(result.data!)); - notifyListeners(); - return result.data!; + unawaited(_cache.upsertDevices(remote)); + if (hasChange) notifyListeners(); + return remote; } } catch (e) { - XLogUtil.e('DeviceRepository.listDevices 远端拉取失败,回退本地缓存: $e'); - } - - // 2. 远端失败:从 SQLite L1 读取 - final local = await _cache.listDevices(spaceId: spaceId, limit: pageSize); - if (local.isNotEmpty) { - var filtered = local; - if (status != null) { - filtered = filtered.where((d) => d.status == status).toList(); - } - if (keyword != null && keyword.isNotEmpty) { - filtered = filtered - .where((d) => d.name.contains(keyword)) - .toList(); - } - for (final device in filtered) { - _devices[device.id] = device; - } - notifyListeners(); - return filtered; + XLogUtil.e('DeviceRepository._refreshFromRemote 远端拉取失败: $e'); } - return []; + return const []; } /// 获取单个设备详情 diff --git a/lib/dart/core/oip/ar_visualization.dart b/lib/dart/core/oip/ar_visualization.dart index 6d5df863d4df40f9b1b85af7e004c4f2929a669b..a411056575333fd42b9ce9e98b2e01fae6c946c6 100644 --- a/lib/dart/core/oip/ar_visualization.dart +++ b/lib/dart/core/oip/ar_visualization.dart @@ -219,10 +219,9 @@ class ArSessionResult { final String? errorMessage; final ArSessionState? state; - const ArSessionResult.success(ArSessionState state) + const ArSessionResult.success(this.state) : success = true, - errorMessage = null, - state = state; + errorMessage = null; const ArSessionResult.failure(String message) : success = false, diff --git a/lib/dart/core/oip/pure_signature_verifier.dart b/lib/dart/core/oip/pure_signature_verifier.dart index c2c5095e06cb84f43e9786e927c38cec58cf2d48..8e7cf421bdda172bcd934a7d9ffdef265c52cde8 100644 --- a/lib/dart/core/oip/pure_signature_verifier.dart +++ b/lib/dart/core/oip/pure_signature_verifier.dart @@ -185,7 +185,5 @@ class PureVerifyResult { const PureVerifyResult.valid() : isValid = true, reason = null; - const PureVerifyResult.invalid({required String reason}) - : isValid = false, - reason = reason; + const PureVerifyResult.invalid({required this.reason}) : isValid = false; } diff --git a/lib/dart/core/provider/auth.dart b/lib/dart/core/provider/auth.dart index a10edcddc98f940b9a6c1a870ada61801a653382..9e94196f6eb43f43e9c1197da8ec65a2e7ecb6bc 100644 --- a/lib/dart/core/provider/auth.dart +++ b/lib/dart/core/provider/auth.dart @@ -1,15 +1,22 @@ +import 'dart:async'; import 'dart:convert'; import 'package:orginone/dart/base/common/commands.dart'; +import 'package:orginone/dart/base/common/systemError.dart'; import 'package:orginone/dart/base/model.dart'; import 'package:orginone/dart/base/schema.dart'; import 'package:orginone/dart/base/storages/bootstrap_snapshot_repository.dart'; import 'package:orginone/dart/base/storages/hive_utils.dart'; +import 'package:orginone/dart/base/storages/local_page_cache_repository.dart'; import 'package:orginone/dart/base/storages/storage.dart'; +import 'package:orginone/dart/base/storages/business_database.dart'; +import 'package:orginone/dart/base/security/secure_database.dart'; import 'package:orginone/dart/controller/data_freshness_tracker.dart'; import 'package:orginone/dart/core/chat/resource.dart'; +import 'package:orginone/dart/core/iot/iot_data_cache.dart'; import 'package:orginone/dart/core/iot/iot_service.dart'; import 'package:orginone/dart/core/public/consts.dart'; +import 'package:orginone/dart/core/repository/flow_data_cache_service.dart'; import 'package:orginone/dart/core/target/base/resource.dart'; import 'package:orginone/utils/log/log_util.dart'; import 'package:orginone/main.dart'; @@ -54,6 +61,9 @@ class AuthProvider with AuthMixin { /// 登录 /// @param {RegisterType} params 参数 + /// + /// ★登录加速:API 成功后不 await _onAuthed,实例创建+数据加载在后台执行。 + /// UI 立即跳转首页从本地存储渲染首屏,_refresh 完成后通过事件刷新。 Future> login( LoginModel params, ) async { @@ -63,7 +73,12 @@ class AuthProvider with AuthMixin { XLogUtil.i('>>>>>>[登录耗时] auth API: ${sw.elapsedMilliseconds}ms'); sw.stop(); if (res.success) { - await _onAuthed(res.data!.target); + // 标记登录状态,供 initialRoute / AppStartController 判定 + // kernel.auth 不会设置 loginStatus(只有已废弃的 kernel.login 会), + // 这里补齐,否则重启后无法自动登录。 + Storage.setBool(Constants.loginStatus, true); + // 不阻塞返回:_onAuthed 内部会同步创建实例 + 后台异步 _refresh + unawaited(_onAuthed(res.data!.target)); } return res; } @@ -76,6 +91,7 @@ class AuthProvider with AuthMixin { var res = await kernel.auth( 'Register', params, TokenResultModel.fromJson); if (res.success) { + Storage.setBool(Constants.loginStatus, true); await _onAuthed(res.data!.target); } return res; @@ -92,6 +108,7 @@ class AuthProvider with AuthMixin { var res = await kernel.auth( 'ResetPwd', params, TokenResultModel.fromJson); if (res.success) { + Storage.setBool(Constants.loginStatus, true); await _onAuthed(res.data!.target); } return res; @@ -131,12 +148,38 @@ mixin AuthMixin { currentUserId.isNotEmpty ? currentUserId : cachedUserId; if (snapshotUserId.isNotEmpty) { await BootstrapSnapshotRepository().clearByUser(snapshotUserId); + // 清理 LocalPageCache 中该用户的页面级缓存 + await LocalPageCacheRepository.instance.clearUser(snapshotUserId); } // 清理静态资源缓存,防止切换账号后残留前一个用户数据 TargetResource.clearAll(); SessionResource.clearAll(); IotServiceManager().clearAll(); + // 清空 IoT SQLite L1 业务表(iot_devices/iot_thing_models/iot_events/iot_commands), + // 防止跨用户数据残留(AGENTS.md §6.2 硬约束) + await IotDataCache.instance.clearAll(); + + // 清空业务数据 SQLite 全量表(sessions/messages/activities/work_tasks/relations/storages/agents) + await BusinessDatabase.instance.clearAll(); + + // 清空流程数据三级缓存之 L2 文件系统(work_node/work_defines 等大文档) + if (snapshotUserId.isNotEmpty) { + await FlowDataCacheService.instance.clearByUser(snapshotUserId); + } + + // 清空 VET 安全数据库(G10 修复,防止跨用户凭证残留) + if (SecureDatabase.current.isInitialized) { + try { + await SecureDatabase.current.clearAllVets(); + await SecureDatabase.current.close(); + } catch (e) { + XLogUtil.e('exitLogin 清理 SecureDatabase 失败: $e'); + } + } + + // 清理错误日志(前一用户的日志不应残留),与 SystemLog 单例解绑 + SystemLog.clearForLogout(); // 清理数据新鲜度跟踪记录(core 层),避免跨账号残留 DataFreshnessTracker.instance.invalidateAll(); diff --git a/lib/dart/core/provider/index.dart b/lib/dart/core/provider/index.dart index 4b786568a400f046c66cb6f2868f4ac08057c552..7a8b0b6c0ec9d28206a7b504e62281eedd114a2a 100644 --- a/lib/dart/core/provider/index.dart +++ b/lib/dart/core/provider/index.dart @@ -7,8 +7,10 @@ import 'package:orginone/components/Tip/ToastUtils.dart'; import 'package:orginone/dart/base/common/commands.dart'; import 'package:orginone/dart/base/common/emitter.dart'; import 'package:orginone/dart/base/common/mutex.dart'; +import 'package:orginone/dart/base/common/systemError.dart'; import 'package:orginone/dart/base/model.dart'; import 'package:orginone/dart/base/schema.dart'; +import 'package:orginone/dart/base/storages/local_page_cache_writer.dart'; import 'package:orginone/dart/base/storages/models/bootstrap_snapshot.dart'; import 'package:orginone/dart/base/storages/storage.dart'; import 'package:orginone/dart/controller/bootstrap_coordinator.dart'; @@ -90,16 +92,21 @@ class DataProvider with EmitterMixin, Mutex { await _chatProvider ?.load(reload: true, skipDeepLoad: true) .then((_) {}) - .catchError((e) { - XLogUtil.e('>>>>>>[登录诊断] _onBackgroundLoaded chat load 失败: $e'); + .catchError((e, s) { + final msg = '_onBackgroundLoaded chat load 失败: $e\n$s'; + XLogUtil.e('>>>>>>[登录诊断] $msg'); + SystemLog.err(msg, '后台加载'); }); // 重新加载 activityProvider(此时 company.activitys 已有数据) await _chatProvider?.activityProvider .load(reload: true) - .catchError((e) { - XLogUtil.e('>>>>>>[登录诊断] _onBackgroundLoaded activity load 失败: $e'); + .catchError((e, s) { + final msg = '_onBackgroundLoaded activity load 失败: $e\n$s'; + XLogUtil.e('>>>>>>[登录诊断] $msg'); + SystemLog.err(msg, '后台加载'); }); - XLogUtil.i('>>>>>>[登录诊断] _onBackgroundLoaded 完成: ${sw.elapsedMilliseconds}ms'); + XLogUtil.i( + '>>>>>>[登录诊断] _onBackgroundLoaded 完成: ${sw.elapsedMilliseconds}ms'); sw.stop(); // 后台二级递归完成后:关系/动态/会话数据均已刷新到最新, // 标记这些数据类型为已刷新,避免子页面进入时重复拉取。 @@ -110,8 +117,10 @@ class DataProvider with EmitterMixin, Mutex { // 通知页面刷新 _notifyDataRefreshed(); _scheduleSnapshotWrite(); - } catch (e) { - XLogUtil.e('>>>>>>[登录诊断] _onBackgroundLoaded 异常: $e'); + } catch (e, s) { + final msg = '_onBackgroundLoaded 异常: $e\n$s'; + XLogUtil.e('>>>>>>[登录诊断] $msg'); + SystemLog.err(msg, '后台加载'); } } @@ -264,33 +273,30 @@ class DataProvider with EmitterMixin, Mutex { /// 加载用户 /// - /// [forLogin] 为 true 时走快速登录路径(首屏必需数据优先,其余后台异步)。 + /// [forLogin] 为 true 时走快速登录路径: + /// - 同步创建领域对象实例(Person/WorkProvider/ChatProvider/HomeProvider) + /// - _refresh 网络数据加载改为后台异步执行(不阻塞登录跳转) + /// - 登录 API 成功后立即返回,UI 跳转首页从本地存储渲染首屏 + /// - _refresh 完成后通过 changeCallback + command 事件通知页面刷新 Future _loadUser(XTarget person, {bool forLogin = false}) async { final sw = Stopwatch()..start(); - XLogUtil.i('>>>>>>[登录诊断] _loadUser 开始 person=${person.id} forLogin=$forLogin keepInstances=${_user != null && _user!.id == person.id && _home != null && _chatProvider != null}'); - // ★6 防御性:先把 lastLoginUserId 写到 SP,避免后续 _refresh / writeFullSnapshot - // 抛错时下次冷启动 hydrate 入口直接拿不到 userId。 - await Storage.setString(Constants.lastLoginUserId, person.id); - await stopWatch("加载${person.name}用户", () async { - try { - // ★1 防御:若 hydrate 在本次执行中已经把同一用户的实例建好, - // 跳过实例重建避免覆盖 hydrate 写入的数据,直接走 _refresh 拉网络。 - final keepInstances = _user != null && - _user!.id == person.id && - _home != null && - _chatProvider != null; - if (keepInstances) { - //XLogUtil.d('>>>>>>User._loadUser keep hydrated instances'); - await _refresh(forLogin: forLogin); - return; - } + XLogUtil.i( + '>>>>>>[登录诊断] _loadUser 开始 person=${person.id} forLogin=$forLogin keepInstances=${_user != null && _user!.id == person.id && _home != null && _chatProvider != null}'); + // ★6 防御性:先把 lastLoginUserId 写到 SP(fire-and-forget 不阻塞) + Storage.setString(Constants.lastLoginUserId, person.id); + try { + // ★1 防御:若 hydrate 在本次执行中已经把同一用户的实例建好, + // 跳过实例重建避免覆盖 hydrate 写入的数据,直接走 _refresh 拉网络。 + final keepInstances = _user != null && + _user!.id == person.id && + _home != null && + _chatProvider != null; + if (!keepInstances) { _user = Person(person); - //XLogUtil.i(_user); _work.value = WorkProvider(this); _box = BoxProvider(this); _chatProvider = ChatProvider(_user!); // 注册后台数据刷新回调:长连接后台刷新会话数据后,触发本地快照写入 - // 确保每次获取新数据都同步更新本地缓存(Hive 快照) _chatProvider!.onDataRefreshed = _scheduleSnapshotWrite; // 保留 Hive 快照注入的工作台摘要,新实例重建后离线兜底数据仍可用 final prevAppsSummary = _home?.workbenchAppsSummary ?? const {}; @@ -302,22 +308,35 @@ class DataProvider with EmitterMixin, Mutex { if (prevDiskSummary.isNotEmpty) { _home!.workbenchDiskInfoSummary = prevDiskSummary; } - //XLogUtil.d('>>>>>>User._loadUser'); - await _refresh(forLogin: forLogin); - } catch (e, s) { - var t = DateUtil.formatDate(DateTime.now(), - format: "yyyy-MM-dd HH:mm:ss.SSS"); - errInfo += '$t $e ==== $s'; - XLogUtil.e('>>>>>>[登录诊断] _loadUser 异常: $e\n$s'); - changeCallback(args: [false]); - } finally { - // release(); - _inited = true; - XLogUtil.i( - '>>>>>>[登录耗时] _loadUser${forLogin ? "(快速)" : "(完整)"} 总计: ${sw.elapsedMilliseconds}ms'); + } + XLogUtil.i('>>>>>>[登录耗时] 实例创建完成: ${sw.elapsedMilliseconds}ms'); + + if (forLogin) { + // ★登录加速:_refresh 改为后台异步执行,不阻塞登录跳转 + // 首页从 LocalPageCache 读取渲染,_refresh 完成后通过事件刷新 + unawaited(_refresh(forLogin: true).then((_) { + XLogUtil.i( + '>>>>>>[登录耗时] 后台 _refresh 完成: ${sw.elapsedMilliseconds}ms'); + sw.stop(); + }).catchError((e, s) { + XLogUtil.e('>>>>>>[登录诊断] 后台 _refresh 异常: $e\n$s'); + sw.stop(); + })); + } else { + // 非登录路径(手动刷新等)仍同步等待 + await _refresh(forLogin: false); sw.stop(); } - }); + } catch (e, s) { + var t = DateUtil.formatDate(DateTime.now(), + format: "yyyy-MM-dd HH:mm:ss.SSS"); + errInfo += '$t $e ==== $s'; + final msg = '[$t] _loadUser 异常: $e\n$s'; + XLogUtil.e('>>>>>>[登录诊断] $msg'); + SystemLog.err(msg, '登录加载'); + changeCallback(args: [false]); + sw.stop(); + } } /// 重载数据 @@ -328,7 +347,8 @@ class DataProvider with EmitterMixin, Mutex { /// 目标登录 <5s,同时保证首屏数据完整。 /// [forLogin] 为 false 时走完整重载(手动刷新等场景)。 Future _refresh({bool forLogin = false}) async { - XLogUtil.i('>>>>>>[登录诊断] _refresh 开始 forLogin=$forLogin user=${_user?.id ?? "null"}'); + XLogUtil.i( + '>>>>>>[登录诊断] _refresh 开始 forLogin=$forLogin user=${_user?.id ?? "null"}'); _inited = false; try { final user = _user; @@ -355,7 +375,11 @@ class DataProvider with EmitterMixin, Mutex { changeCallback(args: [true]); _markCoreDataRefreshed(); _scheduleSnapshotWrite(); + _scheduleLocalPageCacheWrite(); _notifyDataRefreshed(); + // 登录快速路径完成后,触发后台静默更新远程数据(非阻塞) + // load(skipDeepLoad: true) 跳过了 _refreshRemoteData,这里补上 + _chatProvider?.refreshRemote(reload: true); } else if (user != null) { // 完整重载路径(手动刷新) await user.deepLoad(reload: true); @@ -376,6 +400,7 @@ class DataProvider with EmitterMixin, Mutex { changeCallback(args: [true]); _markCoreDataRefreshed(); _scheduleSnapshotWrite(); + _scheduleLocalPageCacheWrite(); _notifyDataRefreshed(); } else { await Future.wait([ @@ -395,13 +420,16 @@ class DataProvider with EmitterMixin, Mutex { changeCallback(args: [true]); _markCoreDataRefreshed(); _scheduleSnapshotWrite(); + _scheduleLocalPageCacheWrite(); _notifyDataRefreshed(); } } catch (e, s) { var t = DateUtil.formatDate(DateTime.now(), format: "yyyy-MM-dd HH:mm:ss.SSS"); errInfo += '$t $e ==== $s'; - XLogUtil.e('>>>>>>[登录诊断] _refresh 异常: $e\n$s'); + final msg = '[$t] _refresh(forLogin=$forLogin) 异常: $e\n$s'; + XLogUtil.e('>>>>>>[登录诊断] $msg'); + SystemLog.err(msg, '数据刷新'); changeCallback(args: [false]); } finally { _inited = true; @@ -414,6 +442,9 @@ class DataProvider with EmitterMixin, Mutex { command.emitterFlag('session'); command.emitter('chat', 'new'); command.emitter('work', 'refresh'); + // 同时发射 flag 事件,确保 subscribeByFlag('work', ...) 订阅者能收到通知 + // (emitter 只通知 subscribe 订阅者,emitterFlag 只通知 subscribeByFlag 订阅者) + command.emitterFlag('work'); } /// 标记核心数据类型为已刷新(用于 _refresh/wakeUp 完成后) @@ -441,7 +472,8 @@ class DataProvider with EmitterMixin, Mutex { await BootstrapCoordinator().writeFullSnapshot(this).catchError((e) { //XLogUtil.e('快照写入失败: $e'); }); - }().whenComplete(() { + }() + .whenComplete(() { _pendingSnapshot = null; }); } @@ -459,6 +491,29 @@ class DataProvider with EmitterMixin, Mutex { }); } + /// ★5: 非阻塞调度一次完整数据落盘到 LocalPageCacheRepository。 + /// 与 BootstrapSnapshot(摘要)不同,这里落盘完整 JSON 数据, + /// 供各页面"本地存储优先 + 后台增量更新"策略读取。 + /// 复用 _pendingSnapshot 去重逻辑,避免并发写盘。 + void _scheduleLocalPageCacheWrite() { + final user = _user; + if (user == null) return; + final todos = work?.todos.toList() ?? []; + final userId = user.id; + final spaceId = user.currentSpace.id; + // fire-and-forget,避免阻塞 UI;失败不影响主流程 + Future.microtask(() async { + try { + await Future.wait([ + LocalPageCacheWriter.writeAll(user), + LocalPageCacheWriter.writeTodos(todos, userId, spaceId), + ]); + } catch (e) { + XLogUtil.w('[LocalPageCache] 落盘失败: $e'); + } + }); + } + void hydrateContext(BootstrapContextSnapshot snapshot) { final userMeta = XTarget.fromJson(snapshot.sessionUserSummary); final person = (_user == null || _user?.id != snapshot.userId) @@ -582,6 +637,7 @@ class DataProvider with EmitterMixin, Mutex { // 注意:无需 invalidateAll(写 0 后再写 now 等于空操作)。 _markCoreDataRefreshed(); _scheduleSnapshotWrite(); + _scheduleLocalPageCacheWrite(); _notifyDataRefreshed(); }); } @@ -601,8 +657,22 @@ class DataProvider with EmitterMixin, Mutex { /// 并发去重:同一 dataType 若已有刷新在跑,直接 await 同一 Future, /// 避免短时间内多次进入子页面触发请求风暴。 final Map> _pendingFreshens = {}; - Future ensureDataFresh(String dataType) async { - if (!DataFreshnessTracker.instance.shouldRefresh(dataType)) return; + Future ensureDataFresh(String dataType, {bool force = false}) async { + // 始终触发后台静默更新(非阻塞),确保在线数据变更能及时同步到本地 + _triggerSilentBackgroundRefresh(dataType); + + // 本地数据完整性检查:如果本地数据为空或不完整,强制同步刷新, + // 即使 TTL 未过期也必须在线获取,避免页面长时间空白。 + // 对齐 AGENTS.md §18.1:本地优先,但本地无数据时必须在线获取。 + if (!force) { + final isLocalEmpty = _isLocalDataEmpty(dataType); + if (isLocalEmpty) { + XLogUtil.i('>>>>>>[ensureDataFresh] $dataType 本地数据为空,强制在线获取'); + force = true; + } else if (!DataFreshnessTracker.instance.shouldRefresh(dataType)) { + return; + } + } // 单飞去重:同一类型若已有 Future 在跑,直接复用 final pending = _pendingFreshens[dataType]; if (pending != null) return pending; @@ -615,13 +685,92 @@ class DataProvider with EmitterMixin, Mutex { } } + /// 检查指定数据类型的本地数据是否为空或不完整 + /// + /// 返回 true 表示本地数据缺失,需要强制在线获取。 + /// 检查范围:L0 内存缓存(最快速),不检查 SQLite(避免 IO 阻塞)。 + /// 因为如果内存为空,说明 SQLite 也没被加载过(或加载失败)。 + bool _isLocalDataEmpty(String dataType) { + switch (dataType) { + case DataType.chats: + final chats = _chatProvider?.chats; + return chats == null || chats.isEmpty; + case DataType.work: + final todos = work?.todos; + return todos == null || todos.isEmpty; + case DataType.relation: + case DataType.storage: + case DataType.apps: + final user = _user; + if (user == null) return true; + // 关系数据完整性:members + cohorts + companys 至少有一项非空 + // (新注册用户可能只有 members,没有 companys/cohortes) + return user.members.isEmpty && + user.cohorts.isEmpty && + user.companys.isEmpty; + case DataType.activities: + // 动态数据:内存中可能未加载,但允许为空(新用户无动态) + // 不强制在线获取,依赖后台静默刷新 + return false; + case DataType.forms: + case DataType.plaza: + case DataType.iotDevices: + // 这些类型由各自仓储管理,不在 DataProvider 范围内 + return false; + default: + return false; + } + } + + /// 后台静默更新(fire-and-forget):不论数据是否新鲜,都发起一次远程拉取。 + /// 完成后通过 command 事件通知 UI 刷新,不阻塞调用方。 + void _triggerSilentBackgroundRefresh(String dataType) { + Future(() async { + try { + switch (dataType) { + case DataType.chats: + _chatProvider?.refreshRemote(reload: true); + break; + case DataType.work: + await work?.loadTodos(reload: true).then((_) {}).catchError((_) {}); + command.emitter('work', 'refresh'); + command.emitterFlag('work'); + break; + case DataType.relation: + case DataType.storage: + case DataType.apps: + if (_user != null) { + await _user!.deepLoad(reload: true).catchError((_) {}); + command.emitterFlag('session'); + } + break; + case DataType.activities: + await _chatProvider?.activityProvider + .load(reload: true) + .catchError((_) {}); + command.emitterFlag('session'); + break; + } + } catch (e, s) { + final msg = '后台静默刷新失败 [$dataType]: $e\n$s'; + XLogUtil.e(msg); + SystemLog.err(msg, '后台刷新[$dataType]'); + } + }); + } + Future _doEnsureDataFresh(String dataType) async { try { + // 刷新前记录数据指纹(§18.2 有更新才刷新) + final beforeFingerprint = _dataFingerprint(dataType); switch (dataType) { case DataType.chats: + // 先从内存快速重建(不阻塞),再后台拉取在线最新数据 await _chatProvider ?.load(reload: true, skipDeepLoad: true) .catchError((_) {}); + // 发起后台远端数据拉取(fire-and-forget,完成后 _notify 触发 chat/new 事件) + _chatProvider?.refreshRemote(reload: true); break; case DataType.work: await work?.loadTodos(reload: true).then((_) {}).catchError((_) {}); @@ -656,7 +805,14 @@ class DataProvider with EmitterMixin, Mutex { return; } DataFreshnessTracker.instance.markRefreshed(dataType); - // 触发对应的事件通知,让 CommandWidget 重建 + + // 数据指纹对比:无变化则不触发 UI 重建(§18.2 履约) + final afterFingerprint = _dataFingerprint(dataType); + if (beforeFingerprint != null && beforeFingerprint == afterFingerprint) { + return; + } + + // 有变化才触发对应的事件通知,让 CommandWidget 重建 switch (dataType) { case DataType.chats: command.emitterFlag('session'); @@ -664,6 +820,7 @@ class DataProvider with EmitterMixin, Mutex { break; case DataType.work: command.emitter('work', 'refresh'); + command.emitterFlag('work'); break; case DataType.relation: case DataType.storage: @@ -679,6 +836,41 @@ class DataProvider with EmitterMixin, Mutex { } } + /// 计算数据指纹:列表长度 + 每项 id:updateTime + /// 用于刷新前后对比,相同则不触发 UI 重建(§18.2) + String? _dataFingerprint(String dataType) { + switch (dataType) { + case DataType.chats: + final chats = _chatProvider?.chats; + if (chats == null) return null; + final buf = StringBuffer('${chats.length}'); + for (final c in chats) { + buf.write('|${c.id}:${c.chatdata.lastMsgTime}'); + } + return buf.toString(); + case DataType.work: + final todos = work?.todos; + if (todos == null) return null; + final buf = StringBuffer('${todos.length}'); + for (final t in todos) { + buf.write('|${t.id}:${t.taskdata.updateTime}'); + } + return buf.toString(); + case DataType.relation: + case DataType.storage: + case DataType.apps: + final user = _user; + if (user == null) return null; + return 'm${user.members.length}' + 'c${user.cohorts.length}' + 's${user.storages.length}' + 'a${user.agents.length}' + 'C${user.companys.length}'; + default: + return null; + } + } + Future stopWatch(String title, Future Function() callback) async { var startTime = DateTime.now().millisecondsSinceEpoch; try { diff --git a/lib/dart/core/provider/session.dart b/lib/dart/core/provider/session.dart index 96d7e6c069a30141404792c0ca110abd6855af35..0f80a3694e0772de259168a84f22c7ddbaa25257 100644 --- a/lib/dart/core/provider/session.dart +++ b/lib/dart/core/provider/session.dart @@ -3,8 +3,10 @@ import 'dart:convert'; import 'package:orginone/dart/base/common/commands.dart'; import 'package:orginone/dart/base/common/emitter.dart'; +import 'package:orginone/dart/base/common/systemError.dart'; import 'package:orginone/dart/base/model.dart'; import 'package:orginone/dart/base/schema.dart'; +import 'package:orginone/dart/base/storages/business_database.dart'; import 'package:orginone/dart/core/chat/activity.dart'; import 'package:orginone/dart/core/chat/session.dart'; import 'package:orginone/dart/core/public/enums.dart'; @@ -35,6 +37,9 @@ abstract class IChatProvider with EmitterMixin { /// [skipDeepLoad] 为 true 时跳过内部的 user/company deepLoad(调用方已加载过)。 Future load({bool reload = false, bool skipDeepLoad = false}); + /// 后台异步拉取在线最新数据(fire-and-forget,完成后通过 _notify 触发 chat/new 事件) + void refreshRemote({bool reload = false}); + ///唤醒(为了临时处理丢消息问题) Future wakeUp(); @@ -51,6 +56,7 @@ class ChatProvider with EmitterMixin implements IChatProvider { final IPerson user; /// 后台数据刷新完成回调(由 DataProvider 注册,用于触发快照写入) + @override void Function()? onDataRefreshed; ///会话数据 @@ -107,11 +113,28 @@ class ChatProvider with EmitterMixin implements IChatProvider { /// /// [skipDeepLoad] 为 true 时跳过内部的 user.deepLoad/company.deepLoad /// (调用方已在 _refresh 中通过 deepLoadForLogin/deepLoad 加载过,避免重复加载)。 + /// + /// 本地优先策略(AGENTS.md §18.1): + /// 1. 先从 SQLite L1 读取会话列表渲染首屏(< 100ms) + /// 2. 再后台 deepLoad 拉取最新在线数据 + /// 3. deepLoad 完成后用在线数据替换本地数据并刷新 UI @override Future load({bool reload = false, bool skipDeepLoad = false}) async { try { List tmpChats = []; if (!_inited || reload) { + // 本地优先:先从 SQLite L1 读取会话列表,快速渲染首屏 + // 避免 cold start 时等待 deepLoad 10s+ 才显示数据 + final localChats = await _loadChatsFromSQLite(); + if (localChats.isNotEmpty) { + tmpChats = localChats; + await Future.wait(tmpChats.map((s) => s.loadChatData())); + chats = _filterAndSortChats(tmpChats); + _refreshNoReadMgsCount(); + _notify(); + XLogUtil.i('>>>>>>[数据刷新] SQLite L1 优先渲染 ${chats.length} 条会话'); + } + if (!skipDeepLoad) { // 并行加载:用户个人数据 + 所有公司数据 final loadFutures = >[ @@ -134,10 +157,19 @@ class ChatProvider with EmitterMixin implements IChatProvider { }, ); } - // 收集所有会话 - tmpChats.addAll(user.chats); + // 收集所有会话(deepLoad 后的在线数据) + List onlineChats = []; + onlineChats.addAll(user.chats); for (var company in user.companys) { - tmpChats.addAll(company.chats); + onlineChats.addAll(company.chats); + } + // 在线数据非空:用在线数据替换本地数据 + // 在线数据为空且本地数据为空:保持空列表(首次登录或无会话) + if (onlineChats.isNotEmpty) { + tmpChats = onlineChats; + } else if (tmpChats.isEmpty) { + // 本地也为空,尝试再次从 SQLite 读取(deepLoad 可能填充了 memberChats) + tmpChats = await _loadChatsFromSQLite(); } // 统一加载会话消息缓存 await Future.wait(tmpChats.map((s) => s.loadChatData())); @@ -155,34 +187,158 @@ class ChatProvider with EmitterMixin implements IChatProvider { } } + /// 从 SQLite L1 恢复会话列表(离线兜底,AGENTS.md §18.1 L1 优先) + /// + /// 仅在 user.chats/company.chats 均为空时调用。通过 sessionId 在 + /// user/companys 的 memberChats 中查找 ISession 实例;找不到则跳过, + /// 等 deepLoad 完成后再恢复。 + Future> _loadChatsFromSQLite() async { + try { + final userId = user.metadata.id; + final rows = await BusinessDatabase.instance.querySessions( + belongId: userId, + ); + XLogUtil.i( + '>>>>>>[数据刷新] SQLite 查询会话 rows=${rows.length} 条, userId=$userId'); + if (rows.isEmpty) { + XLogUtil.w('>>>>>>[数据刷新] SQLite 中无会话数据'); + return []; + } + final sessions = []; + int notFoundCount = 0; + for (final row in rows) { + final sessionId = row['id'] as String?; + if (sessionId == null || sessionId.isEmpty) continue; + // 在 user.memberChats 中查找 + var session = user.findMemberChat(sessionId); + // 在 companys.memberChats 中查找 + if (session == null) { + for (final company in user.companys) { + session = company.findMemberChat(sessionId); + if (session != null) break; + } + } + if (session != null) { + sessions.add(session); + } else { + notFoundCount++; + } + } + XLogUtil.i( + '>>>>>>[数据刷新] 从 SQLite 恢复会话 ${sessions.length} 条 (未找到实例 $notFoundCount 条, memberChats=${user.memberChats.length})'); + return sessions; + } catch (e, s) { + final msg = '[ChatProvider] 从 SQLite 读取会话失败: $e\n$s'; + XLogUtil.e(msg); + SystemLog.err(msg, 'SQLite读取'); + return []; + } + } + + /// 后台异步刷新远程数据(公开接口,供 ensureDataFresh 调用) + @override + void refreshRemote({bool reload = false}) => + _refreshRemoteData(reload: reload); + + /// 防止并发刷新的标记 + bool _isRefreshingRemote = false; + /// 后台异步刷新远程数据 void _refreshRemoteData({bool reload = false}) { + // 去重:已有后台刷新在跑时,跳过本次(下次完成后自然会触发 _notify) + if (_isRefreshingRemote) { + XLogUtil.i('>>>>>>[数据刷新] _refreshRemoteData 跳过(已有刷新在跑)'); + return; + } + _isRefreshingRemote = true; + XLogUtil.i('>>>>>>[数据刷新] _refreshRemoteData 开始'); Future(() async { try { - // 刷新用户数据 - await user.deepLoad(reload: true).catchError((_) {}); - // 刷新所有公司数据 + // 刷新用户数据(添加超时控制,避免 SignalR 拦截时长时间卡住) + await user.deepLoad(reload: true).timeout( + const Duration(seconds: 30), + onTimeout: () { + XLogUtil.w('>>>>>>[数据刷新] user.deepLoad 超时(30s),使用已加载数据'); + return; + }, + ).catchError((e) { + XLogUtil.e('>>>>>>[数据刷新] user.deepLoad 失败: $e'); + }); + XLogUtil.i( + '>>>>>>[数据刷新] user.deepLoad 完成, cohorts=${user.cohorts.length}, storages=${user.storages.length}, agents=${user.agents.length}, companys=${user.companys.length}'); + // 刷新所有公司数据(添加超时控制) await Future.wait( - user.companys.map((c) => c.deepLoad(reload: true).catchError((_) {})), + user.companys.map((c) => c.deepLoad(reload: true).timeout( + const Duration(seconds: 30), + onTimeout: () { + XLogUtil.w( + '>>>>>>[数据刷新] company.deepLoad 超时(30s): ${c.name}'); + return; + }, + ).catchError((e) { + XLogUtil.e('>>>>>>[数据刷新] company.deepLoad 失败: ${c.name} - $e'); + })), ); + XLogUtil.i('>>>>>>[数据刷新] 所有 company.deepLoad 完成'); // 重新收集会话 List tmpChats = []; tmpChats.addAll(user.chats); for (var company in user.companys) { tmpChats.addAll(company.chats); } + XLogUtil.i('>>>>>>[数据刷新] 收集会话 ${tmpChats.length} 条'); await Future.wait(tmpChats.map((s) => s.loadChatData())); chats = _filterAndSortChats(tmpChats); _refreshNoReadMgsCount(); + XLogUtil.i( + '>>>>>>[数据刷新] 会话刷新完成 ${chats.length} 条, 未读 $noReadChatCount'); _notify(); + // 持久化会话到 SQLite L1(await 等待写入完成,避免空窗期) + await _persistSessionsToSQLite(tmpChats); // 通知 DataProvider 触发本地快照写入(长连接后台数据同步更新本地缓存) onDataRefreshed?.call(); - } catch (e) { - XLogUtil.e('后台刷新会话数据失败: $e'); + } catch (e, s) { + final msg = '后台刷新会话数据失败: $e\n$s'; + XLogUtil.e(msg); + SystemLog.err(msg, '会话刷新'); + } finally { + _isRefreshingRemote = false; } }); } + /// 持久化会话列表到 SQLite(AGENTS.md §6.2 / §18.3 持久化同刷) + /// + /// P2-3 修复:添加 Completer 同步机制,避免 fire-and-forget 空窗期 + /// - 关键数据(会话列表)使用 await 等待写入完成 + /// - 非关键数据保持 fire-and-forget + Future _persistSessionsToSQLite(List sessions) async { + if (sessions.isEmpty) return; + try { + final userId = user.metadata.id; + final rows = >[]; + for (final s in sessions) { + rows.add({ + 'id': s.id, + 'belong_id': userId, + 'space_id': s.target.id, + 'type_name': s.target.typeName, + 'name': s.target.name, + 'avatar': s.target.metadata.icon ?? '', + 'last_msg_time': s.chatdata.lastMsgTime, + 'last_message': s.chatdata.lastMessage?.content ?? '', + 'no_read_count': s.noReadCount, + 'update_time': DateTime.now().millisecondsSinceEpoch, + }); + } + await BusinessDatabase.instance.upsertSessions(rows); + } catch (e, s) { + final msg = '[ChatProvider] 持久化会话到 SQLite 失败: $e\n$s'; + XLogUtil.e(msg); + SystemLog.err(msg, 'SQLite写入'); + } + } + @override Future wakeUp() async { bool hasNewData = false; @@ -268,6 +424,9 @@ class ChatProvider with EmitterMixin implements IChatProvider { // activityProvider.load(reload: true); //XLogUtil.dd("唤醒:刷新未读数据$noReadChatCount"); _notify(); + // 持久化最新会话到 SQLite L1(AGENTS.md §6.2 / §18.3 持久化同刷) + // 长连接后台唤醒拉取的新数据必须同步写入本地,避免下次冷启动丢失 + await _persistSessionsToSQLite(chats); } else if (_inited) { load(); } @@ -494,7 +653,8 @@ class ActivityProvider with EmitterMixin implements IActivityProvider { changeCallback(); } - Future _safeLoadActivity(dynamic activity, {required bool reload}) async { + Future _safeLoadActivity(dynamic activity, + {required bool reload}) async { try { await activity.load(reload: reload); } catch (_) {} diff --git a/lib/dart/core/public/collection.dart b/lib/dart/core/public/collection.dart index 946d9f6e3b9123374361cbdbde7b4da19720d65a..0437f906a1885839ac64cb03ef97f9256b52dc59 100644 --- a/lib/dart/core/public/collection.dart +++ b/lib/dart/core/public/collection.dart @@ -40,9 +40,6 @@ class XCollection { int skip = 0, T Function(Map)? fromJson}) async { if (!_loaded || reload) { - if (skip == 0) { - _cache = []; - } var params = { 'skip': skip, 'take': 500, @@ -50,6 +47,11 @@ class XCollection { }; var res = await loadResult(params, fromJson); if (res.success) { + // 关键:远端成功后才清空旧 _cache 并填充新数据。 + // 远端失败时保留旧 _cache 作为兜底(避免空数据覆盖内存缓存)。 + if (skip == 0) { + _cache = []; + } if (null != res.data && res.data!.isNotEmpty) { _cache.addAll(res.data ?? []); if (res.data!.length == 500) { diff --git a/lib/dart/core/repository/flow_data_cache_service.dart b/lib/dart/core/repository/flow_data_cache_service.dart new file mode 100644 index 0000000000000000000000000000000000000000..e7eba97ed7d99162b717d41a5794b84bdf14d7ba --- /dev/null +++ b/lib/dart/core/repository/flow_data_cache_service.dart @@ -0,0 +1,315 @@ +import 'dart:convert'; + +import 'package:orginone/dart/base/model.dart'; +import 'package:orginone/dart/base/schema.dart'; +import 'package:orginone/dart/base/storages/business_database.dart'; +import 'package:orginone/dart/base/storages/file_system_cache_repository.dart'; +import 'package:orginone/utils/log/log_util.dart'; + +/// 流程数据三级缓存编排器(AGENTS.md §6.1 三级缓存) +/// +/// 统一协调流程相关数据的 L0 内存 / L1 SQLite / L2 文件系统 三级缓存。 +/// 业务类(Application / Form / Work)通过本服务享受三级缓存,无需关心底层细节。 +/// +/// 数据分布: +/// | 数据类型 | L0 内存 | L1 SQLite | L2 文件系统 | +/// |---------|---------|-----------|------------| +/// | 应用元数据 | Application.works/forms | applications 表 | - | +/// | 表单元数据+字段 | Form.fields | forms 表 | - | +/// | 办事节点定义 | Work.node | work_nodes 表(索引) | work_node/{id}.json | +/// | 待办/已办任务 | WorkProvider.todos | work_tasks 表 | - | +/// +/// 刷新策略:本地优先 + 后台刷新 + 推送增量(AGENTS.md §6.4) +class FlowDataCacheService { + FlowDataCacheService._(); + static final FlowDataCacheService _instance = FlowDataCacheService._(); + static FlowDataCacheService get instance => _instance; + + final FileSystemCacheRepository _fs = FileSystemCacheRepository.instance; + + // ============ Applications ============ + + /// 落盘应用元数据到 L1 SQLite(fire-and-forget,不阻塞 UI) + void cacheApplications( + String userId, + String spaceId, + List apps, + ) { + if (apps.isEmpty) return; + Future(() async { + try { + final now = DateTime.now().millisecondsSinceEpoch; + final rows = apps.map((app) { + final json = app.toJson(); + return { + 'id': app.id, + 'belong_id': userId, + 'space_id': spaceId, + 'name': app.name, + 'type_name': app.typeName ?? '', + 'data_json': jsonEncode(json), + 'content_hash': _hash(json), + 'update_time': now, + }; + }).toList(); + await BusinessDatabase.instance.upsertApplications(rows); + XLogUtil.i( + '[FlowCache] 应用元数据落盘 ${rows.length} 条 (space=$spaceId)'); + } catch (e, s) { + XLogUtil.e('[FlowCache] 应用元数据落盘失败: $e\n$s'); + } + }); + } + + /// 从 L1 SQLite 读取应用元数据(用于冷启动首屏) + Future> loadApplicationsFromCache( + String userId, + String spaceId, + ) async { + try { + final rows = await BusinessDatabase.instance.queryApplications( + belongId: userId, + spaceId: spaceId, + ); + if (rows.isEmpty) return []; + final apps = []; + for (final row in rows) { + final json = row['data_json'] as String?; + if (json == null || json.isEmpty) continue; + try { + apps.add(XApplication.fromJson( + Map.from(jsonDecode(json)))); + } catch (_) { + // 单条反序列化失败跳过 + } + } + XLogUtil.i( + '[FlowCache] 从 SQLite 恢复应用 ${apps.length} 条 (space=$spaceId)'); + return apps; + } catch (e) { + XLogUtil.w('[FlowCache] 从 SQLite 读应用失败: $e'); + return []; + } + } + + // ============ Forms(含字段定义) ============ + + /// 落盘表单元数据 + 字段到 L1 SQLite(fire-and-forget) + void cacheForms( + String userId, + String spaceId, + String applicationId, + List forms, + ) { + if (forms.isEmpty) return; + Future(() async { + try { + final now = DateTime.now().millisecondsSinceEpoch; + final rows = forms.map((form) { + final json = form.toJson(); + return { + 'id': form.id, + 'belong_id': userId, + 'space_id': spaceId, + 'application_id': applicationId, + 'name': form.name, + 'type_name': form.typeName ?? '', + 'data_json': jsonEncode(json), + 'fields_json': form.attributes != null + ? jsonEncode(form.attributes!.map((a) => a.toJson()).toList()) + : null, + 'content_hash': _hash(json), + 'update_time': now, + }; + }).toList(); + await BusinessDatabase.instance.upsertForms(rows); + XLogUtil.i( + '[FlowCache] 表单元数据落盘 ${rows.length} 条 (app=$applicationId)'); + } catch (e, s) { + XLogUtil.e('[FlowCache] 表单元数据落盘失败: $e\n$s'); + } + }); + } + + /// 从 L1 SQLite 按应用读取表单元数据(用于冷启动首屏) + Future> loadFormsFromCache( + String userId, + String spaceId, + String applicationId, + ) async { + try { + final rows = await BusinessDatabase.instance.queryForms( + belongId: userId, + spaceId: spaceId, + applicationId: applicationId, + ); + if (rows.isEmpty) return []; + final forms = []; + for (final row in rows) { + final json = row['data_json'] as String?; + if (json == null || json.isEmpty) continue; + try { + final form = XForm.fromJson( + Map.from(jsonDecode(json))); + // 还原 attributes + final fieldsJson = row['fields_json'] as String?; + if (fieldsJson != null && fieldsJson.isNotEmpty) { + final attrs = jsonDecode(fieldsJson); + if (attrs is List) { + form.attributes = attrs + .map((a) => XAttribute.fromJson(Map.from(a))) + .toList(); + } + } + forms.add(form); + } catch (_) { + // 单条反序列化失败跳过 + } + } + XLogUtil.i( + '[FlowCache] 从 SQLite 恢复表单 ${forms.length} 条 (app=$applicationId)'); + return forms; + } catch (e) { + XLogUtil.w('[FlowCache] 从 SQLite 读表单失败: $e'); + return []; + } + } + + // ============ Work Nodes(节点定义,大文档走 L2 文件系统) ============ + + /// 落盘办事节点定义到 L2 文件系统,并在 L1 SQLite 索引(fire-and-forget) + void cacheWorkNode( + String userId, + String spaceId, + String workId, + WorkNodeModel node, + ) { + Future(() async { + try { + final json = node.toJson(); + // L2 文件系统 + await _fs.write(userId, spaceId, 'work_node', workId, json); + // L1 SQLite 索引 + await BusinessDatabase.instance.upsertWorkNode({ + 'id': '${userId}_$workId', + 'belong_id': userId, + 'space_id': spaceId, + 'work_id': workId, + 'file_path': '$userId/$spaceId/work_node/$workId', + 'content_hash': _hash(json), + 'update_time': DateTime.now().millisecondsSinceEpoch, + }); + XLogUtil.i('[FlowCache] 节点定义落盘 work=$workId'); + } catch (e, s) { + XLogUtil.e('[FlowCache] 节点定义落盘失败: $e\n$s'); + } + }); + } + + /// 从 L2 文件系统读取办事节点定义(用于发起办事冷启动) + Future loadWorkNodeFromCache( + String userId, + String spaceId, + String workId, + ) async { + try { + final json = await _fs.read(userId, spaceId, 'work_node', workId); + if (json == null) return null; + final node = WorkNodeModel.fromJson(json); + XLogUtil.i('[FlowCache] 从文件系统恢复节点 work=$workId'); + return node; + } catch (e) { + XLogUtil.w('[FlowCache] 从文件系统读节点失败: $e'); + return null; + } + } + + // ============ Work Defines(办事定义列表,按应用组织) ============ + + /// 落盘某应用下的办事定义列表到 L2 文件系统(fire-and-forget) + void cacheWorkDefines( + String userId, + String spaceId, + String applicationId, + List defines, + ) { + if (defines.isEmpty) return; + Future(() async { + try { + final payload = { + 'defines': defines.map((d) => d.toJson()).toList(), + }; + await _fs.write(userId, spaceId, 'work_defines', applicationId, payload); + XLogUtil.i( + '[FlowCache] 办事定义落盘 ${defines.length} 条 (app=$applicationId)'); + } catch (e, s) { + XLogUtil.e('[FlowCache] 办事定义落盘失败: $e\n$s'); + } + }); + } + + /// 从 L2 文件系统读取某应用下的办事定义列表 + Future> loadWorkDefinesFromCache( + String userId, + String spaceId, + String applicationId, + ) async { + try { + final json = + await _fs.read(userId, spaceId, 'work_defines', applicationId); + if (json == null) return []; + final defines = json['defines']; + if (defines is! List) return []; + final result = []; + for (final item in defines) { + if (item is Map) { + try { + result.add(XWorkDefine.fromJson( + Map.from(item))); + } catch (_) {} + } + } + XLogUtil.i( + '[FlowCache] 从文件系统恢复办事定义 ${result.length} 条 (app=$applicationId)'); + return result; + } catch (e) { + XLogUtil.w('[FlowCache] 从文件系统读办事定义失败: $e'); + return []; + } + } + + // ============ 通用清理 ============ + + /// 登出清理当前用户所有缓存(L0 由各业务类自清理,L1+L2 在此清理) + Future clearByUser(String userId) async { + try { + await _fs.clearByUser(userId); + // SQLite 表通过 BusinessDatabase.clearAll() 统一清理 + await BusinessDatabase.instance.deleteWorkNodesByUser(userId); + XLogUtil.i('[FlowCache] clearByUser 完成 userId=$userId'); + } catch (e) { + XLogUtil.e('[FlowCache] clearByUser 失败: $e'); + } + } + + /// 切空间清理(L0 由各业务类自清理,L1+L2 在此清理) + Future clearBySpace(String userId, String spaceId) async { + try { + await _fs.clearBySpace(userId, spaceId); + XLogUtil.i('[FlowCache] clearBySpace 完成 userId=$userId space=$spaceId'); + } catch (e) { + XLogUtil.w('[FlowCache] clearBySpace 失败: $e'); + } + } + + // ============ 工具 ============ + + String _hash(dynamic json) { + try { + final encoded = jsonEncode(json); + return encoded.hashCode.toRadixString(16); + } catch (_) { + return DateTime.now().millisecondsSinceEpoch.toRadixString(16); + } + } +} diff --git a/lib/dart/core/repository/work_repository.dart b/lib/dart/core/repository/work_repository.dart index 5362ce91164a68b4c7c4250b523b49272d7a5310..f717e030c9f24551b61a5b7bd2ca0cfcec0b645e 100644 --- a/lib/dart/core/repository/work_repository.dart +++ b/lib/dart/core/repository/work_repository.dart @@ -1,5 +1,6 @@ import 'package:orginone/utils/log/log_util.dart'; import 'package:orginone/dart/base/common/commands.dart'; +import 'package:orginone/dart/core/repository/flow_data_cache_service.dart'; import 'package:orginone/dart/core/target/base/belong.dart'; import 'package:orginone/dart/core/target/base/target.dart'; import 'package:orginone/dart/core/target/base/team.dart'; @@ -161,6 +162,18 @@ class WorkRepository { XLogUtil.e('WorkRepository._loadAllContentForSpace: 加载文件失败: $e'); } + // 落盘应用元数据列表到 L1 SQLite(fire-and-forget) + // 用于冷启动时 Application.loadForms 等的 L1 兜底 + final uid = _currentUserId ?? ''; + final sid = space.id; + if (uid.isNotEmpty) { + FlowDataCacheService.instance.cacheApplications( + uid, + sid, + applications.map((a) => a.metadata).toList(), + ); + } + return SpaceContentResult( deptGroupItems: _deptGroupItems, applications: applications, @@ -219,6 +232,12 @@ class WorkRepository { _groupsLoaded = false; _deptGroupItems = []; _cache.clear(); + // 同步清理 L2 文件系统缓存(切空间) + final uid = _currentUserId; + final sid = _currentSpaceId; + if (uid != null && sid != null) { + FlowDataCacheService.instance.clearBySpace(uid, sid); + } } Future> _loadDeptGroupItems( @@ -304,9 +323,49 @@ class WorkRepository { final tb = b.metadata.createTime ?? ''; return tb.compareTo(ta); }); + // 远端返回空且无内存缓存时,回退到 L1 SQLite 兜底,避免数据核拉取失败导致门户空白 + if (apps.isEmpty && _currentUserId != null && _currentSpaceId != null) { + final cached = await _loadApplicationsFromCache(target); + if (cached.isNotEmpty) { + XLogUtil.i( + 'WorkRepository._loadApplications 远端为空,回退 L1 缓存 ${cached.length} 个应用'); + return cached; + } + } return apps; } catch (e) { XLogUtil.e('WorkRepository._loadApplications 失败: $e'); + // 异常时也尝试 L1 缓存兜底 + if (_currentUserId != null && _currentSpaceId != null) { + final cached = await _loadApplicationsFromCache(target); + if (cached.isNotEmpty) return cached; + } + return []; + } + } + + /// 从 L1 SQLite 读取应用元数据并构造 IApplication 列表(兜底用) + Future> _loadApplicationsFromCache(ITarget target) async { + try { + final xApps = await FlowDataCacheService.instance + .loadApplicationsFromCache(_currentUserId!, _currentSpaceId!); + if (xApps.isEmpty) return []; + // 过滤已删除 + 按 directoryId 匹配当前 target 的目录树 + final dirId = target.directory.id; + final filtered = xApps + .where((a) => !a.isDeleted && a.directoryId == dirId) + .toList(); + filtered.sort((a, b) { + final ta = a.createTime ?? ''; + final tb = b.createTime ?? ''; + return tb.compareTo(ta); + }); + return filtered + .map((a) => Application(a, target.directory)) + .cast() + .toList(); + } catch (e) { + XLogUtil.w('WorkRepository._loadApplicationsFromCache 失败: $e'); return []; } } diff --git a/lib/dart/core/target/base/team.dart b/lib/dart/core/target/base/team.dart index de6afd3e15bfc3312ce5037dff3276be6e5fb066..c0f16c30d332aec531eb0b5444f55f1dddfbc381 100644 --- a/lib/dart/core/target/base/team.dart +++ b/lib/dart/core/target/base/team.dart @@ -166,26 +166,27 @@ abstract class Team extends Entity implements ITeam { Future> loadMembers( {bool? reload = false, String? filter}) async { if (isMyTeam) { - if (reload! || (filter ?? '') != memberFilter) { - TargetResource.clear(id); - memberFilter = filter ?? ''; - } - // if (!memfinished) { - var part = await getPartMembers(members.length); - TargetResource.pullMembers(id, part.result, part.total); - for (var i in members) { - updateMetadata(i); - } - if (memberFilter == '') { - // _memberCount = part.total; - // LogUtil.dd('>>>>>> members $name $_memberCount $hashCode'); + if (!TargetResource.loaded(id) || + reload! || + (filter ?? '') != memberFilter) { + if (reload! || (filter ?? '') != memberFilter) { + TargetResource.clear(id); + memberFilter = filter ?? ''; + } + TargetResource.setLoaded(id); + var res = await getPartMembers(0); + TargetResource.pullMembers(id, res.result, res.total); + while (res.offset + res.limit < res.total) { + res.offset += res.limit; + var part = await getPartMembers(res.offset); + TargetResource.pullMembers(id, part.result, res.total); + } + for (var i in members) { + updateMetadata(i); + } + loadMemberChats(members, true); } - memberFilterCount = part.total; - // memfinished = part.total < 10; - // members.addAll(part.result); - // return members; - return part.result; - // } + return members; } return []; } diff --git a/lib/dart/core/target/person.dart b/lib/dart/core/target/person.dart index ad71e2785c002f121882ad3782a4ab9131787021..0ed3fedce74f38332d18864508e0fc13ab513c49 100644 --- a/lib/dart/core/target/person.dart +++ b/lib/dart/core/target/person.dart @@ -1,4 +1,3 @@ -import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:get/get.dart'; import 'package:orginone/dart/base/common/commands.dart'; import 'package:orginone/dart/base/enum.dart'; @@ -681,6 +680,7 @@ class Person extends Belong implements IPerson { /// - loadTeams / loadBlackList / loadSuperAuth / loadGivedIdentitys /// - currentSpace 二级递归 deepLoad(groups/depts/stations/cohorts/storages) /// - 完成后 emitter 通知页面刷新 + @override Future deepLoadForLogin({bool? reload = false}) async { final sw = Stopwatch()..start(); // 1. 缓存加载 + 单位列表并行(cacheObj.all 是 objectGet 网络请求) @@ -1145,14 +1145,13 @@ class Person extends Belong implements IPerson { ///加载数据 @override Future loadSpaceData(IBelong space) async { - EasyLoading.show(status: '查询中...', maskType: EasyLoadingMaskType.clear); + // 本地优先策略:不再 EasyLoading 阻塞 UI + // 调用方应以后台 fire-and-forget 方式调用本方法, + // 刷新完成由 command 事件通知 UI 重建 try { await space.deepLoad(reload: true); - // await superAuth?.deepLoad(reload: true); - } catch (e) { - EasyLoading.showError('加载失败'); - } finally { - EasyLoading.dismiss(); + } catch (_) { + // 静默失败,保留本地缓存 } } diff --git a/lib/dart/core/target/team/company.dart b/lib/dart/core/target/team/company.dart index 1a1d7570573319efc504aa96d0bd61b5d2d39f7b..d7446358d1256c63c3f24b9e24d422992c2a6edf 100644 --- a/lib/dart/core/target/team/company.dart +++ b/lib/dart/core/target/team/company.dart @@ -15,6 +15,7 @@ import 'package:orginone/dart/core/target/outTeam/cohort.dart'; import 'package:orginone/dart/core/target/outTeam/group.dart'; import 'package:orginone/dart/core/target/outTeam/istorage.dart'; import 'package:orginone/dart/core/target/person.dart'; +import 'package:orginone/dart/core/target/team/agent.dart'; import 'package:orginone/dart/core/thing/fileinfo.dart'; import 'package:orginone/dart/core/thing/standard/index_standart.dart'; import 'package:orginone/main.dart'; @@ -28,6 +29,9 @@ abstract class ICompany extends IBelong { //加入/管理的组织集群 late List groups; + //设立的集群(单位作为创建者的组织群) + late List establishedGroups; + //设立的岗位 late List stations; @@ -93,6 +97,8 @@ class Company extends Belong implements ICompany { @override List groups = []; @override + List establishedGroups = []; + @override List stations = []; @override List departments = []; @@ -115,7 +121,11 @@ class Company extends Belong implements ICompany { if (!_groupLoaded || reload) { final res = await kernel.queryJoinedTargetById(GetJoinedModel( id: metadata.id, - typeNames: [TargetType.group.label, TargetType.storage.label], + typeNames: [ + TargetType.group.label, + TargetType.storage.label, + TargetType.agent.label, + ], relationType: RelationType.member, page: pageAll, )); @@ -123,12 +133,16 @@ class Company extends Belong implements ICompany { _groupLoaded = true; storages = []; groups = []; + agents = []; for (var i in res.data?.result ?? []) { switch (TargetType.getType(i.typeName)) { case TargetType.storage: storages.add(IIStorage(i, [id], this)); break; + case TargetType.agent: + agents.add(Agent(i, this)); + break; default: groups.add(Group([key], i, [id], this)); } @@ -146,7 +160,8 @@ class Company extends Belong implements ICompany { subTypeNames: [ ...departmentTypes, TargetType.cohort.label, - TargetType.station.label + TargetType.station.label, + TargetType.group.label, ], page: pageAll, )); @@ -155,6 +170,7 @@ class Company extends Belong implements ICompany { departments = []; stations = []; cohorts = []; + establishedGroups = []; for (var i in (res.data?.result ?? [])) { switch (TargetType.getType(i.typeName)) { @@ -164,6 +180,9 @@ class Company extends Belong implements ICompany { case TargetType.station: stations.add(Station(i, this)); break; + case TargetType.group: + establishedGroups.add(Group([key], i, [id], this)); + break; default: departments.add(Department([key], i, this)); } @@ -397,6 +416,7 @@ class Company extends Belong implements ICompany { /// /// 包含:成员、群组、部门、公共数据、目录资源(首屏组织树渲染必需)。 /// 不包含:二级递归 deepLoad(groups/depts/stations 等各自的 deepLoad)。 + @override Future deepLoadForLogin({bool? reload = false}) async { final sw = Stopwatch()..start(); // 首屏必需(同步并行):成员/群组/部门 @@ -435,6 +455,7 @@ class Company extends Belong implements ICompany { /// /// 优化:分批执行,groups 优先(动态消息依赖),完成后立即通知页面刷新, /// 其他后台继续。避免 58s 全部完成后才刷新。 + @override Future deepLoadBackground({bool? reload = false}) async { final sw = Stopwatch()..start(); // 先加载权限数据(后台但优先级较高) @@ -461,6 +482,7 @@ class Company extends Belong implements ICompany { ...departments.map((e) => e.deepLoad(reload: reload).catchError((_) {})), ...stations.map((e) => e.deepLoad(reload: reload).catchError((_) {})), ...cohorts.map((e) => e.deepLoad(reload: reload).catchError((_) {})), + ...establishedGroups.map((e) => e.deepLoad(reload: reload).catchError((_) {})), superAuth?.deepLoad(reload: reload ?? false).catchError((_) {}) ?? Future.value(), ]); XLogUtil.i('>>>>>>[登录诊断] deepLoadBackground 全部完成: ${sw.elapsedMilliseconds}ms'); diff --git a/lib/dart/core/thing/directory.dart b/lib/dart/core/thing/directory.dart index 8771a078c47965a8dfaab356f75dec6b2147758e..baa56d35a2aa37b2219dc9e86b5778d54d2c3782 100644 --- a/lib/dart/core/thing/directory.dart +++ b/lib/dart/core/thing/directory.dart @@ -10,6 +10,7 @@ import 'package:orginone/dart/core/public/operates.dart'; import 'package:orginone/dart/core/target/base/target.dart'; import 'package:orginone/dart/core/target/outTeam/istorage.dart'; import 'package:orginone/dart/core/thing/fileinfo.dart'; +import 'package:orginone/utils/log/log_util.dart'; import 'package:orginone/dart/core/thing/resource.dart'; import 'package:orginone/dart/core/thing/standard/index.dart'; import 'package:orginone/dart/core/thing/standard/page.dart'; @@ -474,13 +475,20 @@ class Directory extends StandardFileInfo implements IDirectory { @override Future> loadAllApplication() async { - await deepLoadLazy(); - final List applications = [...standard.applications]; - - for (var item in children) { - applications.addAll(await item.loadAllApplication()); + // 与 react 端实现保持一致:先 loadAllDirectory 递归加载所有子目录, + // 再对每个目录调 standard.loadApplications() 聚合应用列表。 + // 这样 preLoad 和 loadDirectorys 只在顶层目录执行一次,避免重复请求。 + final applications = []; + final dirs = await loadAllDirectory(); + for (var item in dirs) { + // 跳过 MirrorDirectory 影子目录,避免 NoSuchMethodError + if (item is MirrorDirectory) continue; + try { + applications.addAll(await item.standard.loadApplications()); + } catch (e) { + XLogUtil.e('loadAllApplication 子目录加载失败: $e'); + } } - return applications; } @@ -495,12 +503,17 @@ class Directory extends StandardFileInfo implements IDirectory { @override Future> loadAllDirectory({bool reload = false}) async { - if (parent == null) { + // 顶层目录(parent 为 null 或 MirrorDirectory 影子目录)才触发 preLoad, + // 与 react 端 loadAllDirectory 的 `this.parent === undefined` 判断保持一致。 + final isTopLevel = parent == null || parent is MirrorDirectory; + if (isTopLevel) { await resource.preLoad(reload: reload); await standard.loadDirectorys(); } List directories = [this]; for (var child in children) { + // 跳过 MirrorDirectory 影子目录,避免 NoSuchMethodError + if (child is MirrorDirectory) continue; directories.addAll(await child.loadAllDirectory()); } return directories; @@ -611,14 +624,32 @@ class Directory extends StandardFileInfo implements IDirectory { return operates; } + /// 正在加载资源目录的 directoryId 集合(防递归循环引用/栈溢出) + /// 仅在加载过程中保留,加载完成后移除,不影响后续刷新。 + static final Set _loadingDirectoryIds = {}; + @override Future loadDirectoryResource({bool? reload = false}) async { - if (parent == null || reload == true) { - await resource.preLoad(reload: reload!); + // 环检测:若本 directory.id 正在加载(递归调用),跳过避免无限循环 + final dirId = directory.id; + if (_loadingDirectoryIds.contains(dirId)) { + return; + } + _loadingDirectoryIds.add(dirId); + try { + // 顶层目录(parent 为 null 或 MirrorDirectory 影子目录)才触发 preLoad 拉取资源, + // 子目录通过父目录的 standard.loadDirectorys() 递归加载,避免重复 preLoad。 + // 与 react 端 loadAllDirectory 的 `this.parent === undefined` 判断保持一致。 + final isTopLevel = parent == null || parent is MirrorDirectory; + if (isTopLevel || reload == true) { + await resource.preLoad(reload: reload ?? false); + } + await standard.loadApplications(); + await standard.loadDirectorys(); + await standard.loadTemplates(); + } finally { + _loadingDirectoryIds.remove(dirId); } - await standard.loadApplications(); - await standard.loadDirectorys(); - await standard.loadTemplates(); } ///对目录下所有资源进行操作 diff --git a/lib/dart/core/thing/standard/application.dart b/lib/dart/core/thing/standard/application.dart index 17ebe034fdf43c857a9c14b69b9698bf0e94cadd..0b2e9d46940f34b7697d499ef0fbf0ed045c7382 100644 --- a/lib/dart/core/thing/standard/application.dart +++ b/lib/dart/core/thing/standard/application.dart @@ -5,10 +5,12 @@ import 'package:orginone/dart/base/common/commands.dart'; import 'package:orginone/dart/base/model.dart'; import 'package:orginone/dart/base/schema.dart'; import 'package:orginone/dart/core/public/operates.dart'; +import 'package:orginone/dart/core/repository/flow_data_cache_service.dart'; import 'package:orginone/dart/core/thing/directory.dart'; import 'package:orginone/dart/core/thing/fileinfo.dart'; import 'package:orginone/dart/core/work/index.dart'; import 'package:orginone/main.dart'; +import 'package:orginone/utils/log/log_util.dart'; import 'form.dart'; @@ -190,25 +192,98 @@ class Application extends StandardFileInfo implements IApplication @override Future> loadWorks({bool reload = false}) async { - if (!_worksLoaded || works.isEmpty || reload) { - // var res = await kernel.queryWorkDefine( - // IdBelongPageModel(id: id, belongId: belongId, page: pageAll)); - final options = LoadOptions( - options: QueryOptions( - match: {'isDeleted': false, 'applicationId': id}, project: {'resource': 0}), - userData: [], - skip: 0, - take: 100, - ); - var res = await directory.resource.workDefineColl.loadResult2(options,XWorkDefine.fromJson); - if (res.success && res.data != null) { - _worksLoaded = true; - works = (res.data ?? []).map((a) => Work(a, this)).toList(); + // L0 内存命中 + if (_worksLoaded && works.isNotEmpty && !reload) { + return works; + } + + final userId = belongId; + final sid = spaceId; + + // L2 文件系统兜底(仅 reload=false 时尝试,让首屏有数据) + if (!reload && !_worksLoaded) { + try { + final cachedDefines = await FlowDataCacheService.instance + .loadWorkDefinesFromCache(userId, sid, id); + if (cachedDefines.isNotEmpty) { + works = cachedDefines.map((a) => Work(a, this)).toList(); + _worksLoaded = true; + // 后台 fire-and-forget 拉远端刷新 + _refreshWorksFromRemote(userId, sid); + return works; + } + } catch (e) { + XLogUtil.w('[Application.loadWorks] L2 缓存读取失败 app=$id: $e'); + } + } + + // 远端拉取(首次或 reload),失败时回退 L2 缓存(兜底) + try { + await _loadWorksFromRemote(userId, sid, reload: reload); + } catch (e) { + XLogUtil.w('[Application.loadWorks] 远端拉取失败 app=$id, 回退 L2 缓存: $e'); + if (works.isEmpty) { + try { + final cachedDefines = await FlowDataCacheService.instance + .loadWorkDefinesFromCache(userId, sid, id); + if (cachedDefines.isNotEmpty) { + works = cachedDefines.map((a) => Work(a, this)).toList(); + _worksLoaded = true; + } + } catch (e2) { + XLogUtil.w('[Application.loadWorks] L2 缓存回退失败 app=$id: $e2'); + } } } return works; } + /// 从远端拉取办事定义并落盘 L2 + Future _loadWorksFromRemote(String userId, String sid, + {bool reload = false}) async { + final options = LoadOptions( + options: QueryOptions( + match: {'isDeleted': false, 'applicationId': id}, + project: {'resource': 0}), + userData: [], + skip: 0, + take: 100, + ); + var res = await directory.resource.workDefineColl + .loadResult2(options, XWorkDefine.fromJson); + // 关键:远端失败(500/超时/网络异常)时抛异常,让 loadWorks 走 catch 回退 L2 缓存, + // 避免空数据覆盖内存缓存(原逻辑失败时啥也不做,works 保持空,UI 显示空列表) + if (!res.success) { + throw Exception( + 'loadResult2(workDefine) 失败 app=$id code=${res.code} msg=${res.msg}'); + } + if (res.data != null) { + _worksLoaded = true; + works = (res.data ?? []).map((a) => Work(a, this)).toList(); + // 落盘 L2 文件系统 + FlowDataCacheService.instance + .cacheWorkDefines(userId, sid, id, res.data ?? []); + } + } + + /// 后台刷新办事定义(本地优先策略,数量变化才更新 + 落盘) + void _refreshWorksFromRemote(String userId, String sid) { + Future(() async { + try { + final beforeCount = works.length; + final beforeIds = works.map((w) => w.id).toSet(); + await _loadWorksFromRemote(userId, sid, reload: true); + final afterIds = works.map((w) => w.id).toSet(); + if (works.length != beforeCount || !beforeIds.containsAll(afterIds)) { + structCallback(); + XLogUtil.i('[Application.loadWorks] 后台刷新办事有更新 app=$id'); + } + } catch (e) { + XLogUtil.w('[Application.loadWorks] 后台刷新失败 app=$id: $e'); + } + }); + } + @override Future createWork(WorkDefineModel data) async { data.applicationId = id; @@ -223,18 +298,117 @@ class Application extends StandardFileInfo implements IApplication @override Future> loadForms({bool reload = false}) async { - if (_formsLoaded == false || reload) { - _formsLoaded = true; - var data = await directory.resource.formColl.loadSpace1({ - "options": { - "match": {"applicationId": id} - }, - }, cvt: XForm.fromJson); - forms = data.map((i) => Form(i, directory)).toList(); + // L0 内存命中 + if (_formsLoaded && forms.isNotEmpty && !reload) { + return forms; + } + + final userId = belongId; + final sid = spaceId; + + // L1 SQLite 兜底(仅 reload=false 时尝试,让首屏有数据) + if (!reload && !_formsLoaded) { + try { + final cachedForms = await FlowDataCacheService.instance + .loadFormsFromCache(userId, sid, id); + if (cachedForms.isNotEmpty) { + forms = cachedForms.map((i) => Form(i, directory)).toList(); + _formsLoaded = true; + // 后台 fire-and-forget 拉远端刷新 + _refreshFormsFromRemote(userId, sid); + return forms; + } + } catch (e) { + XLogUtil.w('[Application.loadForms] L1 缓存读取失败 app=$id: $e'); + } + } + + // 远端拉取(首次或 reload),失败时回退 L1 缓存(兜底) + try { + await _loadFormsFromRemote(userId, sid, reload: reload); + } catch (e) { + XLogUtil.w('[Application.loadForms] 远端拉取失败 app=$id, 回退 L1 缓存: $e'); + if (forms.isEmpty) { + try { + final cachedForms = await FlowDataCacheService.instance + .loadFormsFromCache(userId, sid, id); + if (cachedForms.isNotEmpty) { + forms = cachedForms.map((i) => Form(i, directory)).toList(); + _formsLoaded = true; + } + } catch (e2) { + XLogUtil.w('[Application.loadForms] L1 缓存回退失败 app=$id: $e2'); + } + } } return forms; } + /// 从远端拉取表单并落盘 L1 + Future _loadFormsFromRemote(String userId, String sid, + {bool reload = false}) async { + // 关键:使用 loadResult 显式检查 success,避免 loadSpace1 在远端失败时静默返回空数组 + // 导致空数据覆盖内存缓存和持久化(loadSpace1 内部 catch 了 success=false 直接返回 []) + final res = await directory.resource.formColl.loadResult({ + "options": { + "match": {"applicationId": id} + }, + }, XForm.fromJson); + if (!res.success) { + throw Exception( + 'loadResult(form) 失败 app=$id code=${res.code} msg=${res.msg}'); + } + _formsLoaded = true; + final data = res.data ?? []; + forms = data.map((i) => Form(i, directory)).toList(); + // 落盘 L1 SQLite + FlowDataCacheService.instance.cacheForms(userId, sid, id, data); + } + + /// 后台刷新表单(本地优先策略,数量变化才更新 + 落盘) + /// 增量合并:保留旧 Form 实例(仅更新 metadata),新增远端新增的,删除远端删除的 + /// 避免用户当前查看的 Form 实例被替换导致 fields 状态丢失 + void _refreshFormsFromRemote(String userId, String sid) { + Future(() async { + try { + final beforeCount = forms.length; + final beforeIds = forms.map((f) => f.id).toSet(); + final data = await directory.resource.formColl.loadSpace1({ + "options": { + "match": {"applicationId": id} + }, + }, cvt: XForm.fromJson); + final afterIds = data.map((f) => f.id).toSet(); + if (data.length != beforeCount || + !beforeIds.containsAll(afterIds)) { + final remoteMap = {for (var f in data) f.id: f}; + // 删除远端已不存在的 + forms.removeWhere((f) => !remoteMap.containsKey(f.id)); + // 更新已存在的 metadata(保留旧实例,避免 fields 状态丢失),新增的追加 + for (final remoteForm in data) { + final idx = forms.indexWhere((f) => f.id == remoteForm.id); + if (idx >= 0) { + final existing = forms[idx]; + if (existing is Form) { + existing.setMetadata(remoteForm); + } else { + forms[idx] = Form(remoteForm, directory); + } + } else { + forms.add(Form(remoteForm, directory)); + } + } + // 落盘 L1 + FlowDataCacheService.instance.cacheForms(userId, sid, id, data); + structCallback(); + XLogUtil.i('[Application.loadForms] 后台刷新表单有更新 app=$id'); + } + } catch (e) { + XLogUtil.w('[Application.loadForms] 后台刷新失败 app=$id: $e'); + } + }); + } + @override Future> loadFormDataById(String id) async { // if (this.company) { diff --git a/lib/dart/core/thing/standard/form.dart b/lib/dart/core/thing/standard/form.dart index e66ffcf8d309040dd75816156244af97115e8e8c..51c64e953ac872e0c8ef7f90e1ae350c125e5dc8 100644 --- a/lib/dart/core/thing/standard/form.dart +++ b/lib/dart/core/thing/standard/form.dart @@ -6,6 +6,7 @@ import 'package:orginone/dart/base/schema.dart'; import 'package:orginone/dart/core/iot/iot_service.dart'; import 'package:orginone/dart/core/public/collection.dart'; import 'package:orginone/dart/core/public/consts.dart'; +import 'package:orginone/dart/core/repository/flow_data_cache_service.dart'; import 'package:orginone/dart/core/thing/directory.dart'; import 'package:orginone/dart/core/thing/fileinfo.dart'; import 'package:orginone/utils/log/log_util.dart'; @@ -107,73 +108,126 @@ class Form extends StandardFileInfo implements IForm { @override Future load({bool reload = false}) async { - if (!_attributeLoad || reload) { - if (metadata.primaryId != null && metadata.primaryId!.isNotEmpty) { - final attrs = await attributeColl!.loadSpace({ - 'options': { - 'match': { - 'formId': id, - 'isDeleted': false, - }, + // L0 内存命中 + if (_attributeLoad && !reload) { + return metadata; + } + + // L1 SQLite 兜底:metadata.attributes 已有数据(从 Application.loadForms 缓存恢复) + // 直接使用,后台刷新远端补全 speciesItems + if (!reload && + metadata.attributes != null && + metadata.attributes!.isNotEmpty) { + _attributeLoad = true; + _refreshAttributesFromRemote(); + return metadata; + } + + // 远端拉取(首次或 reload) + await _loadAttributesFromRemote(); + return metadata; + } + + /// 从远端拉取 attributes(含 speciesItems),原 load() 主逻辑 + Future _loadAttributesFromRemote() async { + if (metadata.primaryId != null && metadata.primaryId!.isNotEmpty) { + final attrs = await attributeColl!.loadSpace({ + 'options': { + 'match': { + 'formId': id, + 'isDeleted': false, }, - }, (json) => XAttribute.fromJson(json)); + }, + }, (json) => XAttribute.fromJson(json)); - metadata.attributes = attrs.map((a) { + metadata.attributes = attrs.map((a) { + return XAttribute.fromJson( + {...a.toJson(), 'id': a.primaryId ?? a.id}); + }).toList(); + + if (metadata.attributes!.isNotEmpty) { + setMetadata(metadata); + } + } else { + final data = await directory.resource.formColl + .find(ids: [id], fromJson: XForm.fromJson); + if (data.length == 1) { + metadata.attributes = data[0].attributes?.map((a) { return XAttribute.fromJson( - {...a.toJson(), 'id': a.primaryId ?? a.id}); + {...a.toJson(), 'formId': id, 'primaryId': a.id}); }).toList(); - if (metadata.attributes!.isNotEmpty) { - setMetadata(metadata); - } - } else { - final data = await directory.resource.formColl - .find(ids: [id], fromJson: XForm.fromJson); - if (data.length == 1) { - metadata.attributes = data[0].attributes?.map((a) { - return XAttribute.fromJson( - {...a.toJson(), 'formId': id, 'primaryId': a.id}); - }).toList(); - - final selectAttributes = metadata.attributes! + final selectAttributes = metadata.attributes! + .where( + (a) => + a.property != null && + ['选择型', '分类型'].contains(a.property!.valueType), + ) + .toList(); + + final speciesIds = selectAttributes + .map((a) => a.property!.speciesId) + .where((id) => id != null && id.isNotEmpty) + .toList() + .map((id) => id!) + .toList(); + + final speciesItems = await loadItems(speciesIds); + + for (var attribute in selectAttributes) { + final items = speciesItems .where( - (a) => - a.property != null && - ['选择型', '分类型'].contains(a.property!.valueType), + (a) => a.speciesId == attribute.property!.speciesId, ) .toList(); - final speciesIds = selectAttributes - .map((a) => a.property!.speciesId) - .where((id) => id != null && id.isNotEmpty) - .toList() - .map((id) => id!) - .toList(); - - final speciesItems = await loadItems(speciesIds); - - for (var attribute in selectAttributes) { - final items = speciesItems - .where( - (a) => a.speciesId == attribute.property!.speciesId, - ) - .toList(); + attribute.property = XProperty.fromJson({ + ...attribute.property!.toJson(), + 'species': { + ...attribute.property!.species?.toJson() ?? {}, + 'speciesItems': items.map((e) => e.toJson()).toList(), + }, + }); + } - attribute.property = XProperty.fromJson({ - ...attribute.property!.toJson(), - 'species': { - ...attribute.property!.species?.toJson() ?? {}, - 'speciesItems': items.map((e) => e.toJson()).toList(), - }, - }); - } + setMetadata(metadata); + } + } + _attributeLoad = true; + } - setMetadata(metadata); + /// 后台刷新 attributes(本地优先策略,补全 speciesItems) + void _refreshAttributesFromRemote() { + Future(() async { + try { + final beforeIds = (metadata.attributes ?? []) + .map((a) => a.id) + .toSet(); + await _loadAttributesFromRemote(); + final afterIds = (metadata.attributes ?? []) + .map((a) => a.id) + .toSet(); + // 有变化才通知刷新 + 落盘 + if (beforeIds.length != afterIds.length || + !beforeIds.containsAll(afterIds)) { + // 重置 _fieldsLoaded,让 fields 基于最新 attributes 重新生成 + // (load(reload=false) 在 _attributeLoad=true 时直接返回,不重复拉远端) + _fieldsLoaded = false; + await loadFields(reload: false); + directory.changeCallback(); + // 落盘 L1 + FlowDataCacheService.instance.cacheForms( + belongId, + spaceId, + metadata.applicationId ?? '', + [metadata], + ); + XLogUtil.i('[Form.load] 后台刷新 attributes 有更新 form=$id'); } + } catch (e) { + XLogUtil.w('[Form.load] 后台刷新失败 form=$id: $e'); } - _attributeLoad = true; - } - return metadata; + }); } @override diff --git a/lib/dart/core/work/index.dart b/lib/dart/core/work/index.dart index bd7909e0aedcb296799e3c9ae75e2ea1a9aa90fd..a301a1a4f085912ecd1b92f8846f1ae0636013b7 100644 --- a/lib/dart/core/work/index.dart +++ b/lib/dart/core/work/index.dart @@ -1,14 +1,17 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:orginone/dart/base/model.dart'; import 'package:orginone/dart/base/schema.dart'; import 'package:orginone/dart/core/public/operates.dart'; +import 'package:orginone/dart/core/repository/flow_data_cache_service.dart'; import 'package:orginone/dart/core/thing/standard/application.dart'; import 'package:orginone/dart/core/thing/directory.dart'; import 'package:orginone/dart/core/thing/fileinfo.dart'; import 'package:orginone/dart/core/thing/standard/form.dart'; import 'package:orginone/main.dart'; +import 'package:orginone/utils/log/log_util.dart'; import 'apply.dart'; @@ -196,43 +199,132 @@ class Work extends FileInfo implements IWork { @override Future loadWorkNode({bool reload = false}) async { - if (node == null || reload) { - final res = - await kernel.queryWorkNodes({'id': id, 'shareId': metadata.shareId}); - if (res.success) { + // L0 内存命中 + if (node != null && !reload) { + return node; + } + + final userId = belongId; + final sid = spaceId; + + // L2 文件系统兜底(仅 reload=false 时尝试,让调用方有数据可用) + WorkNodeModel? cachedNode; + if (!reload && node == null) { + try { + cachedNode = await FlowDataCacheService.instance + .loadWorkNodeFromCache(userId, sid, id); + if (cachedNode != null) { + node = cachedNode; + await recursionForms(node!); + // 后台 fire-and-forget 拉远端刷新(有变化才更新 + 落盘) + _refreshWorkNodeFromRemote(userId, sid); + return node; + } + } catch (e) { + XLogUtil.w('[Work.loadWorkNode] L2 缓存读取失败 work=$id: $e'); + } + } + + // 远端拉取(首次或 reload)- 添加超时控制 + try { + final res = await kernel + .queryWorkNodes({'id': id, 'shareId': metadata.shareId}).timeout( + const Duration(seconds: 15), onTimeout: () { + XLogUtil.w('[Work.loadWorkNode] 远端查询超时 work=$id, 尝试使用缓存'); + throw TimeoutException('queryWorkNodes 超时'); + }); + if (res.success && res.data != null) { node = res.data; await recursionForms(node!); + // 落盘 L2 文件系统 + FlowDataCacheService.instance.cacheWorkNode(userId, sid, id, node!); + } else { + // 远端返回失败或空数据,尝试使用缓存 + if (cachedNode != null) { + XLogUtil.w('[Work.loadWorkNode] 远端返回失败,使用缓存 work=$id'); + node = cachedNode; + await recursionForms(node!); + } + } + } catch (e) { + XLogUtil.w('[Work.loadWorkNode] 远端查询异常 work=$id: $e'); + // 网络异常时使用缓存兜底 + if (cachedNode != null) { + XLogUtil.i('[Work.loadWorkNode] 使用缓存兜底 work=$id'); + node = cachedNode; + await recursionForms(node!); } } return node; } + /// 后台刷新节点定义(本地优先策略,hash 比对有变化才更新 + 落盘) + void _refreshWorkNodeFromRemote(String userId, String sid) { + Future(() async { + try { + final res = await kernel + .queryWorkNodes({'id': id, 'shareId': metadata.shareId}); + if (res.success && res.data != null) { + final remote = res.data!; + // 简单比对:节点 id + forms 数量 + 字段数 + final remoteKey = '${remote.id ?? ''}_${remote.forms?.length ?? 0}'; + final localKey = '${node?.id ?? ''}_${node?.forms?.length ?? 0}'; + if (remoteKey != localKey) { + node = remote; + await recursionForms(node!); + FlowDataCacheService.instance.cacheWorkNode(userId, sid, id, node!); + XLogUtil.i('[Work.loadWorkNode] 后台刷新节点有更新 work=$id'); + } + } + } catch (e) { + XLogUtil.w('[Work.loadWorkNode] 后台刷新失败 work=$id: $e'); + } + }); + } + @override Future createApply() async { - await loadWorkNode(); - if (node != null && forms.isNotEmpty) { - final InstanceDataModel data = InstanceDataModel(node: node!, rules: [] - // allowAdd: metadata.allowAdd, - // allowEdit: metadata.allowEdit, - // allowSelect: metadata.allowSelect, - ); - await Future.wait( - forms.map((form) async { - await form.loadContent(); - data.fields?[form.id] = form.fields; - }).toList(), - ); - return WorkApply( - WorkInstanceModel( - hook: '', - taskId: '0', - title: metadata.name, - defineId: id, - ), - data, - directory.target.space!, - forms, - ); + try { + await loadWorkNode(); + if (node != null && forms.isNotEmpty) { + final InstanceDataModel data = InstanceDataModel(node: node!, rules: [] + // allowAdd: metadata.allowAdd, + // allowEdit: metadata.allowEdit, + // allowSelect: metadata.allowSelect, + ); + await Future.wait( + forms.map((form) async { + try { + await form.loadContent().timeout(const Duration(seconds: 10), + onTimeout: () { + XLogUtil.w( + '[Work.createApply] form.loadContent 超时 form=${form.id}'); + return false; + }); + } catch (e) { + XLogUtil.w( + '[Work.createApply] form.loadContent 异常 form=${form.id}: $e'); + } + data.fields?[form.id] = form.fields; + }).toList(), + ); + return WorkApply( + WorkInstanceModel( + hook: '', + taskId: '0', + title: metadata.name, + defineId: id, + ), + data, + directory.target.space!, + forms, + ); + } else { + XLogUtil.w( + '[Work.createApply] node 或 forms 为空 work=$id, node=${node != null}, forms=${forms.length}'); + } + } catch (e) { + XLogUtil.e('[Work.createApply] 异常 work=$id: $e'); } return null; } diff --git a/lib/dart/core/work/provider.dart b/lib/dart/core/work/provider.dart index f2ce7a5f3c4ea28b5533071a2df1e759d85c323c..5acf79b1cd8b707e39f2a64c8ddc99f59c43f9fe 100644 --- a/lib/dart/core/work/provider.dart +++ b/lib/dart/core/work/provider.dart @@ -1,14 +1,18 @@ import 'dart:async'; +import 'dart:convert'; import 'package:get/get.dart'; import 'package:orginone/dart/base/common/commands.dart'; import 'package:orginone/dart/base/common/emitter.dart'; +import 'package:orginone/dart/base/common/systemError.dart'; import 'package:orginone/dart/base/model.dart'; import 'package:orginone/dart/base/schema.dart'; +import 'package:orginone/dart/base/storages/business_database.dart'; import 'package:orginone/dart/core/public/consts.dart'; import 'package:orginone/dart/core/public/enums.dart'; import 'package:orginone/dart/core/provider/index.dart'; import 'package:orginone/main.dart'; +import 'package:orginone/utils/log/log_util.dart'; import 'task.dart'; @@ -72,6 +76,10 @@ class WorkProvider with EmitterMixin implements IWorkProvider { String get userId => user.user?.id ?? ""; bool _todoLoaded = false; bool finished = false; + /// 各任务类型的内存缓存(L0),key = TaskType.label + final Map> _tasksCacheByType = {}; + /// emitterFlag('work') 去抖计时器(合并并发后台刷新触发的 UI 重建) + Timer? _emitWorkDebounce; // @override /// 每页条数 int pageSize = 20; @@ -96,7 +104,9 @@ class WorkProvider with EmitterMixin implements IWorkProvider { } // 发出刷新命令,通知待办列表页面刷新 + // 同时发射 emitter 和 emitterFlag,确保 subscribe 和 subscribeByFlag 订阅者都能收到 command.emitter('work', 'refresh'); + command.emitterFlag('work'); // if (task.status != TaskStatus.approvalStart.status) { // if (index < 0) { @@ -124,7 +134,31 @@ class WorkProvider with EmitterMixin implements IWorkProvider { @override Future> loadTodos({bool reload = false}) async { - if (!_todoLoaded || reload) { + // L0 内存命中(非 reload) + if (_todoLoaded && !reload) { + return todos; + } + + final uid = userId; + final sid = user.user?.currentSpace.id ?? 'personal'; + + // L1 SQLite 兜底(仅 reload=false 且内存未命中时尝试,让冷启动首屏有数据) + if (!reload && !_todoLoaded) { + try { + await _loadWorkTasksFromSQLite(); + if (todos.isNotEmpty) { + // 后台 fire-and-forget 拉远端刷新(hash 比对有变化才更新 + 落盘) + _refreshTodosFromRemote(uid, sid); + return todos; + } + } catch (e) { + XLogUtil.w('>>>>>>[数据刷新] loadTodos L1 缓存读取失败: $e'); + } + } + + // 远端拉取(首次或 reload 或 SQLite 为空) + XLogUtil.i('>>>>>>[数据刷新] WorkProvider.loadTodos 开始 reload=$reload'); + try { final res = await kernel.queryApproveTask(IdModel('0')); if (res.success) { _todoLoaded = true; @@ -132,91 +166,336 @@ class WorkProvider with EmitterMixin implements IWorkProvider { todos.addAll((res.data?.result ?? []) .map((task) => WorkTask(task, user)) .toList()); - // todos.refresh(); + XLogUtil.i( + '>>>>>>[数据刷新] WorkProvider.loadTodos 成功 ${todos.length} 条'); changeCallback(); + // 持久化到 SQLite L1(AGENTS.md §6.2 / §18.3 持久化同刷) + _persistWorkTasksToSQLite(); + } else { + XLogUtil.w('>>>>>>[数据刷新] WorkProvider.loadTodos 返回失败,尝试从 SQLite 读取'); + await _loadWorkTasksFromSQLite(); } + } catch (e, s) { + final msg = 'WorkProvider.loadTodos 异常: $e\n$s'; + XLogUtil.e('>>>>>>[数据刷新] $msg, 尝试从 SQLite 读取'); + SystemLog.err(msg, '待办加载'); + await _loadWorkTasksFromSQLite(); } return todos; } + /// emitterFlag('work') 去抖:合并并发后台刷新触发的多次 UI 重建为一次 + /// (用户切 Tab 时 loadTodos + loadTasks 后台刷新几乎同时完成,避免重复 emitterFlag) + void _debounceEmitWork() { + _emitWorkDebounce?.cancel(); + _emitWorkDebounce = Timer(const Duration(milliseconds: 100), () { + command.emitterFlag('work'); + }); + } + + /// 后台刷新待办(本地优先策略,hash 比对有变化才更新 + 落盘) + void _refreshTodosFromRemote(String uid, String sid) { + Future(() async { + try { + final beforeIds = todos.map((t) => t.id).toSet(); + final res = await kernel.queryApproveTask(IdModel('0')); + if (res.success) { + final remoteTasks = (res.data?.result ?? []) + .map((task) => WorkTask(task, user)) + .toList(); + final afterIds = remoteTasks.map((t) => t.id).toSet(); + if (beforeIds.length != afterIds.length || + !beforeIds.containsAll(afterIds)) { + todos.clear(); + todos.addAll(remoteTasks); + _todoLoaded = true; + changeCallback(); + _persistWorkTasksToSQLite(); + _debounceEmitWork(); + XLogUtil.i('>>>>>>[数据刷新] 待办后台刷新有更新'); + } + } + } catch (e) { + XLogUtil.w('>>>>>>[数据刷新] 待办后台刷新失败: $e'); + } + }); + } + + /// 持久化待办任务到 SQLite(fire-and-forget,不阻塞 UI) + void _persistWorkTasksToSQLite() { + if (todos.isEmpty) return; + Future(() async { + try { + final userId = this.userId; + final spaceId = user.user?.currentSpace.id ?? 'personal'; + final rows = >[]; + for (final t in todos) { + final taskData = t.taskdata; + rows.add({ + 'id': taskData.id, + 'belong_id': userId, + 'space_id': spaceId, + 'task_type': '待办', + 'title': taskData.title ?? '', + 'content': jsonEncode(taskData.toJson()), + 'status': '${taskData.status ?? 0}', + 'create_time': _parseTime(taskData.createTime), + 'update_time': _parseTime(taskData.updateTime), + }); + } + await BusinessDatabase.instance.upsertWorkTasks(rows); + XLogUtil.i('>>>>>>[数据刷新] 待办任务持久化到 SQLite ${rows.length} 条'); + } catch (e, s) { + final msg = '[WorkProvider] 持久化待办到 SQLite 失败: $e\n$s'; + XLogUtil.e(msg); + SystemLog.err(msg, 'SQLite写入'); + } + }); + } + + /// 从 SQLite 读取待办任务(网络失败时的本地兜底) + Future _loadWorkTasksFromSQLite() async { + try { + final rows = await BusinessDatabase.instance.queryWorkTasks( + belongId: userId, + taskType: '待办', + ); + if (rows.isEmpty) { + XLogUtil.w('>>>>>>[数据刷新] SQLite 中无待办数据'); + return; + } + final tasks = []; + for (final row in rows) { + final contentJson = row['content'] as String?; + if (contentJson == null || contentJson.isEmpty) continue; + try { + final taskData = XWorkTask.fromJson( + Map.from(jsonDecode(contentJson))); + tasks.add(WorkTask(taskData, user)); + } catch (e, s) { + final msg = '[WorkProvider] 待办反序列化失败: $e\n$s'; + XLogUtil.e(msg); + SystemLog.err(msg, 'SQLite读取'); + } + } + if (tasks.isNotEmpty) { + todos.clear(); + todos.addAll(tasks); + _todoLoaded = true; + changeCallback(); + XLogUtil.i('>>>>>>[数据刷新] 从 SQLite 恢复待办 ${tasks.length} 条'); + } + } catch (e, s) { + final msg = '[WorkProvider] 从 SQLite 读取待办失败: $e\n$s'; + XLogUtil.e(msg); + SystemLog.err(msg, 'SQLite读取'); + } + } + + /// 安全解析时间字符串为毫秒时间戳 + int _parseTime(String? timeStr) { + if (timeStr == null || timeStr.isEmpty) return 0; + try { + return DateTime.parse(timeStr).millisecondsSinceEpoch; + } catch (_) { + return 0; + } + } + @override void hydrate(Map summary) { final rawTodos = summary['todos']; if (rawTodos is List) { todos.clear(); todos.addAll(rawTodos - .map((item) => XWorkTask.fromJson(Map.from(item as Map))) + .map((item) => + XWorkTask.fromJson(Map.from(item as Map))) .map((task) => WorkTask(task, user))); _todoLoaded = todos.isNotEmpty; changeCallback(); } } - Future> loadTasks(TaskType type, [bool reload = false, int take = 10]) async { + Future> loadTasks(TaskType type, + [bool reload = false, int take = 10]) async { + final cacheKey = type.label; + + // L0 内存命中 + if (!reload && _tasksCacheByType.containsKey(cacheKey)) { + return _tasksCacheByType[cacheKey]!; + } + + final uid = userId; + final sid = user.user?.currentSpace.id ?? 'personal'; + + // L1 SQLite 兜底(仅 reload=false 时尝试,让首屏有数据) + if (!reload) { + try { + final cached = await _loadTasksByTypeFromSQLite(type, uid); + if (cached.isNotEmpty) { + _tasksCacheByType[cacheKey] = cached; + // 后台 fire-and-forget 拉远端刷新 + _refreshTasksByTypeFromRemote(type, uid, sid, take); + return cached; + } + } catch (e) { + XLogUtil.w( + '>>>>>>[数据刷新] loadTasks L1 缓存读取失败 type=$cacheKey: $e'); + } + } + + // 远端拉取(首次或 reload) + try { + final tasks = await _loadTasksFromRemote(type, take); + _tasksCacheByType[cacheKey] = tasks; + _persistTasksByType(type, tasks, uid, sid); + return tasks; + } catch (e, s) { + final msg = 'WorkProvider.loadTasks(${type.label}) 远端异常: $e\n$s'; + XLogUtil.e('>>>>>>[数据刷新] $msg, 尝试从 SQLite 读取'); + SystemLog.err(msg, '后台加载'); + // 远端异常时从 L1 SQLite 读取兜底 + final cached = await _loadTasksByTypeFromSQLite(type, uid); + if (cached.isNotEmpty) { + _tasksCacheByType[cacheKey] = cached; + return cached; + } + rethrow; + } + } + + /// 从远端拉取任务(原 loadTasks 主逻辑) + Future> _loadTasksFromRemote(TaskType type, int take) async { List tasks = []; - // if (reload) { - // tasks = []; - // } - // int skip = tasks.length; - // if (!finished) { - var result = await kernel.collectionLoad>( - // '445635922516643840', // PC的id - userId, - userId, - [], - TaskCollName, - /*(type.label == '草稿') ? { - 'userData': [], - 'options': { - 'match': { - 'createUser': userId, - 'status': { - '_gte_': 100, - }, - 'nodeId': { - '_exists_': false, - }, - }, - 'sort': { - 'createTime': -1, - }, - 'project': { - 'data': 0 - } - }, - 'skip': 0, - 'take': 1000, - } : */{ - 'options': { - 'match': _typeMatch(type), - 'sort': { - 'createTime': -1, - }, + var result = await kernel.collectionLoad>( + userId, + userId, + [], + TaskCollName, + { + 'options': { + 'match': _typeMatch(type), + 'sort': { + 'createTime': -1, }, - 'skip': 0, - 'take': 1000, //pageSize, - }, - fromJson: (data) { - return XWorkTask.fromList(data['data'] is List ? data['data'] : []); }, + 'skip': 0, + 'take': 1000, + }, + fromJson: (data) { + return XWorkTask.fromList(data['data'] is List ? data['data'] : []); + }, + ); + // 关键:远端失败(500/超时/网络异常)时 result.success=false,必须抛异常, + // 让 loadTasks 走 catch 路径回退 L1 SQLite,避免空数据覆盖内存缓存和持久化 + if (!result.success) { + throw Exception( + 'collectionLoad(${type.label}) 失败: code=${result.code} msg=${result.msg}'); + } + if (result.success && result.data != null && result.data!.isNotEmpty) { + result.data?.forEach((item) { + if (tasks.every((i) => i.id != item.id)) { + tasks.add(WorkTask(item, user)); + } + }); + } + tasks = tasks.where((i) => i.isTaskType(type)).toList(); + tasks.sort((a, b) { + return DateTime.parse(b.metadata.updateTime ?? "") + .compareTo(DateTime.parse(a.metadata.updateTime ?? "")); + }); + return tasks; + } + + /// 持久化某类型任务到 L1 SQLite(fire-and-forget) + void _persistTasksByType( + TaskType type, List tasks, String uid, String sid) { + if (tasks.isEmpty) return; + Future(() async { + try { + final rows = tasks.map((t) { + final taskData = t.taskdata; + return { + 'id': taskData.id, + 'belong_id': uid, + 'space_id': sid, + 'task_type': type.label, + 'title': taskData.title ?? '', + 'content': jsonEncode(taskData.toJson()), + 'status': '${taskData.status ?? 0}', + 'create_time': _parseTime(taskData.createTime), + 'update_time': _parseTime(taskData.updateTime), + }; + }).toList(); + await BusinessDatabase.instance.upsertWorkTasks(rows); + XLogUtil.i( + '>>>>>>[数据刷新] ${type.label}任务持久化到 SQLite ${rows.length} 条'); + } catch (e, s) { + final msg = '[WorkProvider] 持久化${type.label}到 SQLite 失败: $e\n$s'; + XLogUtil.e(msg); + SystemLog.err(msg, 'SQLite写入'); + } + }); + } + + /// 从 L1 SQLite 读取某类型任务(本地兜底) + Future> _loadTasksByTypeFromSQLite( + TaskType type, String uid) async { + try { + final rows = await BusinessDatabase.instance.queryWorkTasks( + belongId: uid, + taskType: type.label, ); - // List taskDatas = []; - if (result.success && result.data != null && result.data!.isNotEmpty) { - result.data?.forEach((item) { - if (tasks.every((i) => i.id != item.id)) { - tasks.add(WorkTask(item, user)); - } - }); + if (rows.isEmpty) return []; + final tasks = []; + for (final row in rows) { + final contentJson = row['content'] as String?; + if (contentJson == null || contentJson.isEmpty) continue; + try { + final taskData = XWorkTask.fromJson( + Map.from(jsonDecode(contentJson))); + tasks.add(WorkTask(taskData, user)); + } catch (e, s) { + final msg = '[WorkProvider] ${type.label}反序列化失败: $e\n$s'; + XLogUtil.e(msg); + SystemLog.err(msg, 'SQLite读取'); + } } - tasks.where((i) => i.isTaskType(type)).toList(); tasks.sort((a, b) { - return DateTime.parse(b.metadata.updateTime ?? "") - .compareTo(DateTime.parse(a.metadata.updateTime ?? "")); + return DateTime.parse(b.metadata.updateTime ?? "") + .compareTo(DateTime.parse(a.metadata.updateTime ?? "")); }); - // finished = taskDatas.length < 10; - // tasks.addAll(taskDatas); - // } - return tasks; + XLogUtil.i( + '>>>>>>[数据刷新] 从 SQLite 恢复${type.label} ${tasks.length} 条'); + return tasks; + } catch (e, s) { + final msg = '[WorkProvider] 从 SQLite 读${type.label}失败: $e\n$s'; + XLogUtil.e(msg); + SystemLog.err(msg, 'SQLite读取'); + return []; + } + } + + /// 后台刷新某类型任务(本地优先策略,hash 比对有变化才更新 + 落盘) + void _refreshTasksByTypeFromRemote( + TaskType type, String uid, String sid, int take) { + Future(() async { + try { + final beforeIds = + (_tasksCacheByType[type.label] ?? []).map((t) => t.id).toSet(); + final tasks = await _loadTasksFromRemote(type, take); + final afterIds = tasks.map((t) => t.id).toSet(); + _tasksCacheByType[type.label] = tasks; + if (beforeIds.length != afterIds.length || + !beforeIds.containsAll(afterIds)) { + _persistTasksByType(type, tasks, uid, sid); + _debounceEmitWork(); + XLogUtil.i('>>>>>>[数据刷新] ${type.label}后台刷新有更新'); + } + } catch (e) { + XLogUtil.w('>>>>>>[数据刷新] ${type.label}后台刷新失败: $e'); + } + }); } @override diff --git a/lib/dart/core/work/rules/lib/tools.dart b/lib/dart/core/work/rules/lib/tools.dart index ef15b9f6add413ab42b43d3611a7d3fca271b5a5..d3bb86c667157d5777306c981d1118894113bbbc 100644 --- a/lib/dart/core/work/rules/lib/tools.dart +++ b/lib/dart/core/work/rules/lib/tools.dart @@ -177,7 +177,7 @@ var formAttrResolver = ( List missingAttrs, ) async { var replacedStr = ruleAttrs - .map((_str) => getSpecialChartcterContent(_str)) + .map((str) => getSpecialChartcterContent(str)) .reduce((ruleContent, item) { //在attrs数组中查找是否有name等于item的对象 var attrObj = formAttr.singleWhere((v) => v.name == item); diff --git a/lib/dart/core/work/task.dart b/lib/dart/core/work/task.dart index bcd90736788edceaa6b67a9c87b630a40112855b..dc58babda4ade5a2fbd270304972c6783e19dcdd 100644 --- a/lib/dart/core/work/task.dart +++ b/lib/dart/core/work/task.dart @@ -59,6 +59,7 @@ abstract class IWorkTask extends IFileInfo { String? backId, bool? isSkip, List? executors, //执行器 + Map>? gatewayData, //网关: nodeId/fieldId -> 目标 targetId 列表(转办/加签/委托) }); //加载办事 Future loadWork(String wrokId, String shareId); @@ -217,9 +218,11 @@ class WorkTask extends FileInfo implements IWorkTask { case '已办': return taskdata.status! >= TaskStatus.approvalStart.status; case '已发起': - return taskdata.createUser == userId; + return taskdata.createUser == userId && + taskdata.status! < TaskStatus.approvalStart.status; case '已完结': - return taskdata.createUser == userId; + return taskdata.createUser == userId && + taskdata.status! >= TaskStatus.approvalStart.status; case '待办': return taskdata.status! < TaskStatus.approvalStart.status; case '抄送': @@ -605,6 +608,7 @@ class WorkTask extends FileInfo implements IWorkTask { String? backId, bool? isSkip, List? executors, + Map>? gatewayData, }) async { if ((taskdata.status!) < TaskStatus.approvalStart.status) { if (status == -1) { @@ -614,6 +618,16 @@ class WorkTask extends FileInfo implements IWorkTask { return approvalJoinTask(status, comment: comment); } else if (await loadInstance(reload: true)) { await _executors(executors); + // 构造 gatewayData JSON:[{id: nodeId, targetIds: [targetId...]}...] + // 对齐 oiocns-react executor/tools/task/approval: memberData/gatewayData + String gatewaysStr = ""; + if (gatewayData != null && gatewayData.isNotEmpty) { + final list = >[]; + gatewayData.forEach((key, ids) { + list.add({"id": key, "targetIds": ids}); + }); + gatewaysStr = jsonEncode(list); + } final res = await kernel.approvalTask(ApprovalTaskReq( id: taskdata.id, status: status, @@ -623,7 +637,7 @@ class WorkTask extends FileInfo implements IWorkTask { : null, backId: backId, isSkip: isSkip, - gateways: "")); + gateways: gatewaysStr)); if (!res.success) { ToastUtils.showMsg(msg: res.msg); } @@ -758,9 +772,8 @@ class WorkTask extends FileInfo implements IWorkTask { for (var app in await target.directory.loadAllApplication()) { final works = await app.loadWorks(); final indx = works.indexWhere((a) => a.metadata.id == wrokId); - final work = works.firstWhere((a) => a.metadata.id == wrokId); - if (indx < 0) { - return work; + if (indx >= 0) { + return works[indx]; } } } diff --git a/lib/dart/extension/ex_color.dart b/lib/dart/extension/ex_color.dart index 445f1577603526d72d1866247fb06cbd62c5140f..ab8f5bc9370c87d86a6b5417e1178a91ee8c7f80 100644 --- a/lib/dart/extension/ex_color.dart +++ b/lib/dart/extension/ex_color.dart @@ -22,6 +22,6 @@ extension ExColor on Color { 1, ); } - return MaterialColor(value, swatch); + return MaterialColor(toARGB32(), swatch); } } diff --git a/lib/dart/extension/ex_string.dart b/lib/dart/extension/ex_string.dart index d6ba0c070c21ef3289d06597a4d5eeb83a858e7e..647aaafcaecb367366367782afd29fd5e57945d7 100644 --- a/lib/dart/extension/ex_string.dart +++ b/lib/dart/extension/ex_string.dart @@ -30,7 +30,7 @@ extension ExString on String { 1, ); } - return MaterialColor(color.value, swatch); + return MaterialColor(color.toARGB32(), swatch); } /// 清除 html 标签 diff --git a/lib/global.dart b/lib/global.dart index 7b9b8247ef17213d5ba23c9eefca8653ad3e5d3e..b72ac51f4dcc63bd19f4a129ccfe2b1f1480c5c9 100644 --- a/lib/global.dart +++ b/lib/global.dart @@ -12,7 +12,9 @@ import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:logging/logging.dart'; import 'package:orginone/dart/base/storages/hive_utils.dart'; +import 'package:orginone/dart/base/storages/local_page_cache_repository.dart'; import 'package:orginone/dart/base/storages/storage.dart'; +import 'package:orginone/dart/base/storages/business_database.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:orginone/utils/log/log_util.dart'; @@ -25,12 +27,13 @@ class Global { // WidgetsBinding widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized(); setSystemUi(); - // 初始化本地存储(并行执行以减少启动耗时) + // 初始化本地存储(HiveUtils 必须先初始化 Hive 路径,再初始化 LocalPageCache) try { - await Future.wait([ - Storage.init(), - HiveUtils.init(), - ]); + await Storage.init(); + await HiveUtils.init(); + await LocalPageCacheRepository.instance.ensureInitialized(); + // 初始化业务数据 SQLite 全量持久化(AGENTS.md §6.2) + await BusinessDatabase.instance.database; } catch (e, st) { XLogUtil.e('Global 初始化本地存储失败: $e\n$st'); } @@ -106,7 +109,7 @@ class Global { SystemChrome.setApplicationSwitcherDescription( ApplicationSwitcherDescription( label: '奥集能', // 设置应用名称 - primaryColor: Colors.white.value, // 主色 + primaryColor: Colors.white.toARGB32(), // 主色 )); } } diff --git a/lib/pages/auth/forgot_password/view.dart b/lib/pages/auth/forgot_password/view.dart index 9738a6589c1b1cbbe9d1390074706bec6386f659..ce48d5980b7cfec78cdee8eb233c40752ecb3335 100644 --- a/lib/pages/auth/forgot_password/view.dart +++ b/lib/pages/auth/forgot_password/view.dart @@ -187,7 +187,7 @@ class _ForgotPasswordPageState controller: keyController, title: '私钥', hint: '请输入注册时保存的账户私钥') : XTextField.input( controller: verifyController, - title: '手机号', + title: '验证码', hint: '请输入验证码', action: Container( padding: const EdgeInsets.only(bottom: 5), @@ -357,7 +357,7 @@ class _ForgotPasswordPageState TextStyle textStyle = TextStyle(color: OipBrand.primary, fontSize: 20.sp); String text = '发送验证码'; if (startCountDown) { - textStyle = TextStyle(color: Colors.grey, fontSize: 15.sp); + textStyle = TextStyle(color: Colors.grey, fontSize: 16.sp); text = "$countDown秒后重新发送"; } return GestureDetector( diff --git a/lib/pages/auth/login/view.dart b/lib/pages/auth/login/view.dart index f29ec2b7c58e0a9e23562a884baa4d18a04a71df..e07585b9d572166058ddaf2240ab7844b7d91d88 100644 --- a/lib/pages/auth/login/view.dart +++ b/lib/pages/auth/login/view.dart @@ -9,7 +9,6 @@ import 'package:orginone/components/VerificationCodeButton/VerificationCodeButto import 'package:orginone/dart/extension/ex_widget.dart'; import 'package:orginone/pages/auth/forgot_password/view.dart'; import 'package:orginone/routers/pages.dart'; -import 'package:orginone/routers/router_const.dart'; import 'package:permission_handler/permission_handler.dart'; import '../../../components/BeautifulBGWidget/BeautifulBGWidget.dart'; import '../../../components/XDialogs/dialog_utils.dart'; @@ -95,6 +94,7 @@ class _LoginPageState extends BeautifulBGStatefulState { Widget loginTitle() { return Container( alignment: Alignment.centerLeft, + padding: const EdgeInsets.only(top: 56), child: const Text( '欢迎来到 奥集能', style: TextStyle(fontSize: 28, fontWeight: FontWeight.w600), @@ -422,7 +422,9 @@ class _LoginPageState extends BeautifulBGStatefulState { // 避免 refreshToken 用 MD5 当明文密码重登必然失败,且违反安全规范 Storage.setList(Constants.account, [accountController.text, '']); [Permission.notification].request(); - RoutePages.to(context: context, path: Routers.logintrans); + // ★登录加速:直接跳首页,_onAuthed 在后台执行实例创建+数据加载 + // 首页从本地存储渲染首屏,_refresh 完成后通过 command 事件刷新 + RoutePages.jumpHome(); } else if (res.code == 400 && res.msg == "认证失败,请重试。") { EasyLoading.dismiss(); ToastUtils.showMsg(msg: '账户名或密码错误'); diff --git a/lib/pages/auth/login_transition/index.dart b/lib/pages/auth/login_transition/index.dart index d4f0704d3147b7728037c072b3cab12fb1cb8909..d6e0a66b99a1c57310bfdc75d20b12a1016c1aaa 100644 --- a/lib/pages/auth/login_transition/index.dart +++ b/lib/pages/auth/login_transition/index.dart @@ -133,7 +133,7 @@ class _LoginTransPageState extends State '数据加载中', style: TextStyle( color: const Color(0xFF366EF4), - fontSize: 13.sp, + fontSize: 16.sp, fontWeight: FontWeight.w400, ), ), diff --git a/lib/pages/auth/register/view.dart b/lib/pages/auth/register/view.dart index b1c58762a583815ec1728300f2e08c9511a444ce..1cc954e1ad22d8b45624dcfa1ee3363b20510150 100644 --- a/lib/pages/auth/register/view.dart +++ b/lib/pages/auth/register/view.dart @@ -405,7 +405,7 @@ class _RegisterPageState extends State { TextStyle textStyle = TextStyle(color: OipBrand.primary, fontSize: 20.sp); String text = '发送验证码'; if (startCountDown) { - textStyle = TextStyle(color: Colors.grey, fontSize: 15.sp); + textStyle = TextStyle(color: Colors.grey, fontSize: 16.sp); text = "$countDown秒后重新发送"; } return GestureDetector( diff --git a/lib/pages/chats/chat_page.dart b/lib/pages/chats/chat_page.dart index 96d0ce6b34687557a6281f8443b4f92226f1ff5f..dcd062d0e65584cbeddfcdc464d4ec4271bc0c78 100644 --- a/lib/pages/chats/chat_page.dart +++ b/lib/pages/chats/chat_page.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:orginone/components/CommandWidget/index.dart'; import 'package:orginone/components/LoadingWidget/LoadingWidget.dart'; import 'package:orginone/components/TabContainerWidget/TabContainerWidget.dart'; import 'package:orginone/components/TabContainerWidget/types.dart'; @@ -20,11 +19,9 @@ import '../../dart/base/common/commands.dart'; /// 沟通页面 // ignore: must_be_immutable class ChatPage extends StatefulWidget { - TabContainerModel? relationModel; List? datas; ChatPage({super.key, List? datas}) { - relationModel = null; dynamic params = RoutePages.getParentRouteParam(); this.datas = datas ?? (params is List ? params : null); } @@ -33,92 +30,170 @@ class ChatPage extends StatefulWidget { State createState() => _ChatPageState(); } -class _ChatPageState extends State { +class _ChatPageState extends State + with AutomaticKeepAliveClientMixin { final ScrollController scrollController = ScrollController(); StreamSubscription? _portalRefreshSub; - TabContainerModel? get relationModel => widget.relationModel; - set relationModel(TabContainerModel? value) { - widget.relationModel = value; - } + final List _subscriptions = []; + TabContainerModel? _chatModel; List? get datas => widget.datas; - IChatProvider? get chatProvider => relationCtrl.provider.chatProvider; List? get chats => chatProvider?.chats ?? []; final Map> dataSourceCache = {}; - // 上次构建时的会话总数、未读总数与最近消息时间,用于判断是否需要重建 - // _lastLastMsgTime 解决"正在会话时新消息到达但 unread=0、count 不变,列表预览陈旧"问题 int? _lastChatCount; int? _lastUnreadCount; int? _lastLastMsgTime; + // 后台刷新指示器:仅在已有数据、后台拉取新数据时显示 + bool _isBgRefreshing = false; + + @override + bool get wantKeepAlive => true; @override void initState() { super.initState(); - // 事件监听器(保存订阅引用以便 dispose 时取消) _portalRefreshSub = portalRefreshEventBus.on().listen(onPortalEvent); - // 进入沟通页面时按需刷新会话数据(数据过期才刷新,新鲜则直接从内存渲染) - relationCtrl.provider.ensureDataFresh(DataType.chats); + // 先用内存缓存渲染首屏(chatProvider.chats 已经加载过的数据), + // 然后触发后台刷新(ensureDataFresh 内部 fire-and-forget) + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _refreshModel(); + _triggerBgRefresh(); + }); + // 后台刷新完成后通过 'chat'/'new' 事件通知刷新 UI + final chatSub = command.subscribe((type, cmd, args) { + if (type == 'chat' && cmd == 'new') { + if (!mounted) return; + // 事件回调可能在 build 阶段被同步派发,延迟到首帧后执行避免 + // "setState during build" 异常 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _refreshModel(); + }); + } + }); + _subscriptions.add(chatSub); + // 订阅 'session' flag:deepLoadBackground/loadCacheChatData 等中间环节通知 + final sessionFlagSub = command.subscribeByFlag('session', ([dynamic args]) { + if (!mounted) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _refreshModel(); + }); + }); + _subscriptions.add(sessionFlagSub); + // 沟通页不跟随单位切换刷新数据,保持本地缓存优先策略 + } + + /// 触发后台数据刷新(带顶部"数据更新中"指示器) + /// + /// 首屏 chats 为空时(chatProvider 未完成首次加载),主动调用 + /// `chatProvider.load(reload: true)` 触发 user/company deepLoad, + /// 确保 user.chats/company.chats 有数据后再渲染。 + /// 已有数据时走 `ensureDataFresh`(skipDeepLoad: true)后台刷新路径, + /// 避免重复 deepLoad。 + void _triggerBgRefresh() { + if (!mounted) return; + setState(() => _isBgRefreshing = true); + final chatProvider = relationCtrl.provider.chatProvider; + final Future future; + if (chatProvider == null) { + future = Future.value(); + } else if (chatProvider.chats.isEmpty) { + // chats 为空:主动 load 触发 deepLoad(reload: true 强制重新加载, + // 即使 _inited=true 也会进入加载逻辑) + future = chatProvider.load(reload: true); + } else { + // 已有数据:走后台刷新(skipDeepLoad: true,避免重复 deepLoad) + future = relationCtrl.provider.ensureDataFresh(DataType.chats); + } + future.then((_) { + if (mounted) { + // 后台刷新完成后强制重建模型,不依赖 _refreshModel 的计数优化 + load(context); + setState(() => _isBgRefreshing = false); + } + }).catchError((_) { + if (mounted) setState(() => _isBgRefreshing = false); + }); + } + + /// 统一的模型刷新入口:重建 _chatModel 后 setState。 + /// 仅在事件回调和首帧回调中调用,禁止在 build 中调用。 + void _refreshModel() { + final currentChats = chats ?? []; + final currentCount = currentChats.length; + final currentUnread = + currentChats.fold(0, (sum, s) => sum + s.noReadCount); + final currentLastMsgTime = currentChats.isEmpty + ? null + : currentChats + .map((s) => s.chatdata.lastMsgTime) + .fold(0, (a, b) => a > b ? a : b); + if (_chatModel != null && + _lastChatCount == currentCount && + _lastUnreadCount == currentUnread && + _lastLastMsgTime == currentLastMsgTime) { + return; + } + load(context); + _lastChatCount = currentCount; + _lastUnreadCount = currentUnread; + _lastLastMsgTime = currentLastMsgTime; + setState(() {}); } @override void dispose() { _portalRefreshSub?.cancel(); _portalRefreshSub = null; + for (final id in _subscriptions) { + command.unsubscribe(id); + } + _subscriptions.clear(); scrollController.dispose(); super.dispose(); } //监听Bus events void onPortalEvent(PortalRefreshEvent event) { - command.emitter('chat', 'new'); + if (mounted) _refreshModel(); } @override Widget build(BuildContext context) { - return CommandWidget( - type: 'chat', - cmd: 'new', - flag: 'session', - builder: (context, args) { - // 仅在会话数、未读数或最近消息时间变化时重建,避免命令通知触发无谓重建 - final currentChats = chats ?? []; - final currentCount = currentChats.length; - final currentUnread = - currentChats.fold(0, (sum, s) => sum + s.noReadCount); - // 取所有会话中最大的 lastMsgTime 作为最近消息时间 - // 解决:正在会话时新消息到达但 unread=0、count 不变,列表预览陈旧问题 - final currentLastMsgTime = currentChats.isEmpty - ? null - : currentChats - .map((s) => s.chatdata.lastMsgTime) - .fold(0, (a, b) => a > b ? a : b); - // 首次进入:relationModel 为 null 且无数据时显示骨架屏占位 - // 避免直接访问 relationModel! 导致崩溃 - if (relationModel == null && currentChats.isEmpty) { - return const SkeletonWidget(itemCount: 6); - } - if (relationModel == null || - _lastChatCount != currentCount || - _lastUnreadCount != currentUnread || - _lastLastMsgTime != currentLastMsgTime) { - load(context); - _lastChatCount = currentCount; - _lastUnreadCount = currentUnread; - _lastLastMsgTime = currentLastMsgTime; - } - return TabContainerWidget( - relationModel!, - ); - }); + super.build(context); + // build 只做渲染:未加载显示骨架屏,已加载显示 TabContainerWidget + // 模型刷新统一由 initState 和事件回调中的 _refreshModel 触发 + if (_chatModel == null) { + return const SkeletonWidget(itemCount: 6); + } + // 统一下拉刷新交互:所有 Tab 页(沟通/办事/发现/关系)使用 RefreshIndicator + force 刷新 + // force=true 跳过保鲜期检查,确保用户下拉动作必然触发远端拉取 + final body = _isBgRefreshing + ? Column( + children: [ + XUi.refreshingBanner(), + Expanded(child: TabContainerWidget(_chatModel!)), + ], + ) + : TabContainerWidget(_chatModel!); + return RefreshIndicator( + onRefresh: () async { + await relationCtrl.provider + .ensureDataFresh(DataType.chats, force: true); + if (!mounted) return; + // 使用 State.context 而非 build 闭包参数 context,确保 mounted 守卫生效 + load(this.context); + }, + child: body, + ); } void load(BuildContext context) { - // 清空数据源缓存,确保每次重建都从 chatProvider 获取最新数据 dataSourceCache.clear(); - relationModel = TabContainerModel( + _chatModel = TabContainerModel( title: "沟通", activeTabTitle: getActiveTabTitle(), tabItems: [ @@ -138,15 +213,14 @@ class _ChatPageState extends State { } /// 获取或创建数据源 + /// 修正:每次都使用最新计算结果,避免"首次为空后续不更新"的 bug List getOrCreateDataSource(String title) { final computed = datas == null ? getSessionsByLabel(title, '') : getSessionsByLabel(title, '', datas: datas); - final cached = dataSourceCache[title]; - if (cached == null || (cached.isEmpty && computed.isNotEmpty)) { - dataSourceCache[title] = computed; - } - return dataSourceCache[title] ?? computed; + // 始终覆盖缓存,确保数据最新 + dataSourceCache[title] = computed; + return computed; } TabItemsModel createTabItemsModel( @@ -188,20 +262,39 @@ class _ChatPageState extends State { } // 根据标签获得沟通会话列表 + // 修正:所有 Tab 都按 lastMsgTime 倒序,所有 Tab 都支持 searchText 过滤 + // 单位 Tab 只显示"单位"标签(不含群组/部门);最近 Tab 显示最近有消息的会话(按 lastMsgTime 倒序 Top N) List getSessionsByLabel(String label, String searchText, {List? datas}) { datas ??= chats; - if (label == "单位") { - return datas + List filtered; + if (label == "最近") { + // 最近会话:按 lastMsgTime 倒序,过滤掉无消息记录的会话(lastMsgTime<=0) + filtered = datas ?.where((element) => - (element.groupTags.contains(label) || - element.groupTags.contains("群组") || - element.groupTags.contains("部门")) && + element.chatdata.lastMsgTime > 0 && + element.search(searchText)) + .toList() ?? + []; + filtered.sort((a, b) => + b.chatdata.lastMsgTime.compareTo(a.chatdata.lastMsgTime)); + // 最近 Tab 最多展示 100 条,避免列表过长 + if (filtered.length > 100) { + filtered = filtered.sublist(0, 100); + } + return filtered; + } else if (label == "单位") { + // 单位 Tab 只显示"单位"标签(个人加入的单位会话) + // 不再包含"群组"和"部门",避免与群组/部门 Tab 重复展示 + filtered = datas + ?.where((element) => + element.groupTags.contains(label) && element.search(searchText)) .toList() ?? []; } else if (label == "集群") { - return datas + // 集群 Tab:显示集群标签(单位下创建的集群),保留组织群兼容 + filtered = datas ?.where((element) => (element.groupTags.contains(label) || element.groupTags.contains("组织群")) && @@ -209,24 +302,31 @@ class _ChatPageState extends State { .toList() ?? []; } else if (label == "智能体") { - return datas + filtered = datas ?.where((element) => element.groupTags.contains(label) && element.search(searchText)) .toList() ?? []; - } else if (label == "最近") { - return datas + } else { + // 好友/群组/同事/存储 等 Tab:按标签精确匹配 + 搜索文本 + filtered = datas ?.where((element) => - !element.groupTags.contains("集群") && - !element.groupTags.contains("组织群") && + element.groupTags.contains(label) && element.search(searchText)) .toList() ?? []; } - return datas - ?.where((element) => element.groupTags.contains(label)) - .toList() ?? - []; + // 所有 Tab 统一按 lastMsgTime 倒序(最近在前),无消息的按 updateTime 倒序 + filtered.sort((a, b) { + final aTime = a.chatdata.lastMsgTime > 0 + ? a.chatdata.lastMsgTime + : (int.tryParse(a.metadata.updateTime ?? '') ?? 0); + final bTime = b.chatdata.lastMsgTime > 0 + ? b.chatdata.lastMsgTime + : (int.tryParse(b.metadata.updateTime ?? '') ?? 0); + return bTime.compareTo(aTime); + }); + return filtered; } } diff --git a/lib/pages/common/asset_map_page.dart b/lib/pages/common/asset_map_page.dart index 7ee2dd8b295765f2c0f9fc9ae8664c93883fc2b3..3bee00c6e3afa6e3d4d2798c2b478743ff3aa8db 100644 --- a/lib/pages/common/asset_map_page.dart +++ b/lib/pages/common/asset_map_page.dart @@ -77,7 +77,7 @@ class _AssetMapPageState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('资产地图')), + appBar: AppBar(title: Text('资产地图', style: XFonts.headlineSmall)), body: _loading ? const Center(child: CircularProgressIndicator()) : _config == null @@ -125,7 +125,7 @@ class _AssetMapPageState extends State { ), child: Text( asset.name, - style: TextStyle(color: Colors.white, fontSize: 10.sp), + style: TextStyle(color: Colors.white, fontSize: 16.sp), maxLines: 1, overflow: TextOverflow.ellipsis, ), diff --git a/lib/pages/common/location_picker_page.dart b/lib/pages/common/location_picker_page.dart index fff108af5b1e2968a4405d0e24f7f64521953e39..2ea3ad14af7bab1022ea321b57df8a1b0a1a389c 100644 --- a/lib/pages/common/location_picker_page.dart +++ b/lib/pages/common/location_picker_page.dart @@ -171,11 +171,11 @@ class _LocationPickerPageState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('选择位置'), + title: Text('选择位置', style: XFonts.headlineSmall), actions: [ TextButton( onPressed: _confirm, - child: Text('发送', style: TextStyle(color: OipBrand.primary, fontSize: 18.sp)), + child: Text('发送', style: XFonts.labelLarge.copyWith(color: OipBrand.primary)), ), ], ), diff --git a/lib/pages/common/space_select_page.dart b/lib/pages/common/space_select_page.dart index af1ba5b2b0e9202448483e6b63b5ffb3d58214a9..f8dc60c1c0ca2cec44adc626798e9d4faab72297 100644 --- a/lib/pages/common/space_select_page.dart +++ b/lib/pages/common/space_select_page.dart @@ -107,7 +107,7 @@ class _SpaceSelectPageState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('选择空间'), + title: Text('选择空间', style: XFonts.headlineSmall), backgroundColor: OipSurface.card, foregroundColor: OipText.primary, elevation: 0, @@ -229,7 +229,7 @@ class _SpaceSelectPageState extends State { child: Text( item.name, style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, color: isDisabled ? OipText.tertiary : (isSelected diff --git a/lib/pages/discover/discover_page.dart b/lib/pages/discover/discover_page.dart index feefa212c14b4d19b99744c6dca935815fa93f1f..6768cf005942fb4900eb9d0b84eb1d112a6683c9 100644 --- a/lib/pages/discover/discover_page.dart +++ b/lib/pages/discover/discover_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:provider/provider.dart'; import 'package:orginone/components/Executor/open_executor.dart'; @@ -6,6 +7,8 @@ import 'package:orginone/components/GlobalAiInputBar/global_ai_input_bar.dart'; import 'package:orginone/components/LoadingWidget/LoadingWidget.dart'; import 'package:orginone/components/XTextField/XTextField.dart'; import 'package:orginone/components/Tip/ToastUtils.dart'; +import 'package:orginone/config/oip_tokens.dart'; +import 'package:orginone/config/theme/unified_style.dart'; import 'package:orginone/dart/base/common/commands.dart'; import 'package:orginone/dart/core/repository/work_repository.dart'; import 'package:orginone/dart/core/target/base/belong.dart'; @@ -19,6 +22,7 @@ import 'discover_service.dart'; import 'pages/all_activities_page.dart'; import 'pages/data_share_list_page.dart'; import 'pages/group_share_list_page.dart'; +import 'pages/live_list_page.dart'; import 'pages/market_list_page.dart'; import 'pages/notice_list_page.dart'; import 'pages/video_list_page.dart'; @@ -47,20 +51,40 @@ class _DiscoverPageState extends State } void _subscribeRefreshEvents() { - // 订阅 session flag,后台恢复或数据刷新时重新加载 - final sessionId = command.subscribeByFlag('session', ([args]) { + // 发现页不跟随单位切换刷新数据,保持本地缓存优先策略, + // 加速页面渲染速度。单位切换只刷新门户页面数据。 + // 但需要订阅后台数据加载完成事件,确保 deepLoad/ensureDataFresh 拉取的最新 + // cohorts/storages/agents/companys 数据能回流到 DiscoverProvider 重新聚合显示。 + // 对齐 AGENTS.md §18.2「事件通知:后台拉取完成后通过 command.emitter(flag) 通知订阅方刷新」 + // + // 注意:事件回调可能在 build 阶段被同步派发,直接调用 provider.refreshData() + // 会触发 notifyListeners() 导致 "setState during build" 异常。 + // 因此回调中使用 SchedulerBinding.addPostFrameCallback 延迟到帧外执行。 + final sessionSub = command.subscribeByFlag('session', ([dynamic args]) { if (!mounted) return; - context.read().refreshData(); - }, false); - _subscribers.add(sessionId); - - // 订阅 switchSpace 事件:切换单位后清空缓存并强制重新加载 - final switchId = command.subscribeByFlag('switchSpace', ([args]) { + SchedulerBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + // 后台数据加载完成(如登录后 deepLoadBackground),触发发现页重新聚合 + // 只在已有数据时触发(避免首屏加载重复请求) + final provider = context.read(); + if (provider.hasLoadedData) { + provider.refreshData(); + } + }); + }); + _subscribers.add(sessionSub); + final bgLoadedSub = + command.subscribeByFlag('backgroundLoaded', ([dynamic args]) { if (!mounted) return; - DiscoverService.clearCache(); - context.read().loadDiscoverData(forceRefresh: true); - }, false); - _subscribers.add(switchId); + SchedulerBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + // 后台二级递归 deepLoad 完成(groups/cohorts/storages 已填充), + // 此时多集群聚合能拉到完整资源,触发一次刷新 + final provider = context.read(); + provider.refreshData(); + }); + }); + _subscribers.add(bgLoadedSub); } @override @@ -104,9 +128,7 @@ class _DiscoverPageState extends State resourceId: resourceId, title: title, sourceId: sourceId); break; case PlazaType.live: - // 直播 Tab 已移除,但保留兼容 - page = GroupShareListPage( - resourceId: resourceId, title: title, sourceId: sourceId); + page = LiveListPage(resourceId: resourceId, title: title); break; case PlazaType.marketTrade: page = MarketListPage( @@ -136,7 +158,7 @@ class _DiscoverPageState extends State final theme = Theme.of(context); return ColoredBox( - color: const Color(0xFFF5F7FA), + color: OipSurface.background, child: Consumer( builder: (context, provider, child) { final hasLoadedData = provider.hasLoadedData; @@ -146,10 +168,10 @@ class _DiscoverPageState extends State } if (provider.errorMessage != null && !hasLoadedData) { - return _buildFullPageState( + return XUi.emptyState( icon: Icons.cloud_off_rounded, title: '发现页加载失败', - message: provider.errorMessage, + message: provider.errorMessage ?? '请检查网络后重试', actionLabel: '重试', onAction: () => provider.loadDiscoverData(forceRefresh: true), ); @@ -158,6 +180,9 @@ class _DiscoverPageState extends State return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + // 后台刷新时显示"数据更新中"指示器,不影响主内容显示 + if (provider.isBackgroundRefreshing && hasLoadedData) + XUi.refreshingBanner(), _buildTabBar(provider, theme), _buildSmartSearchBar(theme), Expanded( @@ -165,7 +190,7 @@ class _DiscoverPageState extends State controller: _tabController, children: [ for (final config in discoverItems) - _buildTabPane(config, provider, theme), + _keepAliveTabPane(_buildTabPane(config, provider, theme)), ], ), ), @@ -181,10 +206,10 @@ class _DiscoverPageState extends State return Container( width: double.infinity, decoration: const BoxDecoration( - color: Colors.white, + color: OipSurface.card, border: Border( bottom: BorderSide( - color: Color(0xFFE8EBF0), + color: OipBorder.defaultBorder, width: 1, ), ), @@ -192,13 +217,13 @@ class _DiscoverPageState extends State child: TabBar( controller: _tabController, isScrollable: true, - indicatorColor: theme.primaryColor, + indicatorColor: OipBrand.primary, indicatorWeight: 2.5, indicatorSize: TabBarIndicatorSize.label, - labelColor: const Color(0xFF15181D), - unselectedLabelColor: const Color(0xFF7D8592), - labelStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), - unselectedLabelStyle: const TextStyle(fontSize: 14), + labelColor: OipText.primary, + unselectedLabelColor: OipText.tertiary, + labelStyle: XFonts.tabLabel, + unselectedLabelStyle: XFonts.tabUnselected, labelPadding: EdgeInsets.symmetric(horizontal: 16.w), padding: EdgeInsets.only(left: 8.w, right: 8.w, top: 6.h), dividerColor: Colors.transparent, @@ -209,7 +234,7 @@ class _DiscoverPageState extends State child: _buildTabLabel( config, provider.getBadgeCount(config.plazaType), - theme.primaryColor, + OipBrand.primary, ), ), ], @@ -219,7 +244,7 @@ class _DiscoverPageState extends State Widget _buildSmartSearchBar(ThemeData theme) { return Container( - color: Colors.white, + color: OipSurface.card, padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), child: Row( children: [ @@ -237,22 +262,18 @@ class _DiscoverPageState extends State padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h), decoration: BoxDecoration( gradient: const LinearGradient( - colors: [Color(0xFF6C63FF), Color(0xFF3B82F6)], + colors: [OipAi.accent, OipBrand.primary], ), - borderRadius: BorderRadius.circular(20.w), + borderRadius: BorderRadius.circular(OipRadius.full), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.auto_awesome, color: Colors.white, size: 16.sp), + Icon(Icons.auto_awesome, color: OipText.inverse, size: 16.sp), SizedBox(width: 4.w), Text( '智能搜索', - style: TextStyle( - color: Colors.white, - fontSize: 13.sp, - fontWeight: FontWeight.w500, - ), + style: XFonts.whiteSmall, ), ], ), @@ -320,9 +341,10 @@ class _DiscoverPageState extends State showModalBottomSheet( context: context, isScrollControlled: true, - backgroundColor: Colors.white, + backgroundColor: OipSurface.card, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16.r)), + borderRadius: + BorderRadius.vertical(top: Radius.circular(OipRadius.xl.r)), ), builder: (ctx) { return DraggableScrollableSheet( @@ -340,8 +362,7 @@ class _DiscoverPageState extends State children: [ Text( '搜索结果', - style: TextStyle( - fontSize: 18.sp, fontWeight: FontWeight.w600), + style: XFonts.titleSmall, ), const Spacer(), GestureDetector( @@ -355,8 +376,8 @@ class _DiscoverPageState extends State padding: EdgeInsets.symmetric(horizontal: 16.w), child: Text( '"$query" 共找到 ${allResults.length} 条结果', - style: TextStyle( - fontSize: 14.sp, color: const Color(0xFF7D8592)), + style: + XFonts.captionSmall.copyWith(color: OipText.tertiary), ), ), SizedBox(height: 8.h), @@ -425,7 +446,7 @@ class _DiscoverPageState extends State height: 40.w, decoration: BoxDecoration( color: color.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(8.w), + borderRadius: BorderRadius.circular(OipRadius.md.w), ), child: Center( child: Icon( @@ -444,20 +465,18 @@ class _DiscoverPageState extends State item.name, maxLines: 1, overflow: TextOverflow.ellipsis, - style: - TextStyle(fontSize: 15.sp, fontWeight: FontWeight.w500), + style: XFonts.bodyMedium, ), SizedBox(height: 2.h), Text( '${item.type} · ${item.spaceName}', - style: TextStyle( - fontSize: 12.sp, color: const Color(0xFF7D8592)), + style: + XFonts.captionSmall.copyWith(color: OipText.tertiary), ), ], ), ), - Icon(Icons.chevron_right, - size: 20.sp, color: const Color(0xFFB7BDC7)), + Icon(Icons.chevron_right, size: 20.sp, color: OipText.disabled), ], ), ), @@ -487,25 +506,25 @@ class _DiscoverPageState extends State Color _getSearchResultColor(String category) { switch (category) { case 'work': - return Colors.orange.shade600; + return OipState.warning; case 'application': - return Colors.blue.shade600; + return OipBrand.primary; case 'form': - return Colors.green.shade600; + return OipState.success; case 'file': - return Colors.purple.shade600; + return OipAi.accent; case 'discover_group_share': - return const Color(0xFF2B6AFF); + return OipBrand.primary; case 'discover_video': - return const Color(0xFFE35D45); + return OipEntity.station; case 'discover_market_trade': - return const Color(0xFF18A36A); + return OipState.success; case 'discover_data_share': - return const Color(0xFF3B82F6); + return OipBrand.primary; case 'discover_news_announcement': - return const Color(0xFFF08A24); + return OipState.warning; default: - return Colors.grey.shade600; + return OipText.tertiary; } } @@ -551,16 +570,12 @@ class _DiscoverPageState extends State minHeight: 18.w, ), padding: EdgeInsets.symmetric(horizontal: 5.w, vertical: 2.h), - decoration: BoxDecoration( - color: primaryColor, - borderRadius: BorderRadius.circular(9.w), - ), + decoration: XUi.filledBadge(primaryColor, radius: OipRadius.full), child: Text( badge > 99 ? '99+' : '$badge', textAlign: TextAlign.center, - style: const TextStyle( - color: Colors.white, - fontSize: 11, + style: XFonts.labelSmall.copyWith( + color: OipText.inverse, fontWeight: FontWeight.w700, ), ), @@ -570,6 +585,8 @@ class _DiscoverPageState extends State ); } + Widget _keepAliveTabPane(Widget child) => _KeepAliveWrapper(child: child); + Widget _buildTabPane( DiscoverItemConfig config, DiscoverProvider provider, @@ -649,33 +666,22 @@ class _DiscoverPageState extends State children: [ Text( config.label, - style: const TextStyle( - fontSize: 16.5, - fontWeight: FontWeight.w700, - color: Color(0xFF15181D), - ), + style: XFonts.titleSmall.copyWith(color: OipText.primary), ), SizedBox(width: 8.w), Text( resourceCount > 0 ? '$resourceCount 个入口' : '暂无入口', - style: const TextStyle( - fontSize: 14, - color: Color(0xFF7D8592), - ), + style: XFonts.caption.copyWith(color: OipText.tertiary), ), const Spacer(), if (badge > 0) Container( padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(999.w), - ), + decoration: XUi.softBadge(color, radius: OipRadius.full), child: Text( '更新 $badge', - style: TextStyle( + style: XFonts.labelSmall.copyWith( color: color, - fontSize: 12, fontWeight: FontWeight.w700, ), ), @@ -685,9 +691,8 @@ class _DiscoverPageState extends State SizedBox(height: 4.h), Text( config.description, - style: const TextStyle( - fontSize: 14, - color: Color(0xFF7D8592), + style: XFonts.caption.copyWith( + color: OipText.tertiary, height: 1.35, ), ), @@ -700,31 +705,30 @@ class _DiscoverPageState extends State return Container( padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.h), decoration: BoxDecoration( - color: const Color(0xFFFFF4F4), - borderRadius: BorderRadius.circular(8.w), + color: OipState.errorBg, + borderRadius: BorderRadius.circular(OipRadius.md.w), ), child: Row( children: [ Icon( Icons.error_outline_rounded, size: 18.sp, - color: const Color(0xFFC74343), + color: OipState.error, ), SizedBox(width: 8.w), Expanded( child: Text( provider.errorMessage ?? '加载失败', - style: const TextStyle( - color: Color(0xFFC74343), - fontSize: 14, - ), + style: XFonts.caption.copyWith(color: OipState.error), maxLines: 2, overflow: TextOverflow.ellipsis, ), ), - TextButton( - onPressed: () => provider.loadDiscoverData(forceRefresh: true), - child: const Text('重试'), + XUi.secondaryButton( + label: '重试', + onTap: () => provider.loadDiscoverData(forceRefresh: true), + width: 72, + height: 32, ), ], ), @@ -735,56 +739,15 @@ class _DiscoverPageState extends State DiscoverItemConfig config, DiscoverProvider provider, ) { - final color = _getTypeColor(config.plazaType); final hasError = provider.errorMessage != null; - return Center( - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 32.w), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 56.w, - height: 56.w, - decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(14.w), - ), - child: Icon( - hasError ? Icons.cloud_off_rounded : config.icon, - size: 28.sp, - color: color, - ), - ), - SizedBox(height: 14.h), - Text( - hasError ? '暂时无法加载${config.label}' : '暂无${config.label}内容', - style: const TextStyle( - fontSize: 16.5, - fontWeight: FontWeight.w700, - color: Color(0xFF15181D), - ), - ), - SizedBox(height: 6.h), - Text( - hasError ? provider.errorMessage! : '下拉可重新刷新当前发现内容', - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 14, - color: Color(0xFF7D8592), - height: 1.4, - ), - ), - if (hasError) ...[ - SizedBox(height: 14.h), - OutlinedButton( - onPressed: () => provider.loadDiscoverData(forceRefresh: true), - child: const Text('重新加载'), - ), - ], - ], - ), - ), + return XUi.emptyState( + icon: hasError ? Icons.cloud_off_rounded : config.icon, + message: hasError + ? (provider.errorMessage ?? '暂时无法加载${config.label}') + : '暂无${config.label}内容,下拉可重新刷新', + actionLabel: hasError ? '重新加载' : null, + onAction: + hasError ? () => provider.loadDiscoverData(forceRefresh: true) : null, ); } @@ -794,72 +757,70 @@ class _DiscoverPageState extends State ThemeData theme, ) { final color = _getTypeColor(config.plazaType); - return Material( - color: Colors.white, - borderRadius: BorderRadius.circular(8.w), - child: InkWell( - onTap: () => _handleResourceTap(config, resource), - borderRadius: BorderRadius.circular(8.w), - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 14.h), - child: Row( - children: [ - Container( - width: 42.w, - height: 42.w, - decoration: BoxDecoration( - color: color.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(8.w), - ), - child: Icon( - config.icon, - size: 20.sp, - color: color, + return Container( + decoration: XUi.cardDecoration(radius: OipRadius.md, shadow: false), + clipBehavior: Clip.antiAlias, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => _handleResourceTap(config, resource), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 14.h), + child: Row( + children: [ + Container( + width: 44.w, + height: 44.w, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(OipRadius.md.w), + ), + child: Icon( + config.icon, + size: 20.sp, + color: color, + ), ), - ), - SizedBox(width: 12.w), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - resource.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 16.5, - fontWeight: FontWeight.w700, - color: Color(0xFF15181D), - ), - ), - SizedBox(height: 4.h), - Text( - _buildResourceSubtitle(config, resource), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 14, - color: Color(0xFF7D8592), - height: 1.35, + SizedBox(width: 12.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + resource.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: + XFonts.titleSmall.copyWith(color: OipText.primary), ), - ), - if (resource.isPublic) ...[ - SizedBox(height: 8.h), - _buildMetaChip( - label: '公开', - color: theme.primaryColor, + SizedBox(height: 4.h), + Text( + _buildResourceSubtitle(config, resource), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: XFonts.caption.copyWith( + color: OipText.tertiary, + height: 1.35, + ), ), + if (resource.isPublic) ...[ + SizedBox(height: 8.h), + _buildMetaChip( + label: '公开', + color: OipBrand.primary, + ), + ], ], - ], + ), ), - ), - SizedBox(width: 8.w), - Icon( - Icons.chevron_right_rounded, - size: 20.sp, - color: const Color(0xFFB7BDC7), - ), - ], + SizedBox(width: 8.w), + Icon( + Icons.chevron_right_rounded, + size: 20.sp, + color: OipText.disabled, + ), + ], + ), ), ), ), @@ -872,73 +833,17 @@ class _DiscoverPageState extends State }) { return Container( padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(999.w), - ), + decoration: XUi.softBadge(color, radius: OipRadius.full), child: Text( label, - style: TextStyle( + style: XFonts.labelSmall.copyWith( color: color, - fontSize: 12, fontWeight: FontWeight.w700, ), ), ); } - Widget _buildFullPageState({ - required IconData icon, - required String title, - String? message, - String? actionLabel, - VoidCallback? onAction, - }) { - return Center( - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 32.w), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - icon, - size: 40.sp, - color: const Color(0xFF7D8592), - ), - SizedBox(height: 12.h), - Text( - title, - style: const TextStyle( - fontSize: 16.5, - fontWeight: FontWeight.w700, - color: Color(0xFF15181D), - ), - ), - if (message != null) ...[ - SizedBox(height: 8.h), - Text( - message, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 14, - color: Color(0xFF7D8592), - height: 1.4, - ), - ), - ], - if (actionLabel != null && onAction != null) ...[ - SizedBox(height: 16.h), - ElevatedButton( - onPressed: onAction, - child: Text(actionLabel), - ), - ], - ], - ), - ), - ); - } - String _buildResourceSubtitle( DiscoverItemConfig config, PlazaResource resource, @@ -954,19 +859,39 @@ class _DiscoverPageState extends State Color _getTypeColor(PlazaType? type) { switch (type) { case PlazaType.groupShare: - return const Color(0xFF2B6AFF); + return OipBrand.primary; case PlazaType.video: - return const Color(0xFFE35D45); + return OipEntity.station; case PlazaType.live: - return const Color(0xFF8A55F7); + return OipAi.accent; case PlazaType.marketTrade: - return const Color(0xFF18A36A); + return OipState.success; case PlazaType.dataShare: - return const Color(0xFF3B82F6); + return OipBrand.primary; case PlazaType.newsAnnouncement: - return const Color(0xFFF08A24); + return OipState.warning; case null: - return const Color(0xFF6C63FF); + return OipAi.accent; } } } + +class _KeepAliveWrapper extends StatefulWidget { + final Widget child; + const _KeepAliveWrapper({required this.child}); + + @override + State<_KeepAliveWrapper> createState() => _KeepAliveWrapperState(); +} + +class _KeepAliveWrapperState extends State<_KeepAliveWrapper> + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + return widget.child; + } +} diff --git a/lib/pages/discover/discover_provider.dart b/lib/pages/discover/discover_provider.dart index 7b6764f3f29a4e531d8a097ccddd52e35153bd66..2832268339b5f9106b923be380c057b384daf684 100644 --- a/lib/pages/discover/discover_provider.dart +++ b/lib/pages/discover/discover_provider.dart @@ -9,15 +9,21 @@ class DiscoverProvider with ChangeNotifier { List _resources = []; XPlaza? _plaza; bool _isLoading = false; + /// 后台刷新标志:已有缓存数据时正在进行后台更新 + bool _isBackgroundRefreshing = false; String? _errorMessage; + bool _hasCompletedFirstLoad = false; + /// 缓存按类型过滤并排序后的结果,避免每次 build 重复计算 + final Map> _filteredCache = {}; Map get badgeCounts => Map.unmodifiable(_badgeCounts); List get resources => List.unmodifiable(_resources); XPlaza? get plaza => _plaza; bool get isLoading => _isLoading; + /// 是否正在后台刷新(用于 UI 显示"数据更新中"指示器,不影响主内容显示) + bool get isBackgroundRefreshing => _isBackgroundRefreshing; String? get errorMessage => _errorMessage; - bool get hasLoadedData => - _resources.isNotEmpty || _badgeCounts.isNotEmpty || _plaza != null; + bool get hasLoadedData => _hasCompletedFirstLoad; Future loadDiscoverData({bool forceRefresh = false}) async { if (_isLoading) return; @@ -25,9 +31,18 @@ class DiscoverProvider with ChangeNotifier { _setLoading(true); _clearError(); + // forceRefresh 时仅切空间场景才清空数据(hasLoadedData 为 false 时); + // 下拉刷新走 refreshData(),保留旧数据避免 UI 闪烁 + if (forceRefresh && !_hasCompletedFirstLoad) { + _resources = []; + _badgeCounts = {}; + _plaza = null; + _hasCompletedFirstLoad = false; + _filteredCache.clear(); + notifyListeners(); + } + try { - // 多集群聚合加载:聚合用户加入的所有空间(个人/单位/集群群组/工作群) - // 各空间返回的 plaza 信息仅用于首次展示,资源列表已合并去重排序 final aggregated = await DiscoverService.loadAggregatedPlazaResources( forceRefresh: forceRefresh, ); @@ -35,12 +50,11 @@ class DiscoverProvider with ChangeNotifier { _plaza = aggregated.plaza; _resources = aggregated.resources; - notifyListeners(); - _badgeCounts = await DiscoverService.loadBadgeCounts(resources: _resources); - notifyListeners(); + _hasCompletedFirstLoad = true; + _filteredCache.clear(); XLogUtil.d('发现数据加载完成(多集群聚合):${_resources.length} 个资源'); } catch (e) { _setError('加载数据失败: $e'); @@ -50,12 +64,67 @@ class DiscoverProvider with ChangeNotifier { } } + /// 下拉刷新:离线优先,保留旧数据显示,后台拉取新数据后替换 Future refreshData() async { - DiscoverService.clearCache(); - await loadDiscoverData(forceRefresh: true); + if (_isLoading || _isBackgroundRefreshing) return; + + // 已有缓存数据时走后台刷新路径:不阻塞 UI,保留旧数据 + if (_hasCompletedFirstLoad && _resources.isNotEmpty) { + _setBackgroundRefreshing(true); + try { + final aggregated = await DiscoverService.loadAggregatedPlazaResources( + forceRefresh: true, + ); + + _plaza = aggregated.plaza; + _resources = aggregated.resources; + + _badgeCounts = + await DiscoverService.loadBadgeCounts(resources: _resources); + + _hasCompletedFirstLoad = true; + _filteredCache.clear(); + XLogUtil.d('发现数据后台刷新完成(离线优先):${_resources.length} 个资源'); + } catch (e) { + _setError('刷新数据失败: $e'); + XLogUtil.e('DiscoverProvider 后台刷新错误: $e'); + } finally { + _setBackgroundRefreshing(false); + } + return; + } + + // 首次加载或无缓存时走常规加载路径 + _setLoading(true); + _clearError(); + + try { + final aggregated = await DiscoverService.loadAggregatedPlazaResources( + forceRefresh: true, + ); + + _plaza = aggregated.plaza; + _resources = aggregated.resources; + + _badgeCounts = + await DiscoverService.loadBadgeCounts(resources: _resources); + + _hasCompletedFirstLoad = true; + _filteredCache.clear(); + XLogUtil.d('发现数据刷新完成(首次):${_resources.length} 个资源'); + } catch (e) { + _setError('刷新数据失败: $e'); + XLogUtil.e('DiscoverProvider 刷新错误: $e'); + } finally { + _setLoading(false); + } } List findResourcesByType(PlazaType type) { + // 命中缓存直接返回,避免每次 build 重复过滤+排序 + final cached = _filteredCache[type]; + if (cached != null) return cached; + final res = _resources .where((resource) => resource.typeName == type.resourceTypeName) .toList(); @@ -73,6 +142,7 @@ class DiscoverProvider with ChangeNotifier { } return a.name.compareTo(b.name); }); + _filteredCache[type] = res; return res; } @@ -98,6 +168,8 @@ class DiscoverProvider with ChangeNotifier { _badgeCounts.clear(); _resources.clear(); _plaza = null; + _hasCompletedFirstLoad = false; + _filteredCache.clear(); _clearError(); notifyListeners(); // 同时清理DiscoverService中的静态缓存 @@ -111,6 +183,13 @@ class DiscoverProvider with ChangeNotifier { } } + void _setBackgroundRefreshing(bool value) { + if (_isBackgroundRefreshing != value) { + _isBackgroundRefreshing = value; + notifyListeners(); + } + } + void _setError(String message) { _errorMessage = message; notifyListeners(); diff --git a/lib/pages/discover/discover_service.dart b/lib/pages/discover/discover_service.dart index 72f0c346086d6585d744aa2169940ad5f1114056..fa78d5a4fbb0783b5d8e0ff3ae27015c97c3c232 100644 --- a/lib/pages/discover/discover_service.dart +++ b/lib/pages/discover/discover_service.dart @@ -1,5 +1,9 @@ +import 'dart:async'; + import 'package:orginone/config/constant.dart'; +import 'package:orginone/dart/base/common/commands.dart'; import 'package:orginone/dart/base/schema.dart'; +import 'package:orginone/dart/base/storages/local_page_cache_repository.dart'; import 'package:orginone/main.dart'; import 'package:orginone/utils/log/log_util.dart'; import 'discover_config.dart'; @@ -10,12 +14,16 @@ class PlazaResource { final String typeName; final int index; final bool isPublic; + /// 资源所属空间 ID(多集群聚合时用于区分来源) final String sourceId; + /// 资源所属空间名称(多集群聚合时展示给用户,如"个人"/"某单位"/"某集群") final String sourceName; + /// 创建时间字符串(ISO 格式),用于多集群聚合时按时间倒序排序 final String createTime; + /// 更新时间字符串(ISO 格式),用于排序 final String updateTime; @@ -47,7 +55,8 @@ class PlazaResource { String get sortTime => updateTime.isNotEmpty ? updateTime : createTime; /// 复制当前对象并补充来源信息(多集群聚合时使用) - PlazaResource withSource({required String sourceId, required String sourceName}) { + PlazaResource withSource( + {required String sourceId, required String sourceName}) { return PlazaResource( id: id, name: name, @@ -66,6 +75,7 @@ class PlazaResource { class AggregatedPlaza { /// 首个有效空间的 plaza(用于 UI 兼容显示) final XPlaza? plaza; + /// 合并去重后的资源列表(已按时间倒序排序) final List resources; @@ -99,6 +109,15 @@ class PlazaTarget { pubTime: json['pubTime'] ?? '', ); } + + Map toJson() => { + 'id': id, + 'resourceId': resourceId, + 'sourceId': sourceId, + 'name': name, + 'typeName': typeName, + 'pubTime': pubTime, + }; } class PlazaNotice { @@ -130,7 +149,9 @@ class PlazaNotice { final content = json['content'] ?? ''; // coverImage 优先取后端字段,为空时尝试从 content HTML 中提取第一张图片 var coverImage = json['coverImage'] as String?; - if ((coverImage == null || coverImage.isEmpty) && content is String && content.isNotEmpty) { + if ((coverImage == null || coverImage.isEmpty) && + content is String && + content.isNotEmpty) { coverImage = _extractFirstImageFromHtml(content); } return PlazaNotice( @@ -146,13 +167,27 @@ class PlazaNotice { remark: json['remark'], ); } + + Map toJson() => { + 'id': id, + 'resourceId': resourceId, + 'contactId': contactId, + 'pubTime': pubTime, + 'name': name, + 'coverImage': coverImage, + 'content': content, + 'belongId': belongId, + 'code': code, + 'remark': remark, + }; } /// 从 HTML content 中提取第一张图片的 src URL /// 用于公告封面缩略图(后端 coverImage 字段为空时回退) String? _extractFirstImageFromHtml(String html) { try { - final imgRegExp = RegExp(r"""]+src=["']([^"']+)["']""", caseSensitive: false); + final imgRegExp = + RegExp(r"""]+src=["']([^"']+)["']""", caseSensitive: false); final match = imgRegExp.firstMatch(html); if (match != null) { final src = match.group(1); @@ -207,6 +242,18 @@ class PlazaVideo { createTime: json['createTime'] ?? json['pubTime'] ?? '', ); } + + Map toJson() => { + 'id': id, + 'resourceId': resourceId, + 'sourceId': sourceId, + 'name': name, + 'typeName': typeName, + 'thumbnailUrl': thumbnailUrl, + 'videoUrl': videoUrl, + 'duration': duration, + 'createTime': createTime, + }; } class PlazaMarketGoods { @@ -259,6 +306,22 @@ class PlazaMarketGoods { createTime: json['createTime'] ?? json['pubTime'] ?? '', ); } + + Map toJson() => { + 'id': id, + 'resourceId': resourceId, + 'shareKey': shareKey, + 'plazaId': plazaId, + 'name': name, + 'typeName': typeName, + 'orgPrice': orgPrice, + 'price': price, + 'amount': amount, + 'imageUrls': imageUrls, + 'description': description, + 'isOnSale': isOnSale, + 'createTime': createTime, + }; } class PlazaDataShare { @@ -294,6 +357,17 @@ class PlazaDataShare { typeName: json['typeName'] ?? '', ); } + + Map toJson() => { + 'id': id, + 'resourceId': resourceId, + 'sourceId': sourceId, + 'shareKey': shareKey, + 'contactId': contactId, + 'pubTime': pubTime, + 'name': name, + 'typeName': typeName, + }; } class PlazaLive { @@ -350,6 +424,24 @@ class PlazaLive { typeName: json['typeName'] ?? '', ); } + + Map toJson() => { + 'id': id, + 'resourceId': resourceId, + 'hostId': hostId, + 'hostName': hostName, + 'hostAvatar': hostAvatar, + 'coverUrl': coverUrl, + 'liveUrl': liveUrl, + 'pubTime': pubTime, + 'startTime': startTime, + 'viewerCount': viewerCount, + 'likeCount': likeCount, + 'isLive': isLive, + 'description': description, + 'name': name, + 'typeName': typeName, + }; } class DiscoverService { @@ -490,7 +582,7 @@ class DiscoverService { final belongId = targetId; final cacheKey = _resolveCacheKey(groupId); - // 检查缓存是否有效 + // 检查内存缓存是否有效 if (!forceRefresh && _discoverCaches.containsKey(cacheKey) && _getLastLoadTime(cacheKey) != null && @@ -500,70 +592,194 @@ class DiscoverService { return _cachedPlaza(groupId); } - var res = await kernel.collectionLoad>( - targetId, - belongId, - [], - _collPlazaInfo, - { - 'options': { - 'match': {'shareId': targetId} - }, - }, - ); + // 本地优先:内存缓存为空时,先读本地存储快速渲染首屏, + // 再后台在线拉取最新数据(AGENTS.md §18.1 L1 优先) + // 仅在非 forceRefresh 时执行(forceRefresh 表示用户主动下拉刷新,应直接在线) + if (!forceRefresh && !_discoverCaches.containsKey(cacheKey)) { + final localPlaza = await _loadPlazaFromLocal(targetId); + if (localPlaza != null) { + XLogUtil.i('loadPlaza: 本地缓存优先渲染 (cluster=$targetId)'); + // 本地数据已填充到 _discoverCaches,后台继续拉取在线数据 + // 后台拉取通过 _refreshPlazaFromRemote 触发 + _refreshPlazaFromRemote(targetId, belongId, cacheKey); + return localPlaza; + } + } - XLogUtil.d('loadPlaza: targetId=$targetId, belongId=$belongId, success=${res.success}, data type=${res.data.runtimeType}'); + return await _fetchPlazaFromRemote(targetId, belongId, cacheKey); + } catch (e) { + XLogUtil.e('加载广场信息失败: $e'); + // 异常时也尝试从本地存储降级 + return _loadPlazaFromLocal( + groupId.isNotEmpty ? groupId : defaultClusterId); + } + } - if (res.success && res.data != null) { - final dataList = _extractList(res.data); - XLogUtil.d('loadPlaza: dataList length=${dataList.length}'); - if (dataList.isNotEmpty) { - var plazaData = dataList.first as Map; - var plaza = XPlaza.fromJson(plazaData); - - List resources = []; - // 1. 尝试从 plazaData 内嵌 resources 获取 - var resourcesData = plazaData['resources']; - if (resourcesData != null && resourcesData is List) { - resources = resourcesData + /// 从远端拉取 plaza 数据并更新缓存 + static Future _fetchPlazaFromRemote( + String targetId, String belongId, String cacheKey) async { + var res = await kernel.collectionLoad>( + targetId, + belongId, + [], + _collPlazaInfo, + { + 'options': { + 'match': {'shareId': targetId} + }, + }, + ); + + XLogUtil.d( + 'loadPlaza: targetId=$targetId, belongId=$belongId, success=${res.success}, data type=${res.data.runtimeType}'); + + if (res.success && res.data != null) { + final dataList = _extractList(res.data); + XLogUtil.d('loadPlaza: dataList length=${dataList.length}'); + if (dataList.isNotEmpty) { + var plazaData = dataList.first as Map; + var plaza = XPlaza.fromJson(plazaData); + + List resources = []; + // 1. 尝试从 plazaData 内嵌 resources 获取 + var resourcesData = plazaData['resources']; + if (resourcesData != null && resourcesData is List) { + resources = resourcesData + .whereType>() + .map((e) => PlazaResource.fromJson(e)) + .toList(); + } + XLogUtil.d('loadPlaza: embedded resources=${resources.length}'); + // 2. 尝试从返回数据顶层 resources key 获取 + if (resources.isEmpty && res.data is Map) { + final resData = res.data as Map; + if (resData['resources'] is List) { + resources = (resData['resources'] as List) .whereType>() .map((e) => PlazaResource.fromJson(e)) .toList(); } - XLogUtil.d('loadPlaza: embedded resources=${resources.length}'); - // 2. 尝试从返回数据顶层 resources key 获取 - if (resources.isEmpty && res.data is Map) { - final resData = res.data as Map; - if (resData['resources'] is List) { - resources = (resData['resources'] as List) - .whereType>() - .map((e) => PlazaResource.fromJson(e)) - .toList(); - } - } - XLogUtil.d('loadPlaza: after step2 resources=${resources.length}'); - // 3. 备用:直接查询 resource 集合 - if (resources.isEmpty) { - resources = await _loadPlazaResourcesDirect(plaza.id, targetId, belongId); - } - XLogUtil.d('loadPlaza: final resources=${resources.length}'); - - _discoverCaches[cacheKey] = { - 'plaza': plaza, - 'shareId': plaza.shareId, - 'belongId': plaza.belongId, - 'resources': resources, - 'loadTime': DateTime.now(), - }; - XLogUtil.d('广场信息已加载 (缓存键: $cacheKey), resources: ${resources.length}'); - - return plaza; } + XLogUtil.d('loadPlaza: after step2 resources=${resources.length}'); + // 3. 备用:直接查询 resource 集合 + if (resources.isEmpty) { + resources = + await _loadPlazaResourcesDirect(plaza.id, targetId, belongId); + } + XLogUtil.d('loadPlaza: final resources=${resources.length}'); + + _discoverCaches[cacheKey] = { + 'plaza': plaza, + 'shareId': plaza.shareId, + 'belongId': plaza.belongId, + 'resources': resources, + 'loadTime': DateTime.now(), + }; + XLogUtil.d('广场信息已加载 (缓存键: $cacheKey), resources: ${resources.length}'); + + // ★落盘到本地存储:plaza + resources 完整数据 + _persistPlazaToLocal(targetId, plaza, resources); + + return plaza; } + } - return null; + // 网络加载失败,尝试从本地存储降级读取 + return _loadPlazaFromLocal(targetId); + } + + /// 后台刷新 plaza 数据(fire-and-forget,不阻塞调用方) + /// 本地缓存优先渲染后,后台拉取最新在线数据更新缓存 + static void _refreshPlazaFromRemote( + String targetId, String belongId, String cacheKey) { + Future(() async { + try { + await _fetchPlazaFromRemote(targetId, belongId, cacheKey); + // 通知发现页刷新 + command.emitterFlag('session'); + } catch (e) { + XLogUtil.w('后台刷新 plaza 失败 (cluster=$targetId): $e'); + } + }); + } + + /// 将 plaza + resources 落盘到 LocalPageCacheRepository + static Future _persistPlazaToLocal( + String clusterId, XPlaza plaza, List resources) async { + try { + final plazaJson = plaza.toJson(); + // PlazaResource 没有 toJson 方法,手动构造 + final resourcesJson = resources + .map((r) => { + 'id': r.id, + 'name': r.name, + 'typeName': r.typeName, + 'index': r.index, + 'isPublic': r.isPublic, + 'sourceId': r.sourceId, + 'sourceName': r.sourceName, + 'createTime': r.createTime, + 'updateTime': r.updateTime, + }) + .toList(); + await Future.wait([ + LocalPageCacheRepository.instance.write( + PageCacheType.plaza, + [plazaJson], + userId: 'system', + spaceId: clusterId, + ), + LocalPageCacheRepository.instance.write( + PageCacheType.plazaResources, + resourcesJson, + userId: 'system', + spaceId: clusterId, + ), + ]); } catch (e) { - XLogUtil.e('加载广场信息失败: $e'); + XLogUtil.w('[DiscoverService] 落盘 plaza 失败: $e'); + } + } + + /// 从本地存储读取 plaza(网络失败时降级) + static Future _loadPlazaFromLocal(String clusterId) async { + try { + final entry = await LocalPageCacheRepository.instance.read( + PageCacheType.plaza, + userId: 'system', + spaceId: clusterId, + ); + if (entry == null || entry.data.isEmpty) return null; + final plazaJson = entry.data.first; + final plaza = XPlaza.fromJson(plazaJson); + + // 同时恢复 resources 到内存缓存 + final resourcesEntry = await LocalPageCacheRepository.instance.read( + PageCacheType.plazaResources, + userId: 'system', + spaceId: clusterId, + ); + final resources = []; + if (resourcesEntry != null) { + for (final json in resourcesEntry.data) { + try { + resources.add(PlazaResource.fromJson(json)); + } catch (_) {} + } + } + final cacheKey = _resolveCacheKey(clusterId); + _discoverCaches[cacheKey] = { + 'plaza': plaza, + 'shareId': plaza.shareId, + 'belongId': plaza.belongId, + 'resources': resources, + 'loadTime': DateTime.now(), + }; + XLogUtil.i( + '[DiscoverService] 从本地存储恢复 plaza (cluster=$clusterId), resources: ${resources.length}'); + return plaza; + } catch (e) { + XLogUtil.w('[DiscoverService] 读取本地 plaza 失败: $e'); return null; } } @@ -930,7 +1146,8 @@ class DiscoverService { // key: sourceId, value: 该空间下的所有资源 final bySource = >{}; for (final r in plazaResources) { - final sid = r.sourceId.isNotEmpty ? r.sourceId : (shareId ?? defaultClusterId); + final sid = + r.sourceId.isNotEmpty ? r.sourceId : (shareId ?? defaultClusterId); bySource.putIfAbsent(sid, () => []).add(r); } @@ -958,8 +1175,8 @@ class DiscoverService { .toList(); if (typeResources.isEmpty) return 0; final resourceIds = typeResources.map((r) => r.id).toList(); - final countsMap = await _loadResourceCounts( - sid, sid, collName, resourceIds); + final countsMap = + await _loadResourceCounts(sid, sid, collName, resourceIds); return countsMap.values.fold(0, (sum, c) => sum + c); }); futures.add(Future.wait(perSourceFutures).then((counts) => @@ -1016,14 +1233,21 @@ class DiscoverService { /// 清除缓存,强制刷新 /// [groupId] 可选,指定集群 ID;不传则清除全部发现缓存。 + /// 同时清理内存缓存和本地存储缓存。 static void clearCache({String? groupId}) { if (groupId != null && groupId.isNotEmpty) { final cacheKey = _resolveCacheKey(groupId); _discoverCaches.remove(cacheKey); XLogUtil.d('发现缓存已清除 (缓存键: $cacheKey)'); + // 异步清理本地存储(fire-and-forget) + LocalPageCacheRepository.instance + .clearSpace('system', groupId) + .catchError((_) {}); } else { _discoverCaches.clear(); XLogUtil.d('所有发现缓存已清除'); + // 异步清理所有集群的本地存储 + LocalPageCacheRepository.instance.clearUser('system').catchError((_) {}); } } @@ -1039,8 +1263,8 @@ class DiscoverService { if (user == null) { // 用户未登录时回退到默认集群 final plaza = await loadPlaza(forceRefresh: forceRefresh); - final resources = await loadPlazaResources('', - forceRefresh: forceRefresh); + final resources = + await loadPlazaResources('', forceRefresh: forceRefresh); return AggregatedPlaza(plaza: plaza, resources: resources); } @@ -1062,15 +1286,15 @@ class DiscoverService { } // 单位 + 单位下的 cohorts/groups for (final company in user.companys) { - spaces.add(_SpaceRef( - id: company.id, name: company.name, type: 'company')); + spaces + .add(_SpaceRef(id: company.id, name: company.name, type: 'company')); for (final cohort in company.cohorts) { - spaces.add(_SpaceRef( - id: cohort.id, name: cohort.name, type: 'companyCohort')); + spaces.add( + _SpaceRef(id: cohort.id, name: cohort.name, type: 'companyCohort')); } for (final group in company.groups) { - spaces.add(_SpaceRef( - id: group.id, name: group.name, type: 'companyGroup')); + spaces.add( + _SpaceRef(id: group.id, name: group.name, type: 'companyGroup')); } } // 去重(按 id),保留第一个出现的(优先个人/单位) @@ -1125,10 +1349,10 @@ class DiscoverService { bool forceRefresh = false, }) async { try { - final plaza = await loadPlaza( - groupId: space.id, forceRefresh: forceRefresh); - final resources = await loadPlazaResources(space.id, - forceRefresh: forceRefresh); + final plaza = + await loadPlaza(groupId: space.id, forceRefresh: forceRefresh); + final resources = + await loadPlazaResources(space.id, forceRefresh: forceRefresh); // 为每个资源补充来源信息 final withSource = resources .map((r) => r.withSource(sourceId: space.id, sourceName: space.name)) diff --git a/lib/pages/discover/pages/all_activities_page.dart b/lib/pages/discover/pages/all_activities_page.dart index b9b08a714d75e6c0d76bf2bd8bcd62159ad51d64..58f64e59ee3f7268bd6b39f0ba6d5c9bb5aa17b2 100644 --- a/lib/pages/discover/pages/all_activities_page.dart +++ b/lib/pages/discover/pages/all_activities_page.dart @@ -61,6 +61,7 @@ class _AllActivitiesPageState extends State IActivity? _groupPublishActivity; bool _isLoading = true; bool _isLoadingMore = false; + bool _isBackgroundRefreshing = false; String? _errorMsg; int _personalDisplayCount = _pageSize; int _groupDisplayCount = _pageSize; @@ -90,14 +91,8 @@ class _AllActivitiesPageState extends State _loadData(); }, false); _subscribers.add(sessionId); - - // 订阅 switchSpace 事件:切换单位后清空缓存并强制重新加载 - final switchId = command.subscribeByFlag('switchSpace', ([args]) { - if (!mounted) return; - ActivityRepository.instance.invalidateCache(); - _loadData(); - }, false); - _subscribers.add(switchId); + // 发现页不跟随单位切换刷新数据,保持本地缓存优先策略, + // 加速页面渲染速度。单位切换只刷新门户页面数据。 } @override @@ -152,7 +147,39 @@ class _AllActivitiesPageState extends State } } - Future _loadData() async { + /// 离线优先加载:优先使用内存缓存数据立即渲染,后台拉取最新数据 + /// + /// 对齐 AGENTS.md §18.1「本地优先 + 后台更新」: + /// - 首次进入页面时,ActivityRepository 内部已缓存动态数据(5 分钟有效期), + /// 优先使用缓存数据立即渲染,避免白屏等待 + /// - 后台拉取最新数据,完成后替换并刷新 UI + /// - 切换 Tab 或下拉刷新时强制在线拉取 + Future _loadData({bool forceRefresh = false}) async { + final hasCachedData = + _personalMessages.isNotEmpty || _groupMessages.isNotEmpty; + + // 已有缓存数据时走后台刷新路径,不阻塞 UI + if (!forceRefresh && hasCachedData) { + if (mounted) { + setState(() { + _isBackgroundRefreshing = true; + _errorMsg = null; + }); + } + try { + final allMessages = await _repository.loadAllActivities(reload: true); + if (!mounted) return; + _applyLoadedMessages(allMessages); + setState(() => _isBackgroundRefreshing = false); + } catch (e) { + XLogUtil.e('后台刷新动态数据失败: $e'); + if (!mounted) return; + setState(() => _isBackgroundRefreshing = false); + } + return; + } + + // 首次加载或无缓存时走常规加载路径 if (mounted) { setState(() { _isLoading = true; @@ -164,29 +191,12 @@ class _AllActivitiesPageState extends State } try { - final allMessages = await _repository.loadAllActivities(reload: true); - - final personalMessages = - allMessages.where((msg) => !msg.activity.session.isGroup).toList(); - final groupMessages = - allMessages.where((msg) => msg.activity.session.isGroup).toList(); - - final personalPublishActivity = - _repository.resolvePublishActivity(isGroup: false); - final groupPublishActivity = - _repository.resolvePublishActivity(isGroup: true); - + // reload=false 优先使用内存缓存(ActivityRepository 内部 5 分钟有效期) + final allMessages = + await _repository.loadAllActivities(reload: forceRefresh); if (!mounted) return; - - setState(() { - _personalMessages = personalMessages; - _groupMessages = groupMessages; - _personalPublishActivity = personalPublishActivity; - _groupPublishActivity = groupPublishActivity; - _personalDisplayCount = _pageSize; - _groupDisplayCount = _pageSize; - _isLoading = false; - }); + _applyLoadedMessages(allMessages); + setState(() => _isLoading = false); } catch (e) { XLogUtil.e('加载动态数据失败: $e'); if (!mounted) return; @@ -198,6 +208,29 @@ class _AllActivitiesPageState extends State } } + void _applyLoadedMessages(List allMessages) { + final personalMessages = + allMessages.where((msg) => !msg.activity.session.isGroup).toList(); + final groupMessages = + allMessages.where((msg) => msg.activity.session.isGroup).toList(); + + final personalPublishActivity = + _repository.resolvePublishActivity(isGroup: false); + final groupPublishActivity = + _repository.resolvePublishActivity(isGroup: true); + + if (!mounted) return; + + setState(() { + _personalMessages = personalMessages; + _groupMessages = groupMessages; + _personalPublishActivity = personalPublishActivity; + _groupPublishActivity = groupPublishActivity; + _personalDisplayCount = _pageSize; + _groupDisplayCount = _pageSize; + }); + } + IActivity? get _currentPublishActivity { return _tabController.index == 0 ? _personalPublishActivity @@ -217,7 +250,7 @@ class _AllActivitiesPageState extends State return Scaffold( backgroundColor: OipSurface.background, appBar: AppBar( - title: const Text('动态'), + title: Text('动态', style: XFonts.headlineSmall), backgroundColor: OipSurface.card, foregroundColor: OipText.primary, elevation: 0, @@ -225,8 +258,6 @@ class _AllActivitiesPageState extends State indicatorColor: OipBrand.primary, labelColor: OipText.primary, unselectedLabelColor: OipText.tertiary, - labelFontSize: 16, - unselectedLabelFontSize: 14, ), ), body: body, @@ -249,10 +280,34 @@ class _AllActivitiesPageState extends State indicatorColor: OipBrand.primary, labelColor: OipText.primary, unselectedLabelColor: OipText.tertiary, - labelFontSize: 14, - unselectedLabelFontSize: 14, ), ), + if (_isBackgroundRefreshing) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 6), + color: const Color(0xFFFFF8E1), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: Colors.orange, + ), + ), + const SizedBox(width: 6), + Text( + '数据更新中', + style: TextStyle( + fontSize: 11, + color: Colors.orange.shade700), + ), + ], + ), + ), Expanded( child: _buildTabContent(context), ), @@ -264,7 +319,7 @@ class _AllActivitiesPageState extends State Widget _buildErrorView() { return RefreshIndicator( - onRefresh: _loadData, + onRefresh: () => _loadData(forceRefresh: true), child: ListView( physics: const AlwaysScrollableScrollPhysics( parent: BouncingScrollPhysics(), @@ -280,8 +335,8 @@ class _AllActivitiesPageState extends State const SizedBox(height: 16), Text( _errorMsg ?? '加载失败', - style: const TextStyle( - fontSize: 14, color: OipText.secondary), + style: + const TextStyle(fontSize: 14, color: OipText.secondary), textAlign: TextAlign.center, ), const SizedBox(height: 16), @@ -321,8 +376,6 @@ class _AllActivitiesPageState extends State required Color indicatorColor, required Color labelColor, required Color unselectedLabelColor, - required double labelFontSize, - required double unselectedLabelFontSize, }) { return TabBar( controller: _tabController, @@ -334,11 +387,8 @@ class _AllActivitiesPageState extends State indicatorWeight: 3, labelColor: labelColor, unselectedLabelColor: unselectedLabelColor, - labelStyle: TextStyle( - fontSize: labelFontSize, - fontWeight: FontWeight.w600, - ), - unselectedLabelStyle: TextStyle(fontSize: unselectedLabelFontSize), + labelStyle: XFonts.tabLabel, + unselectedLabelStyle: XFonts.tabUnselected, ); } @@ -373,7 +423,7 @@ class _AllActivitiesPageState extends State }) { if (messages.isEmpty) { return RefreshIndicator( - onRefresh: _loadData, + onRefresh: () => _loadData(forceRefresh: true), child: ListView( physics: const AlwaysScrollableScrollPhysics( parent: BouncingScrollPhysics(), @@ -408,7 +458,7 @@ class _AllActivitiesPageState extends State final hasMore = messages.length > displayCount; return RefreshIndicator( - onRefresh: _loadData, + onRefresh: () => _loadData(forceRefresh: true), child: ListView.builder( controller: scrollController, physics: const AlwaysScrollableScrollPhysics( diff --git a/lib/pages/discover/pages/data_share_list_page.dart b/lib/pages/discover/pages/data_share_list_page.dart index 41ce14fb7de03059c87e64f8bc64b483d3652566..2f297bc7158787a84061a3edaeac541d0e6b8103 100644 --- a/lib/pages/discover/pages/data_share_list_page.dart +++ b/lib/pages/discover/pages/data_share_list_page.dart @@ -2,14 +2,19 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:orginone/components/LoadingWidget/LoadingWidget.dart'; import 'package:orginone/config/oip_tokens.dart'; +import 'package:orginone/config/theme/unified_style.dart'; import 'package:orginone/dart/base/common/commands.dart'; -import 'package:orginone/pages/discover/widgets/plaza_item_widgets.dart'; +import 'package:orginone/dart/base/storages/local_page_cache_repository.dart'; +import 'package:orginone/main.dart'; +import 'package:orginone/pages/discover/widgets/discover_list_cache.dart'; +import 'package:orginone/pages/discover/widgets/plaza_style_cards.dart'; import 'package:orginone/utils/log/log_util.dart'; import '../discover_service.dart'; class DataShareListPage extends StatefulWidget { final String resourceId; final String title; + /// 资源来源空间 ID(多集群聚合时用于查询对应集群的 collection) final String? sourceId; @@ -26,7 +31,12 @@ class DataShareListPage extends StatefulWidget { class _DataShareListPageState extends State { List _items = []; - bool _isLoading = true; + + /// 是否首次加载(无缓存数据时显示 LoadingWidget) + bool _isInitialLoading = true; + + /// 是否正在后台刷新(有缓存数据时显示顶部"数据更新中"指示器) + bool _isBackgroundRefreshing = false; bool _isLoadingMore = false; String? _errorMsg; int _currentPage = 1; @@ -39,13 +49,7 @@ class _DataShareListPageState extends State { @override void initState() { super.initState(); - _loadData(); - final switchId = command.subscribeByFlag('switchSpace', ([args]) { - if (!mounted) return; - DiscoverService.clearCache(); - _loadData(refresh: true); - }, false); - _subscribers.add(switchId); + _loadInitial(); } @override @@ -57,26 +61,43 @@ class _DataShareListPageState extends State { super.dispose(); } - Future _loadData({bool refresh = false}) async { - if (refresh) { + /// 离线优先加载:先读持久化缓存立即渲染,后台拉取新数据 + Future _loadInitial() async { + final cacheKey = DiscoverListCache.buildKey( + resourceId: widget.resourceId, + sourceId: widget.sourceId, + ); + final userId = relationCtrl.user?.id ?? 'anon'; + final spaceId = widget.sourceId; + + // 1. 先读持久化缓存(内存 → 磁盘) + final cached = await DiscoverListCache.readPersistent( + cacheKey, + dataType: PageCacheType.discoverDataShare, + userId: userId, + spaceId: spaceId, + fromJson: PlazaDataShare.fromJson, + ); + if (cached != null && cached.isNotEmpty) { if (!mounted) return; setState(() { - _currentPage = 1; - _hasMore = true; - _isLoading = true; - _errorMsg = null; + _items = List.from(cached); + _isInitialLoading = false; + _hasMore = cached.length >= _pageSize; + _isBackgroundRefreshing = true; }); } + // 2. 后台拉取第一页 try { - // 确保plaza信息已加载(带缓存) + // 确保 plaza 信息已加载(带缓存,命中时直接返回) if (widget.sourceId == null || widget.sourceId!.isEmpty) { await DiscoverService.loadPlaza(groupId: ''); } - var data = await DiscoverService.loadDataShares( + final data = await DiscoverService.loadDataShares( widget.resourceId, - page: _currentPage, + page: 1, pageSize: _pageSize, filter: _searchText, sourceId: widget.sourceId, @@ -84,34 +105,122 @@ class _DataShareListPageState extends State { if (!mounted) return; setState(() { - if (refresh) { + if (data.isNotEmpty || _items.isEmpty) { _items = data; - } else { - _items.addAll(data); + _hasMore = data.length >= _pageSize; + if (data.isNotEmpty) { + DiscoverListCache.writePersistent( + cacheKey, + data, + dataType: PageCacheType.discoverDataShare, + userId: userId, + spaceId: spaceId, + toJson: (item) => item.toJson(), + ); + } } - _isLoading = false; - _isLoadingMore = false; - _hasMore = data.length >= _pageSize; + _isInitialLoading = false; + _isBackgroundRefreshing = false; _errorMsg = null; }); } catch (e, st) { XLogUtil.e('加载数据分享失败: $e\n$st'); if (!mounted) return; setState(() { - _isLoading = false; - _isLoadingMore = false; - _errorMsg = '加载失败,请下拉刷新重试'; + _isInitialLoading = false; + _isBackgroundRefreshing = false; + if (_items.isEmpty) { + _errorMsg = '加载失败,请下拉刷新重试'; + } + }); + } + } + + /// 下拉刷新:保留旧数据,后台拉取新数据 + Future _refresh() async { + if (_isBackgroundRefreshing) return; + setState(() { + _isBackgroundRefreshing = true; + _errorMsg = null; + }); + + try { + final data = await DiscoverService.loadDataShares( + widget.resourceId, + page: 1, + pageSize: _pageSize, + filter: _searchText, + sourceId: widget.sourceId, + ); + + if (!mounted) return; + setState(() { + _items = data; + _currentPage = 1; + _hasMore = data.length >= _pageSize; + _isBackgroundRefreshing = false; + _errorMsg = null; + if (data.isNotEmpty) { + final cacheKey = DiscoverListCache.buildKey( + resourceId: widget.resourceId, + sourceId: widget.sourceId, + ); + DiscoverListCache.writePersistent( + cacheKey, + data, + dataType: PageCacheType.discoverDataShare, + userId: relationCtrl.user?.id ?? 'anon', + spaceId: widget.sourceId, + toJson: (item) => item.toJson(), + ); + } + }); + } catch (e) { + if (!mounted) return; + setState(() { + _isBackgroundRefreshing = false; + if (_items.isEmpty) { + _errorMsg = '刷新失败,请重试'; + } }); } } void _loadMore() { - if (!_hasMore || _isLoading || _isLoadingMore) return; + if (!_hasMore || + _isInitialLoading || + _isBackgroundRefreshing || + _isLoadingMore) { + return; + } setState(() { _currentPage++; _isLoadingMore = true; }); - _loadData(); + _loadMoreData(); + } + + Future _loadMoreData() async { + try { + final data = await DiscoverService.loadDataShares( + widget.resourceId, + page: _currentPage, + pageSize: _pageSize, + filter: _searchText, + sourceId: widget.sourceId, + ); + if (!mounted) return; + setState(() { + _items.addAll(data); + _isLoadingMore = false; + _hasMore = data.length >= _pageSize; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _isLoadingMore = false; + }); + } } Widget _buildSearchField() { @@ -129,26 +238,25 @@ class _DataShareListPageState extends State { borderRadius: BorderRadius.circular(OipRadius.full), borderSide: BorderSide.none, ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, vertical: 12), + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), ), onChanged: (value) { - setState(() { - _searchText = value; - }); + _searchText = value; _searchDebounce?.cancel(); _searchDebounce = Timer(const Duration(milliseconds: 500), () { - _loadData(refresh: true); + if (!mounted) return; + _refresh(); }); }, - onSubmitted: (_) => _loadData(refresh: true), + onSubmitted: (_) => _refresh(), ), ); } Widget _buildErrorView() { return RefreshIndicator( - onRefresh: () => _loadData(refresh: true), + onRefresh: _refresh, child: ListView( physics: const AlwaysScrollableScrollPhysics( parent: BouncingScrollPhysics(), @@ -164,13 +272,13 @@ class _DataShareListPageState extends State { const SizedBox(height: 16), Text( _errorMsg ?? '加载失败', - style: const TextStyle( - fontSize: 14, color: OipText.secondary), + style: + const TextStyle(fontSize: 14, color: OipText.secondary), textAlign: TextAlign.center, ), const SizedBox(height: 16), OutlinedButton.icon( - onPressed: () => _loadData(refresh: true), + onPressed: _refresh, icon: const Icon(Icons.refresh, size: 18), label: const Text('重试'), style: OutlinedButton.styleFrom( @@ -194,7 +302,7 @@ class _DataShareListPageState extends State { return Scaffold( backgroundColor: OipSurface.background, appBar: AppBar( - title: Text(widget.title), + title: Text(widget.title, style: XFonts.headlineSmall), backgroundColor: OipSurface.card, foregroundColor: OipText.primary, elevation: 0, @@ -203,13 +311,38 @@ class _DataShareListPageState extends State { body: Column( children: [ _buildSearchField(), + // 后台刷新时显示顶部"数据更新中"指示器,保留已有数据 + if (_isBackgroundRefreshing && _items.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + color: const Color(0xFFFFF8E1), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: Colors.orange, + ), + ), + const SizedBox(width: 6), + Text( + '数据更新中', + style: + TextStyle(fontSize: 11, color: Colors.orange.shade700), + ), + ], + ), + ), Expanded( - child: _isLoading && _items.isEmpty + child: _isInitialLoading && _items.isEmpty ? const LoadingWidget() : _errorMsg != null && _items.isEmpty ? _buildErrorView() : RefreshIndicator( - onRefresh: () => _loadData(refresh: true), + onRefresh: _refresh, child: NotificationListener( onNotification: (notification) { if (notification is ScrollEndNotification && @@ -256,7 +389,7 @@ class _DataShareListPageState extends State { } Widget _buildItemCard(PlazaDataShare item) { - return PlazaItemCard( + return DataStyleCard( item: item, sourceName: widget.title, sourceId: widget.sourceId, diff --git a/lib/pages/discover/pages/group_share_list_page.dart b/lib/pages/discover/pages/group_share_list_page.dart index ab0e6232a07e9cbf93bfc16fe4b897f79d064ab9..d4d249caf745e9c8c45f641d6c78cb4fa8ca70fb 100644 --- a/lib/pages/discover/pages/group_share_list_page.dart +++ b/lib/pages/discover/pages/group_share_list_page.dart @@ -2,14 +2,19 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:orginone/components/LoadingWidget/LoadingWidget.dart'; import 'package:orginone/config/oip_tokens.dart'; +import 'package:orginone/config/theme/unified_style.dart'; import 'package:orginone/dart/base/common/commands.dart'; +import 'package:orginone/dart/base/storages/local_page_cache_repository.dart'; +import 'package:orginone/main.dart'; +import 'package:orginone/pages/discover/widgets/discover_list_cache.dart'; import 'package:orginone/utils/log/log_util.dart'; import '../discover_service.dart'; -import '../widgets/plaza_item_widgets.dart'; +import '../widgets/plaza_style_cards.dart'; class GroupShareListPage extends StatefulWidget { final String resourceId; final String title; + /// 资源来源空间 ID(多集群聚合时用于查询对应集群的 collection) final String? sourceId; @@ -26,7 +31,12 @@ class GroupShareListPage extends StatefulWidget { class _GroupShareListPageState extends State { List _items = []; - bool _isLoading = true; + + /// 是否首次加载(无缓存数据时显示 LoadingWidget) + bool _isInitialLoading = true; + + /// 是否正在后台刷新(有缓存数据时显示顶部"数据更新中"指示器) + bool _isBackgroundRefreshing = false; bool _isLoadingMore = false; String? _errorMsg; int _currentPage = 1; @@ -39,13 +49,7 @@ class _GroupShareListPageState extends State { @override void initState() { super.initState(); - _loadData(); - final switchId = command.subscribeByFlag('switchSpace', ([args]) { - if (!mounted) return; - DiscoverService.clearCache(); - _loadData(refresh: true); - }, false); - _subscribers.add(switchId); + _loadInitial(); } @override @@ -57,26 +61,43 @@ class _GroupShareListPageState extends State { super.dispose(); } - Future _loadData({bool refresh = false}) async { - if (refresh) { + /// 离线优先加载:先读持久化缓存立即渲染,后台拉取新数据 + Future _loadInitial() async { + final cacheKey = DiscoverListCache.buildKey( + resourceId: widget.resourceId, + sourceId: widget.sourceId, + ); + final userId = relationCtrl.user?.id ?? 'anon'; + final spaceId = widget.sourceId; + + // 1. 先读持久化缓存(内存 → 磁盘) + final cached = await DiscoverListCache.readPersistent( + cacheKey, + dataType: PageCacheType.discoverGroupShare, + userId: userId, + spaceId: spaceId, + fromJson: PlazaTarget.fromJson, + ); + if (cached != null && cached.isNotEmpty) { if (!mounted) return; setState(() { - _currentPage = 1; - _hasMore = true; - _isLoading = true; - _errorMsg = null; + _items = List.from(cached); + _isInitialLoading = false; + _hasMore = cached.length >= _pageSize; + _isBackgroundRefreshing = true; }); } + // 2. 后台拉取第一页 try { - // 确保plaza信息已加载(带缓存) + // 确保 plaza 信息已加载(带缓存,命中时直接返回) if (widget.sourceId == null || widget.sourceId!.isEmpty) { await DiscoverService.loadPlaza(groupId: ''); } - var data = await DiscoverService.loadGroupShares( + final data = await DiscoverService.loadGroupShares( widget.resourceId, - page: _currentPage, + page: 1, pageSize: _pageSize, filter: _searchText, sourceId: widget.sourceId, @@ -84,34 +105,122 @@ class _GroupShareListPageState extends State { if (!mounted) return; setState(() { - if (refresh) { + if (data.isNotEmpty || _items.isEmpty) { _items = data; - } else { - _items.addAll(data); + _hasMore = data.length >= _pageSize; + if (data.isNotEmpty) { + DiscoverListCache.writePersistent( + cacheKey, + data, + dataType: PageCacheType.discoverGroupShare, + userId: userId, + spaceId: spaceId, + toJson: (item) => item.toJson(), + ); + } } - _isLoading = false; - _isLoadingMore = false; - _hasMore = data.length >= _pageSize; + _isInitialLoading = false; + _isBackgroundRefreshing = false; _errorMsg = null; }); } catch (e, st) { XLogUtil.e('加载群组动态失败: $e\n$st'); if (!mounted) return; setState(() { - _isLoading = false; - _isLoadingMore = false; - _errorMsg = '加载失败,请下拉刷新重试'; + _isInitialLoading = false; + _isBackgroundRefreshing = false; + if (_items.isEmpty) { + _errorMsg = '加载失败,请下拉刷新重试'; + } + }); + } + } + + /// 下拉刷新:保留旧数据,后台拉取新数据 + Future _refresh() async { + if (_isBackgroundRefreshing) return; + setState(() { + _isBackgroundRefreshing = true; + _errorMsg = null; + }); + + try { + final data = await DiscoverService.loadGroupShares( + widget.resourceId, + page: 1, + pageSize: _pageSize, + filter: _searchText, + sourceId: widget.sourceId, + ); + + if (!mounted) return; + setState(() { + _items = data; + _currentPage = 1; + _hasMore = data.length >= _pageSize; + _isBackgroundRefreshing = false; + _errorMsg = null; + if (data.isNotEmpty) { + final cacheKey = DiscoverListCache.buildKey( + resourceId: widget.resourceId, + sourceId: widget.sourceId, + ); + DiscoverListCache.writePersistent( + cacheKey, + data, + dataType: PageCacheType.discoverGroupShare, + userId: relationCtrl.user?.id ?? 'anon', + spaceId: widget.sourceId, + toJson: (item) => item.toJson(), + ); + } + }); + } catch (e) { + if (!mounted) return; + setState(() { + _isBackgroundRefreshing = false; + if (_items.isEmpty) { + _errorMsg = '刷新失败,请重试'; + } }); } } void _loadMore() { - if (!_hasMore || _isLoading || _isLoadingMore) return; + if (!_hasMore || + _isInitialLoading || + _isBackgroundRefreshing || + _isLoadingMore) { + return; + } setState(() { _currentPage++; _isLoadingMore = true; }); - _loadData(); + _loadMoreData(); + } + + Future _loadMoreData() async { + try { + final data = await DiscoverService.loadGroupShares( + widget.resourceId, + page: _currentPage, + pageSize: _pageSize, + filter: _searchText, + sourceId: widget.sourceId, + ); + if (!mounted) return; + setState(() { + _items.addAll(data); + _isLoadingMore = false; + _hasMore = data.length >= _pageSize; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _isLoadingMore = false; + }); + } } Widget _buildSearchField() { @@ -133,22 +242,21 @@ class _GroupShareListPageState extends State { const EdgeInsets.symmetric(horizontal: 16, vertical: 12), ), onChanged: (value) { - setState(() { - _searchText = value; - }); + _searchText = value; _searchDebounce?.cancel(); _searchDebounce = Timer(const Duration(milliseconds: 500), () { - _loadData(refresh: true); + if (!mounted) return; + _refresh(); }); }, - onSubmitted: (_) => _loadData(refresh: true), + onSubmitted: (_) => _refresh(), ), ); } Widget _buildErrorView() { return RefreshIndicator( - onRefresh: () => _loadData(refresh: true), + onRefresh: _refresh, child: ListView( physics: const AlwaysScrollableScrollPhysics( parent: BouncingScrollPhysics(), @@ -164,13 +272,13 @@ class _GroupShareListPageState extends State { const SizedBox(height: 16), Text( _errorMsg ?? '加载失败', - style: const TextStyle( - fontSize: 14, color: OipText.secondary), + style: + const TextStyle(fontSize: 14, color: OipText.secondary), textAlign: TextAlign.center, ), const SizedBox(height: 16), OutlinedButton.icon( - onPressed: () => _loadData(refresh: true), + onPressed: _refresh, icon: const Icon(Icons.refresh, size: 18), label: const Text('重试'), style: OutlinedButton.styleFrom( @@ -194,7 +302,7 @@ class _GroupShareListPageState extends State { return Scaffold( backgroundColor: OipSurface.background, appBar: AppBar( - title: Text(widget.title), + title: Text(widget.title, style: XFonts.headlineSmall), backgroundColor: OipSurface.card, foregroundColor: OipText.primary, elevation: 0, @@ -203,13 +311,38 @@ class _GroupShareListPageState extends State { body: Column( children: [ _buildSearchField(), + // 后台刷新时显示顶部"数据更新中"指示器,保留已有数据 + if (_isBackgroundRefreshing && _items.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + color: const Color(0xFFFFF8E1), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: Colors.orange, + ), + ), + const SizedBox(width: 6), + Text( + '数据更新中', + style: + TextStyle(fontSize: 11, color: Colors.orange.shade700), + ), + ], + ), + ), Expanded( - child: _isLoading && _items.isEmpty + child: _isInitialLoading && _items.isEmpty ? const LoadingWidget() : _errorMsg != null && _items.isEmpty ? _buildErrorView() : RefreshIndicator( - onRefresh: () => _loadData(refresh: true), + onRefresh: _refresh, child: NotificationListener( onNotification: (notification) { if (notification is ScrollEndNotification && @@ -220,9 +353,17 @@ class _GroupShareListPageState extends State { } return false; }, - child: ListView.builder( + // 小红书风格:双列瀑布流 + child: GridView.builder( padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 8), + horizontal: 10, vertical: 8), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + childAspectRatio: 0.72, + ), itemCount: _hasMore ? _items.length + 1 : _items.length, itemBuilder: (context, index) { @@ -256,7 +397,7 @@ class _GroupShareListPageState extends State { } Widget _buildItemCard(PlazaTarget item) { - return PlazaItemCard( + return GroupStyleCard( item: item, sourceName: widget.title, sourceId: widget.sourceId, diff --git a/lib/pages/discover/pages/live_list_page.dart b/lib/pages/discover/pages/live_list_page.dart index 02578f58129b8dc2537dfe9ffde7982f7ac6134a..25e17d9dda824aec34684711899390073abe71f6 100644 --- a/lib/pages/discover/pages/live_list_page.dart +++ b/lib/pages/discover/pages/live_list_page.dart @@ -5,7 +5,11 @@ import 'package:orginone/components/LoadingWidget/LoadingWidget.dart'; import 'package:orginone/components/Tip/ToastUtils.dart'; import 'package:orginone/components/XImage/ImageWidget.dart'; import 'package:orginone/config/oip_tokens.dart'; +import 'package:orginone/config/theme/unified_style.dart'; import 'package:orginone/dart/base/common/commands.dart'; +import 'package:orginone/dart/base/storages/local_page_cache_repository.dart'; +import 'package:orginone/main.dart'; +import 'package:orginone/pages/discover/widgets/discover_list_cache.dart'; import 'package:orginone/utils/log/log_util.dart'; import '../discover_service.dart'; @@ -39,12 +43,8 @@ class _LiveListPageState extends State { void initState() { super.initState(); _loadData(); - final switchId = command.subscribeByFlag('switchSpace', ([args]) { - if (!mounted) return; - DiscoverService.clearCache(); - _loadData(refresh: true); - }, false); - _subscribers.add(switchId); + // 发现页不跟随单位切换刷新数据,保持本地缓存优先策略, + // 加速页面渲染速度。单位切换只刷新门户页面数据。 } @override @@ -57,6 +57,9 @@ class _LiveListPageState extends State { } Future _loadData({bool refresh = false}) async { + final isInitialLoad = !refresh && _items.isEmpty && _currentPage == 1; + bool didShowCache = false; + if (refresh) { if (!mounted) return; setState(() { @@ -65,6 +68,28 @@ class _LiveListPageState extends State { _isLoading = true; _errorMsg = null; }); + } else if (isInitialLoad) { + // 持久化缓存优先:内存 → 磁盘 + final cacheKey = DiscoverListCache.buildKey( + resourceId: widget.resourceId, + ); + final userId = relationCtrl.user?.id ?? 'anon'; + const spaceId = DiscoverService.defaultClusterId; + final cached = await DiscoverListCache.readPersistent( + cacheKey, + dataType: PageCacheType.discoverLive, + userId: userId, + spaceId: spaceId, + fromJson: PlazaLive.fromJson, + ); + if (cached != null && cached.isNotEmpty && mounted) { + setState(() { + _items = List.from(cached); + _isLoading = false; + _hasMore = cached.length >= _pageSize; + }); + didShowCache = true; + } } try { @@ -80,7 +105,7 @@ class _LiveListPageState extends State { if (!mounted) return; setState(() { - if (refresh) { + if (refresh || didShowCache) { _items = data; } else { _items.addAll(data); @@ -90,6 +115,22 @@ class _LiveListPageState extends State { _hasMore = data.length >= _pageSize; _errorMsg = null; }); + // 持久化写入(仅首页数据:refresh 或 initial load) + if ((refresh || isInitialLoad) && data.isNotEmpty) { + final cacheKey = DiscoverListCache.buildKey( + resourceId: widget.resourceId, + ); + final userId = relationCtrl.user?.id ?? 'anon'; + const spaceId = DiscoverService.defaultClusterId; + DiscoverListCache.writePersistent( + cacheKey, + data, + dataType: PageCacheType.discoverLive, + userId: userId, + spaceId: spaceId, + toJson: (item) => item.toJson(), + ); + } } catch (e, st) { XLogUtil.e('加载直播失败: $e\n$st'); if (!mounted) return; @@ -160,8 +201,8 @@ class _LiveListPageState extends State { const SizedBox(height: 16), Text( _errorMsg ?? '加载失败', - style: const TextStyle( - fontSize: 14, color: OipText.secondary), + style: + const TextStyle(fontSize: 14, color: OipText.secondary), textAlign: TextAlign.center, ), const SizedBox(height: 16), @@ -190,7 +231,7 @@ class _LiveListPageState extends State { return Scaffold( backgroundColor: OipSurface.background, appBar: AppBar( - title: Text(widget.title), + title: Text(widget.title, style: XFonts.headlineSmall), backgroundColor: OipSurface.card, foregroundColor: OipText.primary, elevation: 0, @@ -722,7 +763,8 @@ class _LiveListPageState extends State { label: const Text('分享'), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 14), - side: const BorderSide(color: OipBrand.primaryBorder), + side: const BorderSide( + color: OipBrand.primaryBorder), foregroundColor: OipBrand.primary, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), @@ -843,8 +885,7 @@ class _LiveListPageState extends State { HapticFeedback.lightImpact(); final url = item.liveUrl; if (url == null || url.isEmpty) { - ToastUtils.showMsg( - msg: item.isLive ? '当前直播暂无可进入的链接' : '暂无回放链接'); + ToastUtils.showMsg(msg: item.isLive ? '当前直播暂无可进入的链接' : '暂无回放链接'); return; } try { diff --git a/lib/pages/discover/pages/market_list_page.dart b/lib/pages/discover/pages/market_list_page.dart index 397022aa864d0efddc2173a03ddf38e88bf4fc29..67b8618bbaf191b675f8a968419b7864ef697f9d 100644 --- a/lib/pages/discover/pages/market_list_page.dart +++ b/lib/pages/discover/pages/market_list_page.dart @@ -2,14 +2,19 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:orginone/components/LoadingWidget/LoadingWidget.dart'; import 'package:orginone/config/oip_tokens.dart'; +import 'package:orginone/config/theme/unified_style.dart'; import 'package:orginone/dart/base/common/commands.dart'; -import 'package:orginone/pages/discover/widgets/plaza_item_widgets.dart'; +import 'package:orginone/dart/base/storages/local_page_cache_repository.dart'; +import 'package:orginone/main.dart'; +import 'package:orginone/pages/discover/widgets/discover_list_cache.dart'; +import 'package:orginone/pages/discover/widgets/plaza_style_cards.dart'; import 'package:orginone/utils/log/log_util.dart'; import '../discover_service.dart'; class MarketListPage extends StatefulWidget { final String resourceId; final String title; + /// 资源来源空间 ID(多集群聚合时用于查询对应集群的 collection) final String? sourceId; @@ -26,7 +31,12 @@ class MarketListPage extends StatefulWidget { class _MarketListPageState extends State { List _items = []; - bool _isLoading = true; + + /// 是否首次加载(无缓存数据时显示 LoadingWidget) + bool _isInitialLoading = true; + + /// 是否正在后台刷新(有缓存数据时显示顶部"数据更新中"指示器) + bool _isBackgroundRefreshing = false; bool _isLoadingMore = false; String? _errorMsg; int _currentPage = 1; @@ -40,13 +50,7 @@ class _MarketListPageState extends State { @override void initState() { super.initState(); - _loadData(); - final switchId = command.subscribeByFlag('switchSpace', ([args]) { - if (!mounted) return; - DiscoverService.clearCache(); - _loadData(refresh: true); - }, false); - _subscribers.add(switchId); + _loadInitial(); } @override @@ -58,26 +62,43 @@ class _MarketListPageState extends State { super.dispose(); } - Future _loadData({bool refresh = false}) async { - if (refresh) { + /// 离线优先加载:先读持久化缓存立即渲染,后台拉取新数据 + Future _loadInitial() async { + final cacheKey = DiscoverListCache.buildKey( + resourceId: widget.resourceId, + sourceId: widget.sourceId, + ); + final userId = relationCtrl.user?.id ?? 'anon'; + final spaceId = widget.sourceId; + + // 1. 先读持久化缓存(内存 → 磁盘) + final cached = await DiscoverListCache.readPersistent( + cacheKey, + dataType: PageCacheType.discoverMarketGoods, + userId: userId, + spaceId: spaceId, + fromJson: PlazaMarketGoods.fromJson, + ); + if (cached != null && cached.isNotEmpty) { if (!mounted) return; setState(() { - _currentPage = 1; - _hasMore = true; - _isLoading = true; - _errorMsg = null; + _items = List.from(cached); + _isInitialLoading = false; + _hasMore = cached.length >= _pageSize; + _isBackgroundRefreshing = true; }); } + // 2. 后台拉取第一页 try { - // 确保plaza信息已加载(带缓存) + // 确保 plaza 信息已加载(带缓存,命中时直接返回) if (widget.sourceId == null || widget.sourceId!.isEmpty) { await DiscoverService.loadPlaza(groupId: ''); } - var data = await DiscoverService.loadMarketGoods( + final data = await DiscoverService.loadMarketGoods( widget.resourceId, - page: _currentPage, + page: 1, pageSize: _pageSize, filter: _searchText, typeName: _selectedTypeName, @@ -86,39 +107,129 @@ class _MarketListPageState extends State { if (!mounted) return; setState(() { - if (refresh) { + if (data.isNotEmpty || _items.isEmpty) { _items = data; - } else { - _items.addAll(data); + _hasMore = data.length >= _pageSize; + if (data.isNotEmpty) { + DiscoverListCache.writePersistent( + cacheKey, + data, + dataType: PageCacheType.discoverMarketGoods, + userId: userId, + spaceId: spaceId, + toJson: (item) => item.toJson(), + ); + } } - _isLoading = false; - _isLoadingMore = false; - _hasMore = data.length >= _pageSize; + _isInitialLoading = false; + _isBackgroundRefreshing = false; _errorMsg = null; }); } catch (e, st) { XLogUtil.e('加载市场商品失败: $e\n$st'); if (!mounted) return; setState(() { - _isLoading = false; - _isLoadingMore = false; - _errorMsg = '加载失败,请下拉刷新重试'; + _isInitialLoading = false; + _isBackgroundRefreshing = false; + if (_items.isEmpty) { + _errorMsg = '加载失败,请下拉刷新重试'; + } + }); + } + } + + /// 下拉刷新:保留旧数据,后台拉取新数据 + Future _refresh() async { + if (_isBackgroundRefreshing) return; + setState(() { + _isBackgroundRefreshing = true; + _errorMsg = null; + }); + + try { + final data = await DiscoverService.loadMarketGoods( + widget.resourceId, + page: 1, + pageSize: _pageSize, + filter: _searchText, + typeName: _selectedTypeName, + sourceId: widget.sourceId, + ); + + if (!mounted) return; + setState(() { + _items = data; + _currentPage = 1; + _hasMore = data.length >= _pageSize; + _isBackgroundRefreshing = false; + _errorMsg = null; + if (data.isNotEmpty) { + final cacheKey = DiscoverListCache.buildKey( + resourceId: widget.resourceId, + sourceId: widget.sourceId, + ); + DiscoverListCache.writePersistent( + cacheKey, + data, + dataType: PageCacheType.discoverMarketGoods, + userId: relationCtrl.user?.id ?? 'anon', + spaceId: widget.sourceId, + toJson: (item) => item.toJson(), + ); + } + }); + } catch (e) { + if (!mounted) return; + setState(() { + _isBackgroundRefreshing = false; + if (_items.isEmpty) { + _errorMsg = '刷新失败,请重试'; + } }); } } void _loadMore() { - if (!_hasMore || _isLoading || _isLoadingMore) return; + if (!_hasMore || + _isInitialLoading || + _isBackgroundRefreshing || + _isLoadingMore) { + return; + } setState(() { _currentPage++; _isLoadingMore = true; }); - _loadData(); + _loadMoreData(); + } + + Future _loadMoreData() async { + try { + final data = await DiscoverService.loadMarketGoods( + widget.resourceId, + page: _currentPage, + pageSize: _pageSize, + filter: _searchText, + typeName: _selectedTypeName, + sourceId: widget.sourceId, + ); + if (!mounted) return; + setState(() { + _items.addAll(data); + _isLoadingMore = false; + _hasMore = data.length >= _pageSize; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _isLoadingMore = false; + }); + } } Widget _buildErrorView() { return RefreshIndicator( - onRefresh: () => _loadData(refresh: true), + onRefresh: _refresh, child: GridView.builder( physics: const AlwaysScrollableScrollPhysics( parent: BouncingScrollPhysics(), @@ -137,13 +248,12 @@ class _MarketListPageState extends State { const SizedBox(height: 16), Text( _errorMsg ?? '加载失败', - style: - const TextStyle(fontSize: 14, color: OipText.secondary), + style: const TextStyle(fontSize: 14, color: OipText.secondary), textAlign: TextAlign.center, ), const SizedBox(height: 16), OutlinedButton.icon( - onPressed: () => _loadData(refresh: true), + onPressed: _refresh, icon: const Icon(Icons.refresh, size: 18), label: const Text('重试'), style: OutlinedButton.styleFrom( @@ -166,7 +276,7 @@ class _MarketListPageState extends State { return Scaffold( backgroundColor: OipSurface.background, appBar: AppBar( - title: Text(widget.title), + title: Text(widget.title, style: XFonts.headlineSmall), backgroundColor: OipSurface.card, foregroundColor: OipText.primary, elevation: 0, @@ -186,8 +296,10 @@ class _MarketListPageState extends State { child: TextField( decoration: InputDecoration( hintText: '搜索商品...', - hintStyle: const TextStyle(fontSize: 13, color: OipText.tertiary), - prefixIcon: const Icon(Icons.search, size: 18, color: OipText.tertiary), + hintStyle: + const TextStyle(fontSize: 13, color: OipText.tertiary), + prefixIcon: + const Icon(Icons.search, size: 18, color: OipText.tertiary), filled: true, fillColor: OipSurface.muted, border: OutlineInputBorder( @@ -198,24 +310,48 @@ class _MarketListPageState extends State { const EdgeInsets.symmetric(horizontal: 16, vertical: 10), ), onChanged: (value) { - setState(() { - _searchText = value; - }); + _searchText = value; _searchDebounce?.cancel(); _searchDebounce = Timer(const Duration(milliseconds: 500), () { - _loadData(refresh: true); + if (!mounted) return; + _refresh(); }); }, - onSubmitted: (_) => _loadData(refresh: true), + onSubmitted: (_) => _refresh(), ), ), + // 后台刷新时显示顶部"数据更新中"指示器,保留已有数据 + if (_isBackgroundRefreshing && _items.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + color: const Color(0xFFFFF8E1), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: Colors.orange, + ), + ), + const SizedBox(width: 6), + Text( + '数据更新中', + style: + TextStyle(fontSize: 11, color: Colors.orange.shade700), + ), + ], + ), + ), Expanded( - child: _isLoading && _items.isEmpty + child: _isInitialLoading && _items.isEmpty ? const LoadingWidget() : _errorMsg != null && _items.isEmpty ? _buildErrorView() : RefreshIndicator( - onRefresh: () => _loadData(refresh: true), + onRefresh: _refresh, child: NotificationListener( onNotification: (notification) { if (notification is ScrollEndNotification && @@ -268,7 +404,7 @@ class _MarketListPageState extends State { } Widget _buildGoodsCard(PlazaMarketGoods item) { - return PlazaItemCard( + return MarketStyleCard( item: item, sourceName: widget.title, sourceId: widget.sourceId, @@ -302,7 +438,8 @@ class _MarketListPageState extends State { spacing: 8, runSpacing: 8, children: ['全部', '实物', '虚拟', '服务'].map((type) { - final selected = _selectedTypeName == (type == '全部' ? null : type); + final selected = + _selectedTypeName == (type == '全部' ? null : type); return ChoiceChip( label: Text(type), selected: selected, @@ -313,10 +450,9 @@ class _MarketListPageState extends State { onSelected: (sel) { Navigator.pop(context); setState(() { - _selectedTypeName = - sel && type != '全部' ? type : null; + _selectedTypeName = sel && type != '全部' ? type : null; }); - _loadData(refresh: true); + _refresh(); }, ); }).toList(), diff --git a/lib/pages/discover/pages/notice_list_page.dart b/lib/pages/discover/pages/notice_list_page.dart index acdbfba2b829fdb2b5ec241ecc7bf94e7269b8d4..f8f8b2bcf31000bbf9f7a13e942e3e8ae4ed6a3a 100644 --- a/lib/pages/discover/pages/notice_list_page.dart +++ b/lib/pages/discover/pages/notice_list_page.dart @@ -2,14 +2,19 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:orginone/components/LoadingWidget/LoadingWidget.dart'; import 'package:orginone/config/oip_tokens.dart'; +import 'package:orginone/config/theme/unified_style.dart'; import 'package:orginone/dart/base/common/commands.dart'; -import 'package:orginone/pages/discover/widgets/plaza_item_widgets.dart'; +import 'package:orginone/dart/base/storages/local_page_cache_repository.dart'; +import 'package:orginone/main.dart'; +import 'package:orginone/pages/discover/widgets/discover_list_cache.dart'; +import 'package:orginone/pages/discover/widgets/plaza_style_cards.dart'; import 'package:orginone/utils/log/log_util.dart'; import '../discover_service.dart'; class NoticeListPage extends StatefulWidget { final String resourceId; final String title; + /// 资源来源空间 ID(多集群聚合时用于查询对应集群的 collection) final String? sourceId; @@ -26,7 +31,12 @@ class NoticeListPage extends StatefulWidget { class _NoticeListPageState extends State { List _items = []; - bool _isLoading = true; + + /// 是否首次加载(无缓存数据时显示 LoadingWidget) + bool _isInitialLoading = true; + + /// 是否正在后台刷新(有缓存数据时显示顶部"数据更新中"指示器) + bool _isBackgroundRefreshing = false; bool _isLoadingMore = false; String? _errorMsg; int _currentPage = 1; @@ -39,13 +49,7 @@ class _NoticeListPageState extends State { @override void initState() { super.initState(); - _loadData(); - final switchId = command.subscribeByFlag('switchSpace', ([args]) { - if (!mounted) return; - DiscoverService.clearCache(); - _loadData(refresh: true); - }, false); - _subscribers.add(switchId); + _loadInitial(); } @override @@ -57,26 +61,43 @@ class _NoticeListPageState extends State { super.dispose(); } - Future _loadData({bool refresh = false}) async { - if (refresh) { + /// 离线优先加载:先读持久化缓存立即渲染,后台拉取新数据 + Future _loadInitial() async { + final cacheKey = DiscoverListCache.buildKey( + resourceId: widget.resourceId, + sourceId: widget.sourceId, + ); + final userId = relationCtrl.user?.id ?? 'anon'; + final spaceId = widget.sourceId; + + // 1. 先读持久化缓存(内存 → 磁盘) + final cached = await DiscoverListCache.readPersistent( + cacheKey, + dataType: PageCacheType.discoverNotice, + userId: userId, + spaceId: spaceId, + fromJson: PlazaNotice.fromJson, + ); + if (cached != null && cached.isNotEmpty) { if (!mounted) return; setState(() { - _currentPage = 1; - _hasMore = true; - _isLoading = true; - _errorMsg = null; + _items = List.from(cached); + _isInitialLoading = false; + _hasMore = cached.length >= _pageSize; + _isBackgroundRefreshing = true; }); } + // 2. 后台拉取第一页 try { - // 确保plaza信息已加载(带缓存,命中时直接返回) + // 确保 plaza 信息已加载(带缓存,命中时直接返回) if (widget.sourceId == null || widget.sourceId!.isEmpty) { await DiscoverService.loadPlaza(groupId: ''); } - var data = await DiscoverService.loadNotices( + final data = await DiscoverService.loadNotices( widget.resourceId, - page: _currentPage, + page: 1, pageSize: _pageSize, filter: _searchText, sourceId: widget.sourceId, @@ -84,34 +105,122 @@ class _NoticeListPageState extends State { if (!mounted) return; setState(() { - if (refresh) { + if (data.isNotEmpty || _items.isEmpty) { _items = data; - } else { - _items.addAll(data); + _hasMore = data.length >= _pageSize; + if (data.isNotEmpty) { + DiscoverListCache.writePersistent( + cacheKey, + data, + dataType: PageCacheType.discoverNotice, + userId: userId, + spaceId: spaceId, + toJson: (item) => item.toJson(), + ); + } } - _isLoading = false; - _isLoadingMore = false; - _hasMore = data.length >= _pageSize; + _isInitialLoading = false; + _isBackgroundRefreshing = false; _errorMsg = null; }); } catch (e, st) { XLogUtil.e('加载公告失败: $e\n$st'); if (!mounted) return; setState(() { - _isLoading = false; - _isLoadingMore = false; - _errorMsg = '加载失败,请下拉刷新重试'; + _isInitialLoading = false; + _isBackgroundRefreshing = false; + if (_items.isEmpty) { + _errorMsg = '加载失败,请下拉刷新重试'; + } + }); + } + } + + /// 下拉刷新:保留旧数据,后台拉取新数据 + Future _refresh() async { + if (_isBackgroundRefreshing) return; + setState(() { + _isBackgroundRefreshing = true; + _errorMsg = null; + }); + + try { + final data = await DiscoverService.loadNotices( + widget.resourceId, + page: 1, + pageSize: _pageSize, + filter: _searchText, + sourceId: widget.sourceId, + ); + + if (!mounted) return; + setState(() { + _items = data; + _currentPage = 1; + _hasMore = data.length >= _pageSize; + _isBackgroundRefreshing = false; + _errorMsg = null; + if (data.isNotEmpty) { + final cacheKey = DiscoverListCache.buildKey( + resourceId: widget.resourceId, + sourceId: widget.sourceId, + ); + DiscoverListCache.writePersistent( + cacheKey, + data, + dataType: PageCacheType.discoverNotice, + userId: relationCtrl.user?.id ?? 'anon', + spaceId: widget.sourceId, + toJson: (item) => item.toJson(), + ); + } + }); + } catch (e) { + if (!mounted) return; + setState(() { + _isBackgroundRefreshing = false; + if (_items.isEmpty) { + _errorMsg = '刷新失败,请重试'; + } }); } } void _loadMore() { - if (!_hasMore || _isLoading || _isLoadingMore) return; + if (!_hasMore || + _isInitialLoading || + _isBackgroundRefreshing || + _isLoadingMore) { + return; + } setState(() { _currentPage++; _isLoadingMore = true; }); - _loadData(); + _loadMoreData(); + } + + Future _loadMoreData() async { + try { + final data = await DiscoverService.loadNotices( + widget.resourceId, + page: _currentPage, + pageSize: _pageSize, + filter: _searchText, + sourceId: widget.sourceId, + ); + if (!mounted) return; + setState(() { + _items.addAll(data); + _isLoadingMore = false; + _hasMore = data.length >= _pageSize; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _isLoadingMore = false; + }); + } } Widget _buildSearchField() { @@ -133,22 +242,21 @@ class _NoticeListPageState extends State { const EdgeInsets.symmetric(horizontal: 16, vertical: 12), ), onChanged: (value) { - setState(() { - _searchText = value; - }); + _searchText = value; _searchDebounce?.cancel(); _searchDebounce = Timer(const Duration(milliseconds: 500), () { - _loadData(refresh: true); + if (!mounted) return; + _refresh(); }); }, - onSubmitted: (_) => _loadData(refresh: true), + onSubmitted: (_) => _refresh(), ), ); } Widget _buildErrorView() { return RefreshIndicator( - onRefresh: () => _loadData(refresh: true), + onRefresh: _refresh, child: ListView( physics: const AlwaysScrollableScrollPhysics( parent: BouncingScrollPhysics(), @@ -164,13 +272,13 @@ class _NoticeListPageState extends State { const SizedBox(height: 16), Text( _errorMsg ?? '加载失败', - style: const TextStyle( - fontSize: 14, color: OipText.secondary), + style: + const TextStyle(fontSize: 14, color: OipText.secondary), textAlign: TextAlign.center, ), const SizedBox(height: 16), OutlinedButton.icon( - onPressed: () => _loadData(refresh: true), + onPressed: _refresh, icon: const Icon(Icons.refresh, size: 18), label: const Text('重试'), style: OutlinedButton.styleFrom( @@ -194,7 +302,7 @@ class _NoticeListPageState extends State { return Scaffold( backgroundColor: OipSurface.background, appBar: AppBar( - title: Text(widget.title), + title: Text(widget.title, style: XFonts.headlineSmall), backgroundColor: OipSurface.card, foregroundColor: OipText.primary, elevation: 0, @@ -203,13 +311,38 @@ class _NoticeListPageState extends State { body: Column( children: [ _buildSearchField(), + // 后台刷新时显示顶部"数据更新中"指示器,保留已有数据 + if (_isBackgroundRefreshing && _items.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + color: const Color(0xFFFFF8E1), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: Colors.orange, + ), + ), + const SizedBox(width: 6), + Text( + '数据更新中', + style: + TextStyle(fontSize: 11, color: Colors.orange.shade700), + ), + ], + ), + ), Expanded( - child: _isLoading && _items.isEmpty + child: _isInitialLoading && _items.isEmpty ? const LoadingWidget() : _errorMsg != null && _items.isEmpty ? _buildErrorView() : RefreshIndicator( - onRefresh: () => _loadData(refresh: true), + onRefresh: _refresh, child: NotificationListener( onNotification: (notification) { if (notification is ScrollEndNotification && @@ -256,7 +389,7 @@ class _NoticeListPageState extends State { } Widget _buildNoticeCard(PlazaNotice item) { - return PlazaItemCard( + return NoticeStyleCard( item: item, sourceName: widget.title, sourceId: widget.sourceId, diff --git a/lib/pages/discover/pages/video_list_page.dart b/lib/pages/discover/pages/video_list_page.dart index 6b2d2d5635cb1be10d13fac440c006481a9b2b0e..d4d467c7a17bf084c730f74791c4a544bb4c4e9c 100644 --- a/lib/pages/discover/pages/video_list_page.dart +++ b/lib/pages/discover/pages/video_list_page.dart @@ -2,14 +2,19 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:orginone/components/LoadingWidget/LoadingWidget.dart'; import 'package:orginone/config/oip_tokens.dart'; +import 'package:orginone/config/theme/unified_style.dart'; import 'package:orginone/dart/base/common/commands.dart'; +import 'package:orginone/dart/base/storages/local_page_cache_repository.dart'; +import 'package:orginone/main.dart'; import 'package:orginone/utils/log/log_util.dart'; -import 'package:orginone/pages/discover/widgets/plaza_item_widgets.dart'; +import 'package:orginone/pages/discover/widgets/discover_list_cache.dart'; +import 'package:orginone/pages/discover/widgets/plaza_style_cards.dart'; import '../discover_service.dart'; class VideoListPage extends StatefulWidget { final String resourceId; final String title; + /// 资源来源空间 ID(多集群聚合时用于查询对应集群的 collection) final String? sourceId; @@ -26,7 +31,12 @@ class VideoListPage extends StatefulWidget { class _VideoListPageState extends State { List _items = []; - bool _isLoading = true; + + /// 是否首次加载(无缓存数据时显示 LoadingWidget) + bool _isInitialLoading = true; + + /// 是否正在后台刷新(有缓存数据时显示顶部"数据更新中"指示器) + bool _isBackgroundRefreshing = false; bool _isLoadingMore = false; String? _errorMsg; int _currentPage = 1; @@ -39,13 +49,7 @@ class _VideoListPageState extends State { @override void initState() { super.initState(); - _loadData(); - final switchId = command.subscribeByFlag('switchSpace', ([args]) { - if (!mounted) return; - DiscoverService.clearCache(); - _loadData(refresh: true); - }, false); - _subscribers.add(switchId); + _loadInitial(); } @override @@ -57,26 +61,43 @@ class _VideoListPageState extends State { super.dispose(); } - Future _loadData({bool refresh = false}) async { - if (refresh) { + /// 离线优先加载:先读持久化缓存立即渲染,后台拉取新数据 + Future _loadInitial() async { + final cacheKey = DiscoverListCache.buildKey( + resourceId: widget.resourceId, + sourceId: widget.sourceId, + ); + final userId = relationCtrl.user?.id ?? 'anon'; + final spaceId = widget.sourceId; + + // 1. 先读持久化缓存(内存 → 磁盘) + final cached = await DiscoverListCache.readPersistent( + cacheKey, + dataType: PageCacheType.discoverVideo, + userId: userId, + spaceId: spaceId, + fromJson: PlazaVideo.fromJson, + ); + if (cached != null && cached.isNotEmpty) { if (!mounted) return; setState(() { - _currentPage = 1; - _hasMore = true; - _isLoading = true; - _errorMsg = null; + _items = List.from(cached); + _isInitialLoading = false; + _hasMore = cached.length >= _pageSize; + _isBackgroundRefreshing = true; }); } + // 2. 后台拉取第一页 try { - // 确保plaza信息已加载(带缓存) + // 确保 plaza 信息已加载(带缓存,命中时直接返回) if (widget.sourceId == null || widget.sourceId!.isEmpty) { await DiscoverService.loadPlaza(groupId: ''); } - var data = await DiscoverService.loadVideos( + final data = await DiscoverService.loadVideos( widget.resourceId, - page: _currentPage, + page: 1, pageSize: _pageSize, filter: _searchText, sourceId: widget.sourceId, @@ -84,34 +105,122 @@ class _VideoListPageState extends State { if (!mounted) return; setState(() { - if (refresh) { + if (data.isNotEmpty || _items.isEmpty) { _items = data; - } else { - _items.addAll(data); + _hasMore = data.length >= _pageSize; + if (data.isNotEmpty) { + DiscoverListCache.writePersistent( + cacheKey, + data, + dataType: PageCacheType.discoverVideo, + userId: userId, + spaceId: spaceId, + toJson: (item) => item.toJson(), + ); + } } - _isLoading = false; - _isLoadingMore = false; - _hasMore = data.length >= _pageSize; + _isInitialLoading = false; + _isBackgroundRefreshing = false; _errorMsg = null; }); } catch (e, st) { XLogUtil.e('加载视频失败: $e\n$st'); if (!mounted) return; setState(() { - _isLoading = false; - _isLoadingMore = false; - _errorMsg = '加载失败,请下拉刷新重试'; + _isInitialLoading = false; + _isBackgroundRefreshing = false; + if (_items.isEmpty) { + _errorMsg = '加载失败,请下拉刷新重试'; + } + }); + } + } + + /// 下拉刷新:保留旧数据,后台拉取新数据 + Future _refresh() async { + if (_isBackgroundRefreshing) return; + setState(() { + _isBackgroundRefreshing = true; + _errorMsg = null; + }); + + try { + final data = await DiscoverService.loadVideos( + widget.resourceId, + page: 1, + pageSize: _pageSize, + filter: _searchText, + sourceId: widget.sourceId, + ); + + if (!mounted) return; + setState(() { + _items = data; + _currentPage = 1; + _hasMore = data.length >= _pageSize; + _isBackgroundRefreshing = false; + _errorMsg = null; + if (data.isNotEmpty) { + final cacheKey = DiscoverListCache.buildKey( + resourceId: widget.resourceId, + sourceId: widget.sourceId, + ); + DiscoverListCache.writePersistent( + cacheKey, + data, + dataType: PageCacheType.discoverVideo, + userId: relationCtrl.user?.id ?? 'anon', + spaceId: widget.sourceId, + toJson: (item) => item.toJson(), + ); + } + }); + } catch (e) { + if (!mounted) return; + setState(() { + _isBackgroundRefreshing = false; + if (_items.isEmpty) { + _errorMsg = '刷新失败,请重试'; + } }); } } void _loadMore() { - if (!_hasMore || _isLoading || _isLoadingMore) return; + if (!_hasMore || + _isInitialLoading || + _isBackgroundRefreshing || + _isLoadingMore) { + return; + } setState(() { _currentPage++; _isLoadingMore = true; }); - _loadData(); + _loadMoreData(); + } + + Future _loadMoreData() async { + try { + final data = await DiscoverService.loadVideos( + widget.resourceId, + page: _currentPage, + pageSize: _pageSize, + filter: _searchText, + sourceId: widget.sourceId, + ); + if (!mounted) return; + setState(() { + _items.addAll(data); + _isLoadingMore = false; + _hasMore = data.length >= _pageSize; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _isLoadingMore = false; + }); + } } Widget _buildSearchField() { @@ -133,22 +242,21 @@ class _VideoListPageState extends State { const EdgeInsets.symmetric(horizontal: 16, vertical: 12), ), onChanged: (value) { - setState(() { - _searchText = value; - }); + _searchText = value; _searchDebounce?.cancel(); _searchDebounce = Timer(const Duration(milliseconds: 500), () { - _loadData(refresh: true); + if (!mounted) return; + _refresh(); }); }, - onSubmitted: (_) => _loadData(refresh: true), + onSubmitted: (_) => _refresh(), ), ); } Widget _buildErrorView() { return RefreshIndicator( - onRefresh: () => _loadData(refresh: true), + onRefresh: _refresh, child: GridView.builder( physics: const AlwaysScrollableScrollPhysics( parent: BouncingScrollPhysics(), @@ -167,13 +275,12 @@ class _VideoListPageState extends State { const SizedBox(height: 16), Text( _errorMsg ?? '加载失败', - style: - const TextStyle(fontSize: 14, color: OipText.secondary), + style: const TextStyle(fontSize: 14, color: OipText.secondary), textAlign: TextAlign.center, ), const SizedBox(height: 16), OutlinedButton.icon( - onPressed: () => _loadData(refresh: true), + onPressed: _refresh, icon: const Icon(Icons.refresh, size: 18), label: const Text('重试'), style: OutlinedButton.styleFrom( @@ -196,7 +303,7 @@ class _VideoListPageState extends State { return Scaffold( backgroundColor: OipSurface.background, appBar: AppBar( - title: Text(widget.title), + title: Text(widget.title, style: XFonts.headlineSmall), backgroundColor: OipSurface.card, foregroundColor: OipText.primary, elevation: 0, @@ -205,13 +312,38 @@ class _VideoListPageState extends State { body: Column( children: [ _buildSearchField(), + // 后台刷新时显示顶部"数据更新中"指示器,保留已有数据 + if (_isBackgroundRefreshing && _items.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + color: const Color(0xFFFFF8E1), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: Colors.orange, + ), + ), + const SizedBox(width: 6), + Text( + '数据更新中', + style: + TextStyle(fontSize: 11, color: Colors.orange.shade700), + ), + ], + ), + ), Expanded( - child: _isLoading && _items.isEmpty + child: _isInitialLoading && _items.isEmpty ? const LoadingWidget() : _errorMsg != null && _items.isEmpty ? _buildErrorView() : RefreshIndicator( - onRefresh: () => _loadData(refresh: true), + onRefresh: _refresh, child: NotificationListener( onNotification: (notification) { if (notification is ScrollEndNotification && @@ -264,7 +396,7 @@ class _VideoListPageState extends State { } Widget _buildVideoCard(PlazaVideo item) { - return PlazaItemCard( + return VideoStyleCard( item: item, sourceName: widget.title, sourceId: widget.sourceId, diff --git a/lib/pages/discover/widgets/discover_list_cache.dart b/lib/pages/discover/widgets/discover_list_cache.dart new file mode 100644 index 0000000000000000000000000000000000000000..5208d8247bdd235bc0ffb7df43467c66b5c863c8 --- /dev/null +++ b/lib/pages/discover/widgets/discover_list_cache.dart @@ -0,0 +1,245 @@ +import 'dart:async'; + +import 'package:orginone/dart/base/storages/local_page_cache_repository.dart'; +import 'package:orginone/utils/log/log_util.dart'; + +/// 发现页子页面(公告/视频/群组/数据/市场)的本地缓存工具。 +/// +/// 实现"先展示缓存数据,后台更新数据"的离线优先策略: +/// - 首次进入页面时,从内存缓存读取上次数据立即渲染,避免白屏等待 +/// - 后台拉取最新数据,完成后替换并刷新 UI +/// - 切换空间时清空所有缓存 +/// +/// 缓存键规则:`{sourceId ?? 'default'}_{resourceId}_{searchText}` +class DiscoverListCache { + static final Map _cache = {}; + + /// 读取缓存数据(不更新访问时间) + /// 返回 null 表示无缓存或缓存已过期 + static List? read(String key) { + final entry = _cache[key]; + if (entry == null) return null; + if (DateTime.now().difference(entry.createTime) > _maxAge) { + _cache.remove(key); + return null; + } + return entry.data as List?; + } + + /// 写入缓存数据 + static void write(String key, List data) { + _cache[key] = _CacheEntry( + data: List.from(data), + createTime: DateTime.now(), + ); + } + + /// 从磁盘读取持久化缓存(异步) + /// + /// 三级读取策略: + /// 1. 先查内存缓存,命中且非空直接返回 + /// 2. 内存未命中查磁盘(LocalPageCacheRepository),反序列化后回填内存 + /// 3. 磁盘也无数据返回 null,由调用方走在线拉取 + /// + /// [key] 内存缓存键(与 [read] 一致) + /// [dataType] PageCacheType 中对应的数据类型常量 + /// [userId] 当前用户 ID(未登录传 'anon') + /// [spaceId] 空间 ID(多集群场景区分来源) + /// [fromJson] 模型反序列化函数(调用方传入,避免反射) + static Future?> readPersistent( + String key, { + required String dataType, + required String userId, + String? spaceId, + required T Function(Map) fromJson, + }) async { + // 1. 先查内存 + final memCached = read(key); + if (memCached != null && memCached.isNotEmpty) return memCached; + + // 2. 内存未命中,查磁盘 + try { + final entry = await LocalPageCacheRepository.instance.read( + dataType, + userId: userId, + spaceId: spaceId, + ); + if (entry == null || entry.data.isEmpty) return null; + final items = []; + for (final json in entry.data) { + try { + items.add(fromJson(json)); + } catch (e) { + XLogUtil.w('[DiscoverListCache] readPersistent 反序列化失败: $e'); + } + } + if (items.isEmpty) return null; + // 回填内存缓存 + write(key, items); + return items; + } catch (e) { + XLogUtil.w('[DiscoverListCache] readPersistent 失败: $e'); + return null; + } + } + + /// 持久化写入磁盘(fire-and-forget,不阻塞调用方) + /// + /// 1. 同步写内存缓存 + /// 2. 异步写磁盘(LocalPageCacheRepository),失败仅记录日志 + /// + /// [toJson] 模型序列化函数(调用方传入,避免反射) + static void writePersistent( + String key, + List data, { + required String dataType, + required String userId, + String? spaceId, + required Map Function(T) toJson, + }) { + // 1. 写内存 + write(key, data); + // 2. 异步写磁盘(fire-and-forget) + if (data.isEmpty) return; + Future(() async { + try { + final jsonList = data.map(toJson).toList(); + await LocalPageCacheRepository.instance.write( + dataType, + jsonList, + userId: userId, + spaceId: spaceId, + ); + } catch (e) { + XLogUtil.w('[DiscoverListCache] writePersistent 失败: $e'); + } + }); + } + + /// 清空所有缓存(切换空间时调用) + static void clearAll() { + _cache.clear(); + } + + /// 构建缓存键 + static String buildKey({ + required String resourceId, + String? sourceId, + String searchText = '', + }) { + final src = (sourceId != null && sourceId.isNotEmpty) ? sourceId : 'default'; + final search = searchText.trim().toLowerCase(); + return '${src}_$resourceId${search.isEmpty ? '' : '_$search'}'; + } + + static const Duration _maxAge = Duration(minutes: 10); +} + +class _CacheEntry { + final List data; + final DateTime createTime; + + _CacheEntry({required this.data, required this.createTime}); +} + +/// 子页面数据加载状态机,统一管理"离线优先加载"流程。 +/// +/// 使用方式: +/// ```dart +/// final loader = DiscoverListLoader( +/// loadRemote: (page, pageSize, filter) => DiscoverService.loadNotices(...), +/// cacheKey: DiscoverListCache.buildKey(resourceId: widget.resourceId, sourceId: widget.sourceId), +/// ); +/// await loader.loadInitial(); +/// if (loader.hasCachedData) { +/// // 先展示缓存 +/// setState(() => _items = loader.items); +/// } +/// // 后台拉取完成后刷新 +/// if (loader.didRefresh) { +/// setState(() => _items = loader.items); +/// } +/// ``` +class DiscoverListLoader { + final Future> Function(int page, int pageSize, String filter) loadRemote; + final String cacheKey; + final int pageSize; + + DiscoverListLoader({ + required this.loadRemote, + required this.cacheKey, + this.pageSize = 20, + }); + + List items = []; + bool hasCachedData = false; + bool didRefresh = false; + bool hasMore = true; + String? errorMessage; + + /// 离线优先加载:先读缓存立即返回,后台拉取新数据 + Future loadInitial() async { + hasCachedData = false; + didRefresh = false; + + // 1. 先读缓存 + final cached = DiscoverListCache.read(cacheKey); + if (cached != null && cached.isNotEmpty) { + items = List.from(cached); + hasCachedData = true; + hasMore = cached.length >= pageSize; + } + + // 2. 后台拉取第一页 + try { + final fresh = await loadRemote(1, pageSize, ''); + if (fresh.isNotEmpty) { + items = fresh; + hasMore = fresh.length >= pageSize; + DiscoverListCache.write(cacheKey, fresh); + didRefresh = true; + } else if (items.isEmpty) { + // 远程返回空且本地无数据,保持空态 + items = []; + hasMore = false; + didRefresh = true; + } + errorMessage = null; + } catch (e) { + errorMessage = '加载失败,请下拉刷新重试'; + } + } + + /// 下拉刷新:保留旧数据,后台拉取新数据 + Future refresh() async { + didRefresh = false; + try { + final fresh = await loadRemote(1, pageSize, ''); + items = fresh; + hasMore = fresh.length >= pageSize; + DiscoverListCache.write(cacheKey, fresh); + errorMessage = null; + didRefresh = true; + } catch (e) { + errorMessage = '刷新失败,请重试'; + } + } + + /// 加载更多 + Future loadMore(int page) async { + try { + final more = await loadRemote(page, pageSize, ''); + if (more.isNotEmpty) { + items.addAll(more); + hasMore = more.length >= pageSize; + } else { + hasMore = false; + } + // 加载更多后更新缓存(合并第一页 + 后续页) + DiscoverListCache.write(cacheKey, items); + errorMessage = null; + } catch (e) { + errorMessage = '加载更多失败'; + } + } +} diff --git a/lib/pages/discover/widgets/plaza_item_widgets.dart b/lib/pages/discover/widgets/plaza_item_widgets.dart index d8d9c02b00a2bf3c2e2a63c5af5fc07a19b75050..39f775fbf073bd5e0525fac831e96069dd1576f5 100644 --- a/lib/pages/discover/widgets/plaza_item_widgets.dart +++ b/lib/pages/discover/widgets/plaza_item_widgets.dart @@ -268,7 +268,7 @@ class PlazaItemCard extends StatelessWidget { Text( title, style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, fontWeight: FontWeight.w600, color: XColors.black3, ), @@ -280,7 +280,7 @@ class PlazaItemCard extends StatelessWidget { Text( summary, style: TextStyle( - fontSize: 12.sp, + fontSize: 16.sp, color: XColors.black6, height: 1.4, ), @@ -299,7 +299,7 @@ class PlazaItemCard extends StatelessWidget { Text( timeStr, style: TextStyle( - fontSize: 11.sp, + fontSize: 16.sp, color: Colors.grey.shade500, ), ), @@ -459,7 +459,7 @@ class _PlazaItemDetailSheet extends StatelessWidget { child: Text( subtitle, style: TextStyle( - fontSize: 12.sp, + fontSize: 16.sp, color: color, fontWeight: FontWeight.w500, ), @@ -558,14 +558,14 @@ class _PlazaItemDetailSheet extends StatelessWidget { child: Text( meta[i].key, style: TextStyle( - fontSize: 12.sp, color: Colors.grey.shade500), + fontSize: 16.sp, color: Colors.grey.shade500), ), ), Expanded( child: SelectableText( meta[i].value, style: TextStyle( - fontSize: 12.sp, color: XColors.black3), + fontSize: 16.sp, color: XColors.black3), ), ), ], @@ -593,7 +593,7 @@ class _PlazaItemDetailSheet extends StatelessWidget { widgets.addAll([ Text('公告内容', style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, fontWeight: FontWeight.w600, color: XColors.black3)), SizedBox(height: 8.h), @@ -609,13 +609,13 @@ class _PlazaItemDetailSheet extends StatelessWidget { child: SelectableText( content, style: TextStyle( - fontSize: 13.sp, color: XColors.black3, height: 1.7), + fontSize: 16.sp, color: XColors.black3, height: 1.7), ), ), if (images.isNotEmpty) ...[ SizedBox(height: 12.h), Text('图片 (${images.length})', - style: TextStyle(fontSize: 12.sp, color: Colors.grey.shade600)), + style: TextStyle(fontSize: 16.sp, color: Colors.grey.shade600)), SizedBox(height: 8.h), SizedBox( height: 100.w, @@ -649,7 +649,7 @@ class _PlazaItemDetailSheet extends StatelessWidget { final g = item as PlazaMarketGoods; widgets.add(Text('商品详情', style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, fontWeight: FontWeight.w600, color: XColors.black3))); widgets.add(SizedBox(height: 8.h)); @@ -666,7 +666,7 @@ class _PlazaItemDetailSheet extends StatelessWidget { if (imgs.isNotEmpty) { widgets.add(SizedBox(height: 12.h)); widgets.add(Text('图片 (${imgs.length})', - style: TextStyle(fontSize: 12.sp, color: Colors.grey.shade600))); + style: TextStyle(fontSize: 16.sp, color: Colors.grey.shade600))); widgets.add(SizedBox(height: 8.h)); widgets.add(SizedBox( height: 80.w, @@ -706,14 +706,14 @@ class _PlazaItemDetailSheet extends StatelessWidget { ), child: SelectableText(desc, style: - TextStyle(fontSize: 13.sp, color: XColors.black3, height: 1.6)), + TextStyle(fontSize: 16.sp, color: XColors.black3, height: 1.6)), )); } } else if (item is PlazaLive) { final l = item as PlazaLive; widgets.add(Text('直播信息', style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, fontWeight: FontWeight.w600, color: XColors.black3))); widgets.add(SizedBox(height: 8.h)); @@ -735,14 +735,14 @@ class _PlazaItemDetailSheet extends StatelessWidget { ), child: SelectableText(desc, style: - TextStyle(fontSize: 13.sp, color: XColors.black3, height: 1.6)), + TextStyle(fontSize: 16.sp, color: XColors.black3, height: 1.6)), )); } } else if (item is PlazaVideo) { final v = item as PlazaVideo; widgets.add(Text('视频信息', style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, fontWeight: FontWeight.w600, color: XColors.black3))); widgets.add(SizedBox(height: 8.h)); @@ -756,7 +756,7 @@ class _PlazaItemDetailSheet extends StatelessWidget { final d = item as PlazaDataShare; widgets.add(Text('数据分享', style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, fontWeight: FontWeight.w600, color: XColors.black3))); widgets.add(SizedBox(height: 8.h)); @@ -766,7 +766,7 @@ class _PlazaItemDetailSheet extends StatelessWidget { final t = item as PlazaTarget; widgets.add(Text('群组信息', style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, fontWeight: FontWeight.w600, color: XColors.black3))); widgets.add(SizedBox(height: 8.h)); @@ -813,13 +813,13 @@ class _PlazaItemDetailSheet extends StatelessWidget { width: 60.w, child: Text(label, style: TextStyle( - fontSize: 12.sp, color: Colors.grey.shade500)), + fontSize: 16.sp, color: Colors.grey.shade500)), ), Expanded( child: SelectableText( value, style: TextStyle( - fontSize: 13.sp, + fontSize: 16.sp, color: highlight ? (color ?? OipBrand.primary) : XColors.black3, fontWeight: highlight ? FontWeight.w600 : FontWeight.w400, ), @@ -1086,7 +1086,7 @@ class PlazaActionChip extends StatelessWidget { return OutlinedButton.icon( onPressed: onTap, icon: Icon(icon, size: 16.w), - label: Text(label, style: TextStyle(fontSize: 12.sp)), + label: Text(label, style: TextStyle(fontSize: 16.sp)), style: OutlinedButton.styleFrom( foregroundColor: color, side: BorderSide(color: color.withValues(alpha: 0.4)), @@ -1100,7 +1100,7 @@ class PlazaActionChip extends StatelessWidget { onPressed: onTap, icon: Icon(icon, size: 16.w, color: Colors.white), label: Text(label, - style: TextStyle(fontSize: 12.sp, color: Colors.white)), + style: TextStyle(fontSize: 16.sp, color: Colors.white)), style: ElevatedButton.styleFrom( backgroundColor: color, foregroundColor: Colors.white, diff --git a/lib/pages/discover/widgets/plaza_style_cards.dart b/lib/pages/discover/widgets/plaza_style_cards.dart new file mode 100644 index 0000000000000000000000000000000000000000..d6a4aa9dd51abaf98243a3525b586b29f86fe7b5 --- /dev/null +++ b/lib/pages/discover/widgets/plaza_style_cards.dart @@ -0,0 +1,956 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:orginone/config/theme/unified_style.dart'; +import 'package:orginone/pages/discover/discover_service.dart'; +import 'package:orginone/pages/discover/widgets/plaza_item_widgets.dart'; + +/// 栏目特色卡片组件库。 +/// +/// 根据 5 种栏目风格分别提供独立卡片: +/// - [NoticeStyleCard] 公告栏目:参考今日头条图文浏览(左文右图) +/// - [VideoStyleCard] 视频栏目:参考抖音浏览短视频(纵向全屏沉浸式) +/// - [GroupStyleCard] 群组栏目:参考小红书社群(双列瀑布流卡片) +/// - [DataStyleCard] 数据栏目:参考 App Store 展示(大卡片 + 列表) +/// - [MarketStyleCard] 市场栏目:参考商场展示(商品大图 + 价格徽章) +/// +/// 所有卡片复用 [PlazaItemFields] 字段提取与 [showPlazaItemDetail] 详情入口, +/// 与原 [PlazaItemCard] 行为一致,仅视觉风格不同。 + +/// 公告栏目卡片:今日头条图文浏览风格 +/// 左文右图布局:标题(最多2行)+ 摘要 + 来源/时间,右侧 16:9 缩略图 +class NoticeStyleCard extends StatelessWidget { + final dynamic item; + final String sourceName; + final String? sourceId; + final VoidCallback? onTap; + + const NoticeStyleCard({ + super.key, + required this.item, + this.sourceName = '', + this.sourceId, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final title = PlazaItemFields.title(item); + final summary = PlazaItemFields.summary(item); + final timeStr = PlazaItemFields.formatTime(PlazaItemFields.rawTime(item)); + final thumb = PlazaItemFields.thumbnail(item); + final color = PlazaItemFields.colorOf(item); + + return Container( + margin: EdgeInsets.only(bottom: 12.h), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12.r), + border: Border(bottom: BorderSide(color: Colors.grey.shade100, width: 0.5)), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap ?? () => showPlazaItemDetail(context, item, + sourceName: sourceName, sourceId: sourceId), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 14.h), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + color: XColors.black3, + height: 1.35, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (summary.isNotEmpty) ...[ + SizedBox(height: 6.h), + Text( + summary, + style: TextStyle( + fontSize: 16.sp, + color: XColors.black6, + height: 1.4, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + const Spacer(), + SizedBox(height: 8.h), + Row( + children: [ + if (sourceName.isNotEmpty) ...[ + Container( + padding: EdgeInsets.symmetric( + horizontal: 6.w, vertical: 2.h), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(3.r), + ), + child: Text( + sourceName, + style: TextStyle( + fontSize: 16.sp, + color: color, + fontWeight: FontWeight.w500, + ), + ), + ), + SizedBox(width: 8.w), + ], + if (timeStr.isNotEmpty) + Text( + timeStr, + style: TextStyle( + fontSize: 16.sp, + color: Colors.grey.shade500, + ), + ), + ], + ), + ], + ), + ), + SizedBox(width: 12.w), + _buildThumb(thumb, color), + ], + ), + ), + ), + ), + ); + } + + Widget _buildThumb(String url, Color color) { + if (url.isEmpty) { + return Container( + width: 110.w, + height: 75.w, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + color.withValues(alpha: 0.18), + color.withValues(alpha: 0.06), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(8.r), + ), + child: Icon(Icons.campaign_outlined, color: color, size: 28.w), + ); + } + return ClipRRect( + borderRadius: BorderRadius.circular(8.r), + child: CachedNetworkImage( + imageUrl: url, + width: 110.w, + height: 75.w, + fit: BoxFit.cover, + placeholder: (_, __) => Container( + width: 110.w, + height: 75.w, + color: color.withValues(alpha: 0.1), + ), + errorWidget: (_, __, ___) => Container( + width: 110.w, + height: 75.w, + color: color.withValues(alpha: 0.1), + child: Icon(Icons.campaign_outlined, color: color, size: 28.w), + ), + ), + ); + } +} + +/// 视频栏目卡片:抖音浏览短视频风格 +/// 16:9 大缩略图叠加播放按钮,底部标题与时长,沉浸式视觉 +class VideoStyleCard extends StatelessWidget { + final dynamic item; + final String sourceName; + final String? sourceId; + final VoidCallback? onTap; + final double? aspectRatio; + + const VideoStyleCard({ + super.key, + required this.item, + this.sourceName = '', + this.sourceId, + this.onTap, + this.aspectRatio, + }); + + @override + Widget build(BuildContext context) { + final title = PlazaItemFields.title(item); + final summary = PlazaItemFields.summary(item); + final thumb = PlazaItemFields.thumbnail(item); + final color = PlazaItemFields.colorOf(item); + + return Container( + margin: EdgeInsets.only(bottom: 12.h), + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(12.r), + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(12.r), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap ?? () => showPlazaItemDetail(context, item, + sourceName: sourceName, sourceId: sourceId), + child: Stack( + children: [ + _buildCover(thumb, color), + Positioned.fill( + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.7), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: const [0.5, 1.0], + ), + ), + ), + ), + Positioned( + top: 12.h, + right: 12.w, + child: Container( + padding: + EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12.r), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.visibility_outlined, + color: Colors.white, size: 12.w), + SizedBox(width: 3.w), + Text( + '视频', + style: TextStyle( + color: Colors.white, fontSize: 16.sp), + ), + ], + ), + ), + ), + Positioned.fill( + child: Center( + child: Container( + width: 52.w, + height: 52.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.black.withValues(alpha: 0.45), + border: Border.all( + color: Colors.white.withValues(alpha: 0.9), width: 2), + ), + child: Icon(Icons.play_arrow_rounded, + color: Colors.white, size: 28.w), + ), + ), + ), + Positioned( + left: 12.w, + right: 12.w, + bottom: 12.h, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + color: Colors.white, + height: 1.35, + shadows: [ + Shadow( + color: Colors.black.withValues(alpha: 0.6), + blurRadius: 4, + ), + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (summary.isNotEmpty) ...[ + SizedBox(height: 4.h), + Text( + summary, + style: TextStyle( + fontSize: 16.sp, + color: Colors.white.withValues(alpha: 0.78), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildCover(String url, Color color) { + final ratio = aspectRatio ?? 16 / 9; + return AspectRatio( + aspectRatio: ratio, + child: url.isEmpty + ? Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + color.withValues(alpha: 0.6), + color.withValues(alpha: 0.2), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + ) + : CachedNetworkImage( + imageUrl: url, + fit: BoxFit.cover, + placeholder: (_, __) => Container( + color: color.withValues(alpha: 0.2), + ), + errorWidget: (_, __, ___) => Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + color.withValues(alpha: 0.6), + color.withValues(alpha: 0.2), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + ), + ), + ); + } +} + +/// 群组栏目卡片:小红书社群风格 +/// 瀑布流卡片:顶部封面图 + 中部标题/描述 + 底部头像/成员数 +class GroupStyleCard extends StatelessWidget { + final dynamic item; + final String sourceName; + final String? sourceId; + final VoidCallback? onTap; + final double? maxWidth; + + const GroupStyleCard({ + super.key, + required this.item, + this.sourceName = '', + this.sourceId, + this.onTap, + this.maxWidth, + }); + + @override + Widget build(BuildContext context) { + final title = PlazaItemFields.title(item); + final summary = PlazaItemFields.summary(item); + final thumb = PlazaItemFields.thumbnail(item); + final color = PlazaItemFields.colorOf(item); + final icon = PlazaItemFields.icon(item); + + Widget card = Container( + margin: EdgeInsets.only(bottom: 10.h), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14.r), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(14.r), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap ?? () => showPlazaItemDetail(context, item, + sourceName: sourceName, sourceId: sourceId), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _buildCover(thumb, color, icon), + Padding( + padding: EdgeInsets.all(10.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + color: XColors.black3, + height: 1.3, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (summary.isNotEmpty) ...[ + SizedBox(height: 4.h), + Text( + summary, + style: TextStyle( + fontSize: 16.sp, + color: XColors.black6, + height: 1.35, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + SizedBox(height: 8.h), + Row( + children: [ + Container( + width: 18.w, + height: 18.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color.withValues(alpha: 0.18), + ), + child: Icon(icon, color: color, size: 11.w), + ), + SizedBox(width: 6.w), + Expanded( + child: Text( + sourceName.isNotEmpty ? sourceName : '群组', + style: TextStyle( + fontSize: 16.sp, + color: Colors.grey.shade600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Icon(Icons.favorite_border, + size: 13.w, color: Colors.grey.shade400), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ); + + if (maxWidth != null) { + return ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth!), + child: card, + ); + } + return card; + } + + Widget _buildCover(String url, Color color, IconData icon) { + final height = 160.w + (item.hashCode % 40).toDouble(); + return SizedBox( + width: double.infinity, + height: height, + child: url.isEmpty + ? Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + color.withValues(alpha: 0.22), + color.withValues(alpha: 0.08), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Center( + child: Icon(icon, color: color, size: 36.w), + ), + ) + : CachedNetworkImage( + imageUrl: url, + fit: BoxFit.cover, + placeholder: (_, __) => Container( + color: color.withValues(alpha: 0.1), + ), + errorWidget: (_, __, ___) => Container( + color: color.withValues(alpha: 0.1), + child: Center(child: Icon(icon, color: color, size: 36.w)), + ), + ), + ); + } +} + +/// 数据栏目卡片:App Store 展示风格 +/// 大卡片:顶部渐变背景 + 大图标 + 标题 + 副标题 + 底部获取/订阅按钮 +class DataStyleCard extends StatelessWidget { + final dynamic item; + final String sourceName; + final String? sourceId; + final VoidCallback? onTap; + + const DataStyleCard({ + super.key, + required this.item, + this.sourceName = '', + this.sourceId, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final title = PlazaItemFields.title(item); + final summary = PlazaItemFields.summary(item); + final timeStr = PlazaItemFields.formatTime(PlazaItemFields.rawTime(item)); + final color = PlazaItemFields.colorOf(item); + final icon = PlazaItemFields.icon(item); + + return Container( + margin: EdgeInsets.only(bottom: 14.h), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18.r), + border: Border.all(color: Colors.grey.shade100, width: 0.6), + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(18.r), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap ?? () => showPlazaItemDetail(context, item, + sourceName: sourceName, sourceId: sourceId), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: double.infinity, + padding: EdgeInsets.fromLTRB(16.w, 18.h, 16.w, 16.h), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + color.withValues(alpha: 0.16), + color.withValues(alpha: 0.04), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Row( + children: [ + Container( + width: 56.w, + height: 56.w, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14.r), + boxShadow: [ + BoxShadow( + color: color.withValues(alpha: 0.18), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Icon(icon, color: color, size: 30.w), + ), + SizedBox(width: 14.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 17.sp, + fontWeight: FontWeight.w700, + color: XColors.black3, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + SizedBox(height: 3.h), + Text( + sourceName.isNotEmpty ? sourceName : '数据资源', + style: TextStyle( + fontSize: 16.sp, + color: Colors.grey.shade600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + Padding( + padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 14.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (summary.isNotEmpty) ...[ + Text( + summary, + style: TextStyle( + fontSize: 16.sp, + color: XColors.black6, + height: 1.45, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + SizedBox(height: 8.h), + ], + Row( + children: [ + if (timeStr.isNotEmpty) + Text( + timeStr, + style: TextStyle( + fontSize: 16.sp, + color: Colors.grey.shade500, + ), + ), + const Spacer(), + Container( + padding: EdgeInsets.symmetric( + horizontal: 14.w, vertical: 6.h), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(16.r), + ), + child: Text( + '查看', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +/// 市场栏目卡片:商场展示风格 +/// 大图商品展示:顶部商品图 + 折扣/特价徽章 + 标题 + 价格 + 购买按钮 +class MarketStyleCard extends StatelessWidget { + final dynamic item; + final String sourceName; + final String? sourceId; + final VoidCallback? onTap; + final double? maxWidth; + + const MarketStyleCard({ + super.key, + required this.item, + this.sourceName = '', + this.sourceId, + this.onTap, + this.maxWidth, + }); + + @override + Widget build(BuildContext context) { + final title = PlazaItemFields.title(item); + final summary = PlazaItemFields.summary(item); + final thumb = PlazaItemFields.thumbnail(item); + final color = PlazaItemFields.colorOf(item); + final icon = PlazaItemFields.icon(item); + final price = _extractPrice(item); + + Widget card = Container( + margin: EdgeInsets.only(bottom: 12.h), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14.r), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(14.r), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap ?? () => showPlazaItemDetail(context, item, + sourceName: sourceName, sourceId: sourceId), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Stack( + children: [ + _buildCover(thumb, color, icon), + if (price != null && price > 0) + Positioned( + top: 10.h, + left: 10.w, + child: Container( + padding: EdgeInsets.symmetric( + horizontal: 8.w, vertical: 3.h), + decoration: BoxDecoration( + color: const Color(0xFFFF4D4F), + borderRadius: BorderRadius.circular(10.r), + ), + child: Text( + '特惠', + style: TextStyle( + color: Colors.white, + fontSize: 16.sp, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + Padding( + padding: EdgeInsets.all(12.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + color: XColors.black3, + height: 1.3, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (summary.isNotEmpty) ...[ + SizedBox(height: 4.h), + Text( + summary, + style: TextStyle( + fontSize: 16.sp, + color: XColors.black6, + height: 1.35, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + SizedBox(height: 10.h), + Row( + children: [ + if (price != null) ...[ + Text( + price > 0 + ? '¥${price.toStringAsFixed(2)}' + : '免费', + style: TextStyle( + fontSize: 17.sp, + fontWeight: FontWeight.w700, + color: const Color(0xFFFF4D4F), + ), + ), + SizedBox(width: 8.w), + ], + if (sourceName.isNotEmpty) + Expanded( + child: Text( + sourceName, + style: TextStyle( + fontSize: 16.sp, + color: Colors.grey.shade500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ) + else + const Spacer(), + Container( + padding: EdgeInsets.symmetric( + horizontal: 14.w, vertical: 6.h), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [color, color.withValues(alpha: 0.8)], + ), + borderRadius: BorderRadius.circular(16.r), + ), + child: Text( + price != null && price > 0 ? '下单' : '获取', + style: TextStyle( + color: Colors.white, + fontSize: 16.sp, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ); + + if (maxWidth != null) { + return ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth!), + child: card, + ); + } + return card; + } + + double? _extractPrice(dynamic item) { + if (item is PlazaMarketGoods) return item.price; + return null; + } + + Widget _buildCover(String url, Color color, IconData icon) { + return SizedBox( + width: double.infinity, + height: 150.w, + child: url.isEmpty + ? Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + color.withValues(alpha: 0.2), + color.withValues(alpha: 0.06), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Center( + child: Icon(icon, color: color, size: 40.w), + ), + ) + : CachedNetworkImage( + imageUrl: url, + fit: BoxFit.cover, + placeholder: (_, __) => Container( + color: color.withValues(alpha: 0.1), + ), + errorWidget: (_, __, ___) => Container( + color: color.withValues(alpha: 0.1), + child: Center(child: Icon(icon, color: color, size: 40.w)), + ), + ), + ); + } +} + +/// 根据栏目 typeName 分发到对应风格的卡片组件 +/// 用于 PlatformEntryPage 等需要按栏目类型动态渲染的场景 +Widget plazaStyleCardByType({ + required dynamic item, + required String typeName, + String sourceName = '', + String? sourceId, + VoidCallback? onTap, + double? maxWidth, +}) { + switch (typeName) { + case '公告': + return NoticeStyleCard( + item: item, + sourceName: sourceName, + sourceId: sourceId, + onTap: onTap, + ); + case '视频': + return VideoStyleCard( + item: item, + sourceName: sourceName, + sourceId: sourceId, + onTap: onTap, + ); + case '群组': + return GroupStyleCard( + item: item, + sourceName: sourceName, + sourceId: sourceId, + onTap: onTap, + maxWidth: maxWidth, + ); + case '数据': + return DataStyleCard( + item: item, + sourceName: sourceName, + sourceId: sourceId, + onTap: onTap, + ); + case '市场': + return MarketStyleCard( + item: item, + sourceName: sourceName, + sourceId: sourceId, + onTap: onTap, + maxWidth: maxWidth, + ); + default: + return PlazaItemCard( + item: item, + sourceName: sourceName, + sourceId: sourceId, + onTap: onTap, + maxWidth: maxWidth, + ); + } +} diff --git a/lib/pages/home/HomePage.dart b/lib/pages/home/HomePage.dart index d5481e496bc31bc1ff292c4ec71b40536c94182e..534e5b37295b1e4187848c3825ffb19e5b843457 100644 --- a/lib/pages/home/HomePage.dart +++ b/lib/pages/home/HomePage.dart @@ -11,7 +11,6 @@ import 'package:orginone/components/ResponsiveLayout/responsive_scaffold.dart'; import 'package:orginone/dart/base/storages/storage.dart'; import 'package:orginone/utils/responsive/device_utils.dart'; import 'package:orginone/pages/home/components/BadgeTabWidget/BadgeTabWidget.dart'; -import 'package:orginone/components/KeepAliveWidget/KeepAliveWidget.dart'; import 'package:orginone/components/ExpandTabBar/ExpandTabBar.dart'; import 'package:orginone/components/XStatefulWidget/XStatefulWidget.dart'; import 'package:orginone/components/Tip/QuitAppTipWidget.dart'; @@ -235,9 +234,11 @@ class _HomePageState extends XStatefulState child: ExtendedTabBarView( controller: _tabController, children: [ - KeepAliveWidget(child: ChatPage()), - KeepAliveWidget(child: WorkPage()), - KeepAliveWidget(child: const PortalPage()), + // 子页面已通过 AutomaticKeepAliveClientMixin 实现状态保持 + // 统一不在外层包裹 KeepAliveWidget,避免双重 keep-alive 导致的状态混乱 + ChatPage(), + const WorkPage(), + const PortalPage(), const DiscoverPage(), const RelationPage(), ], diff --git a/lib/pages/iot/device_detail_page.dart b/lib/pages/iot/device_detail_page.dart index 7aeac2aaaee38c2549a726b96f6519c7317dc876..312a8409e85cad057e11f116152ed8748acf940d 100644 --- a/lib/pages/iot/device_detail_page.dart +++ b/lib/pages/iot/device_detail_page.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:orginone/components/base/oip_component_state.dart'; import 'package:orginone/components/base/oip_feedback.dart'; import 'package:orginone/components/iot/thing_model_view.dart'; +import 'package:orginone/config/oip_tokens.dart'; +import 'package:orginone/config/theme/unified_style.dart'; import 'package:orginone/dart/core/iot/command_service.dart'; import 'package:orginone/dart/core/iot/device.dart'; import 'package:orginone/dart/core/iot/device_repository.dart'; @@ -148,10 +151,12 @@ class _DeviceDetailPageState extends State { onAcknowledgeEvent: _acknowledgeEvent, ) else - const Padding( - padding: EdgeInsets.all(24), + Padding( + padding: const EdgeInsets.all(24), child: Center( - child: Text('未加载到物模型', style: TextStyle(color: Color(0xFF94A3B8))), + child: Text('未加载到物模型', + style: XFonts.captionSmall + .copyWith(color: OipSurface.mutedFg)), ), ), const SizedBox(height: 32), @@ -165,9 +170,9 @@ class _DeviceDetailPageState extends State { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: const Color(0xFFF8FAFC), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: const Color(0xFFE2E8F0)), + color: OipSurface.background, + borderRadius: BorderRadius.circular(OipRadius.md), + border: Border.all(color: OipSurface.border), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -179,18 +184,21 @@ class _DeviceDetailPageState extends State { Expanded( child: Text( device.name, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + style: XFonts.titleSmall, ), ), Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: statusColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(OipRadius.xs), ), child: Text( device.status.name, - style: TextStyle(fontSize: 12, color: statusColor, fontWeight: FontWeight.w500), + style: XFonts.captionSmall.copyWith( + fontSize: 16.sp, + color: statusColor, + fontWeight: FontWeight.w500), ), ), ], @@ -246,12 +254,15 @@ class _InfoRow extends StatelessWidget { children: [ SizedBox( width: 72, - child: Text(label, style: const TextStyle(fontSize: 12, color: Color(0xFF64748B))), + child: Text(label, + style: XFonts.captionSmall.copyWith( + fontSize: 16.sp, color: OipText.tertiary)), ), Expanded( child: SelectableText( value, - style: const TextStyle(fontSize: 12, color: Color(0xFF1E293B)), + style: XFonts.captionSmall.copyWith( + fontSize: 16.sp, color: OipText.primary), ), ), ], @@ -261,12 +272,12 @@ class _InfoRow extends StatelessWidget { } Color _statusColor(DeviceStatus status) => switch (status) { - DeviceStatus.online => const Color(0xFF10B981), - DeviceStatus.offline => const Color(0xFF94A3B8), - DeviceStatus.error => const Color(0xFFF43F5E), - DeviceStatus.maintenance => const Color(0xFFF59E0B), - DeviceStatus.unactivated => const Color(0xFFCBD5E1), -}; + DeviceStatus.online => OipState.success, + DeviceStatus.offline => OipSurface.mutedFg, + DeviceStatus.error => OipState.error, + DeviceStatus.maintenance => OipState.warning, + DeviceStatus.unactivated => OipText.disabled, + }; /// 异步指令进度对话框 class _AsyncProgressDialog extends StatelessWidget { @@ -298,10 +309,10 @@ class _AsyncProgressDialog extends StatelessWidget { : Icons.sync, size: 40, color: tracker.status == CommandStatus.succeeded - ? const Color(0xFF10B981) + ? OipState.success : tracker.status == CommandStatus.failed - ? const Color(0xFFF43F5E) - : const Color(0xFF3B82F6), + ? OipState.error + : OipState.info, ), const SizedBox(height: 12), Text(tracker.status.label), @@ -309,7 +320,8 @@ class _AsyncProgressDialog extends StatelessWidget { const SizedBox(height: 4), Text( tracker.errorMessage!, - style: const TextStyle(fontSize: 12, color: Color(0xFFF43F5E)), + style: XFonts.captionSmall.copyWith( + fontSize: 16.sp, color: OipState.error), textAlign: TextAlign.center, ), ], @@ -320,7 +332,8 @@ class _AsyncProgressDialog extends StatelessWidget { minHeight: 4, ), const SizedBox(height: 4), - Text('${tracker.progress}%', style: const TextStyle(fontSize: 11)), + Text('${tracker.progress}%', + style: XFonts.captionSmall.copyWith(fontSize: 16.sp)), ], ], ), diff --git a/lib/pages/iot/device_list_page.dart b/lib/pages/iot/device_list_page.dart index 073fba7f346a3573ebf0053aca59fdc3816eaeb8..12bff18b35f14e0e0404b9cfaa5e202cea9eb32e 100644 --- a/lib/pages/iot/device_list_page.dart +++ b/lib/pages/iot/device_list_page.dart @@ -1,9 +1,12 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:orginone/components/base/oip_a11y.dart'; import 'package:orginone/components/base/oip_component_state.dart'; import 'package:orginone/components/base/oip_feedback.dart'; +import 'package:orginone/config/oip_tokens.dart'; +import 'package:orginone/config/theme/unified_style.dart'; import 'package:orginone/dart/core/iot/device.dart'; import 'package:orginone/dart/core/iot/device_repository.dart'; import 'package:orginone/routers/router_const.dart'; @@ -147,9 +150,11 @@ class _DeviceListPageState extends State Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('设备管理'), + title: Text('设备管理', style: XFonts.headlineSmall), bottom: TabBar( controller: _tabController, + labelStyle: XFonts.tabLabel, + unselectedLabelStyle: XFonts.tabUnselected, tabs: _statusTabs.map((t) => Tab(text: t.$1)).toList(), isScrollable: false, tabAlignment: TabAlignment.fill, @@ -228,8 +233,8 @@ class _DeviceListPageState extends State : null, isDense: true, border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + borderRadius: BorderRadius.circular(OipRadius.md), + borderSide: const BorderSide(color: OipSurface.border), ), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), ), @@ -257,7 +262,7 @@ class _DeviceListTile extends StatelessWidget { device.name, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + style: XFonts.labelSmall, ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -265,7 +270,8 @@ class _DeviceListTile extends StatelessWidget { const SizedBox(height: 2), Text( 'ID: ${device.id}', - style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), + style: XFonts.captionSmall.copyWith( + fontSize: 16.sp, color: OipSurface.mutedFg), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -273,7 +279,10 @@ class _DeviceListTile extends StatelessWidget { const SizedBox(height: 2), Text( '${device.location!.latitude.toStringAsFixed(4)}, ${device.location!.longitude.toStringAsFixed(4)}', - style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8), fontFamily: 'monospace'), + style: XFonts.captionSmall.copyWith( + fontSize: 16.sp, + color: OipSurface.mutedFg, + fontFamily: 'monospace'), ), ], ], @@ -282,11 +291,12 @@ class _DeviceListTile extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: statusColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(OipRadius.xs), ), child: Text( device.status.name, - style: TextStyle(fontSize: 11, color: statusColor), + style: XFonts.captionSmall.copyWith( + fontSize: 16.sp, color: statusColor), ), ), onTap: onTap, @@ -297,9 +307,9 @@ class _DeviceListTile extends StatelessWidget { } Color _statusColor(DeviceStatus status) => switch (status) { - DeviceStatus.online => const Color(0xFF10B981), - DeviceStatus.offline => const Color(0xFF94A3B8), - DeviceStatus.error => const Color(0xFFF43F5E), - DeviceStatus.maintenance => const Color(0xFFF59E0B), - DeviceStatus.unactivated => const Color(0xFFCBD5E1), -}; + DeviceStatus.online => OipState.success, + DeviceStatus.offline => OipSurface.mutedFg, + DeviceStatus.error => OipState.error, + DeviceStatus.maintenance => OipState.warning, + DeviceStatus.unactivated => OipText.disabled, + }; diff --git a/lib/pages/iot/device_map_page.dart b/lib/pages/iot/device_map_page.dart index 424a0d91c7312721969544502c60b623a069e9a7..b5b1e60b97d1bc5ec43d88691c145f85489d12b1 100644 --- a/lib/pages/iot/device_map_page.dart +++ b/lib/pages/iot/device_map_page.dart @@ -3,6 +3,7 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:orginone/components/base/oip_component_state.dart'; import 'package:orginone/components/base/oip_feedback.dart'; +import 'package:orginone/config/theme/unified_style.dart'; import 'package:orginone/dart/core/iot/device.dart'; import 'package:orginone/dart/core/iot/device_clusterer.dart'; import 'package:orginone/dart/core/iot/device_repository.dart'; @@ -103,7 +104,7 @@ class _DeviceMapPageState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('设备地图'), + title: Text('设备地图', style: XFonts.headlineSmall), actions: [ IconButton( icon: const Icon(Icons.refresh), diff --git a/lib/pages/oip/approval_center_page.dart b/lib/pages/oip/approval_center_page.dart index 57eff3dca5c0c48ed522828d75176b28da61e895..d3ace5b1622f8c7a5d2f42b05d55a7590f5094f6 100644 --- a/lib/pages/oip/approval_center_page.dart +++ b/lib/pages/oip/approval_center_page.dart @@ -4,6 +4,7 @@ import 'package:orginone/components/base/oip_component_state.dart'; import 'package:orginone/components/base/oip_feedback.dart'; import 'package:orginone/config/oip_tokens.dart'; import 'package:orginone/config/responsive_breakpoints.dart'; +import 'package:orginone/config/theme/unified_style.dart'; import 'package:orginone/dart/core/oip/oip_api.dart'; import 'package:orginone/dart/core/oip/models/vet.dart'; import 'package:orginone/dart/core/oip/state_provider.dart'; @@ -126,7 +127,7 @@ class _ApprovalCenterPageState extends State return Scaffold( backgroundColor: OipSurface.background, appBar: AppBar( - title: const Text('审批中心'), + title: Text('审批中心', style: XFonts.headlineSmall), backgroundColor: OipSurface.card, foregroundColor: OipText.primary, elevation: 0, @@ -141,6 +142,8 @@ class _ApprovalCenterPageState extends State controller: _tabController, labelColor: OipBrand.primary, unselectedLabelColor: OipText.tertiary, + labelStyle: XFonts.tabLabel, + unselectedLabelStyle: XFonts.tabUnselected, indicatorColor: OipBrand.primary, indicatorSize: TabBarIndicatorSize.label, tabs: [ diff --git a/lib/pages/oip/market_browser_page.dart b/lib/pages/oip/market_browser_page.dart index d2380879dd7564b2d2459a3e61f4e4a0a2ecd129..2964ddc2cdd10edb08f39f701aeddbcbfc04e4b9 100644 --- a/lib/pages/oip/market_browser_page.dart +++ b/lib/pages/oip/market_browser_page.dart @@ -5,6 +5,7 @@ import 'package:orginone/components/base/oip_feedback.dart'; import 'package:orginone/config/form_i_config.dart'; import 'package:orginone/config/oip_tokens.dart'; import 'package:orginone/config/responsive_breakpoints.dart'; +import 'package:orginone/config/theme/unified_style.dart'; import 'package:orginone/dart/core/oip/oip_api.dart'; import 'package:orginone/dart/core/oip/models/vet.dart'; @@ -133,7 +134,7 @@ class _MarketBrowserPageState extends State { return Scaffold( backgroundColor: OipSurface.background, appBar: AppBar( - title: const Text('市场'), + title: Text('市场', style: XFonts.headlineSmall), backgroundColor: OipSurface.card, foregroundColor: OipText.primary, elevation: 0, diff --git a/lib/pages/oip/vet_detail_page.dart b/lib/pages/oip/vet_detail_page.dart index a0da771bd5233b38b8c989a15c8b9800c5e27004..7dc76eef66872089c22af2f9533f242b51bea5d1 100644 --- a/lib/pages/oip/vet_detail_page.dart +++ b/lib/pages/oip/vet_detail_page.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:orginone/components/base/oip_feedback.dart'; +import 'package:orginone/config/oip_tokens.dart'; +import 'package:orginone/config/theme/unified_style.dart'; import 'package:orginone/dart/core/oip/models/vet.dart'; import 'package:orginone/dart/core/oip/oip_api.dart'; import 'package:orginone/dart/core/oip/state_provider.dart'; @@ -26,7 +29,7 @@ class _VetDetailPageState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('VET 详情')), + appBar: AppBar(title: Text('VET 详情', style: XFonts.headlineSmall)), body: Consumer( builder: (context, state, _) { final vet = state.vetState.byId[widget.vetId]; @@ -81,9 +84,9 @@ class _HeaderCard extends StatelessWidget { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: const Color(0xFFF8FAFC), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: const Color(0xFFE2E8F0)), + color: OipSurface.background, + borderRadius: BorderRadius.circular(OipRadius.md), + border: Border.all(color: OipSurface.border), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -95,7 +98,7 @@ class _HeaderCard extends StatelessWidget { Expanded( child: Text( vet.object.id, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + style: XFonts.titleSmall, maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -104,11 +107,11 @@ class _HeaderCard extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: stateColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(OipRadius.xs), ), child: Text( vet.lifecycleState.name, - style: TextStyle(fontSize: 11, color: stateColor), + style: XFonts.captionSmall.copyWith(fontSize: 16.sp, color: stateColor), ), ), ], @@ -131,11 +134,11 @@ class _HeaderCard extends StatelessWidget { '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; Color _stateColor(LifecycleState s) => switch (s) { - LifecycleState.active => const Color(0xFF10B981), - LifecycleState.suspended => const Color(0xFFF59E0B), - LifecycleState.revoked => const Color(0xFFF43F5E), - LifecycleState.expired => const Color(0xFF94A3B8), - LifecycleState.archived => const Color(0xFF64748B), + LifecycleState.active => OipState.success, + LifecycleState.suspended => OipState.warning, + LifecycleState.revoked => OipState.error, + LifecycleState.expired => OipText.tertiary, + LifecycleState.archived => OipText.secondary, }; } @@ -154,11 +157,11 @@ class _InfoRow extends StatelessWidget { SizedBox( width: 80, child: Text(label, - style: const TextStyle(fontSize: 12, color: Color(0xFF64748B))), + style: XFonts.labelSmall.copyWith(fontSize: 16.sp, color: OipText.secondary)), ), Expanded( child: Text(value, - style: const TextStyle(fontSize: 12, color: Color(0xFF1E293B))), + style: XFonts.labelSmall.copyWith(fontSize: 16.sp, color: OipText.primary)), ), ], ), @@ -181,20 +184,20 @@ class _OperationsCard extends StatelessWidget { runSpacing: 6, children: vet.operation.map((op) => _PermissionChip( label: op.name, - color: const Color(0xFF10B981), + color: OipState.success, )).toList(), ), if (vet.negativePermissions.isNotEmpty) ...[ const SizedBox(height: 8), - const Text('否定权限:', - style: TextStyle(fontSize: 12, color: Color(0xFF64748B))), + Text('否定权限:', + style: XFonts.labelSmall.copyWith(fontSize: 16.sp, color: OipText.secondary)), const SizedBox(height: 4), Wrap( spacing: 6, runSpacing: 6, children: vet.negativePermissions.map((op) => _PermissionChip( label: op.name, - color: const Color(0xFFF43F5E), + color: OipState.error, )).toList(), ), ], @@ -214,10 +217,10 @@ class _PermissionChip extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(OipRadius.xs), border: Border.all(color: color.withValues(alpha: 0.3)), ), - child: Text(label, style: TextStyle(fontSize: 11, color: color)), + child: Text(label, style: XFonts.captionSmall.copyWith(fontSize: 16.sp, color: color)), ); } } @@ -276,10 +279,10 @@ class _ConstraintsCard extends StatelessWidget { } if (items.isEmpty) { - return const _SectionCard( + return _SectionCard( title: '约束条件', icon: Icons.shield_outlined, - children: [Text('无约束(最宽泛授权)', style: TextStyle(fontSize: 12, color: Color(0xFF64748B)))], + children: [Text('无约束(最宽泛授权)', style: XFonts.labelSmall.copyWith(fontSize: 16.sp, color: OipText.secondary))], ); } @@ -291,16 +294,16 @@ class _ConstraintsCard extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(i.icon, size: 16, color: const Color(0xFF64748B)), + Icon(i.icon, size: 16, color: OipText.secondary), const SizedBox(width: 8), SizedBox( width: 64, child: Text(i.label, - style: const TextStyle(fontSize: 12, color: Color(0xFF64748B))), + style: XFonts.labelSmall.copyWith(fontSize: 16.sp, color: OipText.secondary)), ), Expanded( child: Text(i.value, - style: const TextStyle(fontSize: 12, color: Color(0xFF1E293B))), + style: XFonts.labelSmall.copyWith(fontSize: 16.sp, color: OipText.primary)), ), ], ), @@ -370,15 +373,15 @@ class _DelegationChainCard extends StatelessWidget { if (delegations.isNotEmpty) ...[ const SizedBox(height: 12), Text('子委托 (${delegations.length})', - style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF475569))), + style: XFonts.labelSmall.copyWith(fontSize: 16.sp, fontWeight: FontWeight.w600, color: OipText.secondary)), const SizedBox(height: 4), ...delegations.map((d) => ListTile( dense: true, contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.subdirectory_arrow_right, size: 16, color: Color(0xFF94A3B8)), - title: Text(d.object.id, style: const TextStyle(fontSize: 12)), + leading: const Icon(Icons.subdirectory_arrow_right, size: 16, color: OipText.tertiary), + title: Text(d.object.id, style: XFonts.labelSmall.copyWith(fontSize: 16.sp)), subtitle: Text(d.lifecycleState.name, - style: const TextStyle(fontSize: 10, color: Color(0xFF64748B))), + style: XFonts.captionSmall.copyWith(fontSize: 16.sp, color: OipText.secondary)), )), ], ], @@ -413,9 +416,9 @@ class _ChainNode extends StatelessWidget { height: 12, decoration: BoxDecoration( shape: BoxShape.circle, - color: isCurrent ? const Color(0xFF2563EB) : const Color(0xFFCBD5E1), + color: isCurrent ? OipBrand.primary : OipText.disabled, border: Border.all( - color: isCurrent ? const Color(0xFF2563EB) : const Color(0xFF94A3B8), + color: isCurrent ? OipBrand.primary : OipText.tertiary, width: 2, ), ), @@ -424,7 +427,7 @@ class _ChainNode extends StatelessWidget { Container( width: 2, height: 20, - color: const Color(0xFFCBD5E1), + color: OipText.disabled, ), ], ), @@ -437,17 +440,17 @@ class _ChainNode extends StatelessWidget { children: [ Text( id, - style: TextStyle( - fontSize: 12, + style: XFonts.labelSmall.copyWith( + fontSize: 16.sp, fontWeight: isCurrent ? FontWeight.w600 : FontWeight.w400, - color: isCurrent ? const Color(0xFF1E293B) : const Color(0xFF64748B), + color: isCurrent ? OipText.primary : OipText.secondary, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), if (isCurrent) - const Text('当前 VET', - style: TextStyle(fontSize: 10, color: Color(0xFF2563EB))), + Text('当前 VET', + style: XFonts.captionSmall.copyWith(fontSize: 16.sp, color: OipBrand.primary)), ], ), ), @@ -468,22 +471,22 @@ class _SectionCard extends StatelessWidget { return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: const Color(0xFFFFFFFF), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: const Color(0xFFE2E8F0)), + color: OipSurface.card, + borderRadius: BorderRadius.circular(OipRadius.md), + border: Border.all(color: OipSurface.border), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - Icon(icon, size: 16, color: const Color(0xFF64748B)), + Icon(icon, size: 16, color: OipText.secondary), const SizedBox(width: 6), Text(title, - style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), + style: XFonts.labelSmall.copyWith(fontWeight: FontWeight.w600)), ], ), - const Divider(height: 12, color: Color(0xFFF1F5F9)), + const Divider(height: 12, color: OipSurface.muted), ...children, ], ), @@ -510,10 +513,10 @@ class _ActionButtons extends StatelessWidget { if (vet.canDelegate()) const SizedBox(width: 8), Expanded( child: OutlinedButton.icon( - icon: const Icon(Icons.block, size: 16, color: Color(0xFFF43F5E)), - label: const Text('撤销', style: TextStyle(color: Color(0xFFF43F5E))), + icon: const Icon(Icons.block, size: 16, color: OipState.error), + label: const Text('撤销', style: TextStyle(color: OipState.error)), style: OutlinedButton.styleFrom( - foregroundColor: const Color(0xFFF43F5E), + foregroundColor: OipState.error, ), onPressed: () => _revoke(context), ), @@ -548,7 +551,7 @@ class _ActionButtons extends StatelessWidget { TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('取消')), TextButton( onPressed: () => Navigator.of(ctx).pop(true), - child: const Text('确认撤销', style: TextStyle(color: Color(0xFFF43F5E))), + child: const Text('确认撤销', style: TextStyle(color: OipState.error)), ), ], ), diff --git a/lib/pages/oip/vet_management_page.dart b/lib/pages/oip/vet_management_page.dart index f0ef00e651ae28467c2261ed202c222ab3fd3569..39efb1a6f9bd759353afb216de590c14e2a05725 100644 --- a/lib/pages/oip/vet_management_page.dart +++ b/lib/pages/oip/vet_management_page.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:orginone/components/base/oip_a11y.dart'; import 'package:orginone/components/base/oip_component_state.dart'; import 'package:orginone/components/base/oip_feedback.dart'; +import 'package:orginone/config/oip_tokens.dart'; +import 'package:orginone/config/theme/unified_style.dart'; import 'package:orginone/dart/core/oip/models/vet.dart'; import 'package:orginone/dart/core/oip/state_provider.dart'; import 'package:orginone/pages/oip/vet_detail_page.dart'; @@ -56,7 +59,7 @@ class _VetManagementPageState extends State Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('我的 VET'), + title: Text('我的 VET', style: XFonts.headlineSmall), actions: [ A11yIconButton( icon: Icons.refresh, @@ -66,6 +69,8 @@ class _VetManagementPageState extends State ], bottom: TabBar( controller: _tabController, + labelStyle: XFonts.tabLabel, + unselectedLabelStyle: XFonts.tabUnselected, tabs: const [ Tab(text: '活跃'), Tab(text: '已过期'), @@ -126,15 +131,16 @@ class _VetList extends StatelessWidget { @override Widget build(BuildContext context) { if (vets.isEmpty) { - return const Center( + return Center( child: Padding( - padding: EdgeInsets.all(24), + padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.inbox_outlined, size: 40, color: Color(0xFF94A3B8)), - SizedBox(height: 8), - Text('此分类下暂无 VET', style: TextStyle(color: Color(0xFF64748B))), + const Icon(Icons.inbox_outlined, size: 40, color: OipText.tertiary), + const SizedBox(height: 8), + Text('此分类下暂无 VET', + style: XFonts.labelSmall.copyWith(color: OipText.secondary)), ], ), ), @@ -146,7 +152,7 @@ class _VetList extends StatelessWidget { child: ListView.separated( padding: const EdgeInsets.symmetric(vertical: 8), itemCount: vets.length, - separatorBuilder: (_, __) => const Divider(height: 1, color: Color(0xFFF1F5F9)), + separatorBuilder: (_, __) => const Divider(height: 1, color: OipSurface.muted), itemBuilder: (ctx, i) => _VetListTile(vet: vets[i]), ), ); @@ -168,13 +174,13 @@ class _VetListTile extends StatelessWidget { height: 40, decoration: BoxDecoration( color: stateColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(OipRadius.md), ), child: Icon(Icons.verified, size: 20, color: stateColor), ), title: Text( vet.object.id, - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + style: XFonts.labelMedium, maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -184,7 +190,7 @@ class _VetListTile extends StatelessWidget { const SizedBox(height: 2), Text( '类型: ${vet.object.type} · 操作: ${vet.operation.length} 项', - style: const TextStyle(fontSize: 11, color: Color(0xFF64748B)), + style: XFonts.captionSmall.copyWith(fontSize: 16.sp, color: OipText.secondary), ), const SizedBox(height: 2), Row( @@ -193,21 +199,22 @@ class _VetListTile extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), decoration: BoxDecoration( color: stateColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(OipRadius.xs), ), child: Text( stateLabel, - style: TextStyle(fontSize: 10, color: stateColor), + style: XFonts.captionSmall.copyWith(fontSize: 16.sp, color: stateColor), ), ), const SizedBox(width: 6), if (vet.canDelegate()) - const Text('可委托', style: TextStyle(fontSize: 10, color: Color(0xFF3B82F6))), + Text('可委托', + style: XFonts.captionSmall.copyWith(fontSize: 16.sp, color: OipBrand.primary)), ], ), ], ), - trailing: const Icon(Icons.chevron_right, size: 18, color: Color(0xFF94A3B8)), + trailing: const Icon(Icons.chevron_right, size: 18, color: OipText.tertiary), onTap: () => Navigator.of(context).push( MaterialPageRoute( builder: (_) => VetDetailPage(vetId: vet.id), @@ -217,11 +224,11 @@ class _VetListTile extends StatelessWidget { } Color _stateColor(LifecycleState s) => switch (s) { - LifecycleState.active => const Color(0xFF10B981), - LifecycleState.suspended => const Color(0xFFF59E0B), - LifecycleState.revoked => const Color(0xFFF43F5E), - LifecycleState.expired => const Color(0xFF94A3B8), - LifecycleState.archived => const Color(0xFF64748B), + LifecycleState.active => OipState.success, + LifecycleState.suspended => OipState.warning, + LifecycleState.revoked => OipState.error, + LifecycleState.expired => OipText.tertiary, + LifecycleState.archived => OipText.secondary, }; String _stateLabel(LifecycleState s) => switch (s) { diff --git a/lib/pages/portal/account_binding_page.dart b/lib/pages/portal/account_binding_page.dart index d0354192281d5fdf3a1d9a606b0ac5838401a5fa..e6a2e8435bfa64300ee068d533b34fd56ddd314b 100644 --- a/lib/pages/portal/account_binding_page.dart +++ b/lib/pages/portal/account_binding_page.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:orginone/components/XScaffold/XScaffold.dart'; +import 'package:orginone/config/oip_tokens.dart'; +import 'package:orginone/config/theme/unified_style.dart'; import 'package:orginone/dart/core/public/re_auth_service.dart'; import 'package:orginone/dart/core/public/secret_vault.dart'; @@ -137,7 +139,7 @@ class _AccountBindingPageState extends State { Text( '扫码绑定', style: - TextStyle(fontSize: 14.sp, color: Colors.grey.shade500), + TextStyle(fontSize: 16.sp, color: Colors.grey.shade500), ), ], ), @@ -148,7 +150,7 @@ class _AccountBindingPageState extends State { widget.bindingType == BindingType.wechat ? '请使用微信扫描二维码完成绑定' : '请使用钉钉扫描二维码完成绑定', - style: TextStyle(fontSize: 14.sp, color: Colors.grey.shade600), + style: TextStyle(fontSize: 16.sp, color: Colors.grey.shade600), ), SizedBox(height: 32.h), SizedBox( @@ -162,13 +164,14 @@ class _AccountBindingPageState extends State { ); }, style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).primaryColor, + backgroundColor: OipBrand.primary, foregroundColor: Colors.white, + disabledBackgroundColor: OipBrand.primaryBorder, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12)), ), - child: const Text('开始扫码', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + child: Text('开始扫码', + style: XFonts.labelLarge.copyWith(color: Colors.white)), ), ), ], @@ -199,7 +202,7 @@ class _AccountBindingPageState extends State { children: [ Text(_inputLabel, style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, color: Colors.grey.shade700, fontWeight: FontWeight.w500)), SizedBox(height: 8.h), @@ -208,11 +211,11 @@ class _AccountBindingPageState extends State { decoration: InputDecoration( hintText: _inputHint, hintStyle: - TextStyle(color: Colors.grey.shade400, fontSize: 15.sp), + TextStyle(color: Colors.grey.shade400, fontSize: 16.sp), border: InputBorder.none, contentPadding: EdgeInsets.zero, ), - style: TextStyle(fontSize: 15.sp, color: Colors.black87), + style: TextStyle(fontSize: 16.sp, color: Colors.black87), validator: (v) => (v == null || v.isEmpty) ? '请输入$_inputLabel' : null, ), @@ -229,7 +232,7 @@ class _AccountBindingPageState extends State { children: [ Text('验证码', style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, color: Colors.grey.shade700, fontWeight: FontWeight.w500)), SizedBox(height: 8.h), @@ -241,11 +244,11 @@ class _AccountBindingPageState extends State { decoration: InputDecoration( hintText: '请输入验证码', hintStyle: - TextStyle(color: Colors.grey.shade400, fontSize: 15.sp), + TextStyle(color: Colors.grey.shade400, fontSize: 16.sp), border: InputBorder.none, contentPadding: EdgeInsets.zero, ), - style: TextStyle(fontSize: 15.sp, color: Colors.black87), + style: TextStyle(fontSize: 16.sp, color: Colors.black87), validator: (v) => (v == null || v.isEmpty) ? '请输入验证码' : null, ), ), @@ -255,9 +258,9 @@ class _AccountBindingPageState extends State { child: TextButton( onPressed: _countdown > 0 ? null : _sendCode, style: TextButton.styleFrom( - foregroundColor: Theme.of(context).primaryColor, + foregroundColor: OipBrand.primary, ), - child: Text(_countdown > 0 ? '${_countdown}s' : '获取验证码'), + child: Text(_countdown > 0 ? '${_countdown}s' : '获取验证码', style: XFonts.labelMedium), ), ), ], @@ -274,8 +277,9 @@ class _AccountBindingPageState extends State { child: ElevatedButton( onPressed: _isSaving ? null : _bind, style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).primaryColor, + backgroundColor: OipBrand.primary, foregroundColor: Colors.white, + disabledBackgroundColor: OipBrand.primaryBorder, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), @@ -286,8 +290,8 @@ class _AccountBindingPageState extends State { child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Colors.white))) - : const Text('确认绑定', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + : Text('确认绑定', + style: XFonts.labelLarge.copyWith(color: Colors.white)), ), ); } diff --git a/lib/pages/portal/add_favorite_page.dart b/lib/pages/portal/add_favorite_page.dart index ce2c51eb7786d49ed99838551e8d70ec36310c10..34c81274e5303742b9b9a225a85e5226e336709a 100644 --- a/lib/pages/portal/add_favorite_page.dart +++ b/lib/pages/portal/add_favorite_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:orginone/config/oip_tokens.dart'; +import 'package:orginone/config/theme/unified_style.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:orginone/components/TabContainerWidget/TabContainerWidget.dart'; import 'package:orginone/components/TabContainerWidget/types.dart'; @@ -59,7 +60,7 @@ class _AddFavoritePageState extends State { return Scaffold( backgroundColor: const Color(0xFFF5F6FA), appBar: AppBar( - title: const Text('添加常用'), + title: Text('添加常用', style: XFonts.headlineSmall), centerTitle: true, elevation: 0, ), @@ -130,7 +131,7 @@ class _AddFavoritePageState extends State { Text( '常用', style: TextStyle( - fontSize: 12.sp, + fontSize: 16.sp, color: OipBrand.primary, ), ), diff --git a/lib/pages/portal/agent_chat_page.dart b/lib/pages/portal/agent_chat_page.dart index 0bc4c54a4b6a9d4f3930f3beff5a62acd5c543e6..878bb8ebd13b4dc0a8bb890d1dbfc9a1e214509b 100644 --- a/lib/pages/portal/agent_chat_page.dart +++ b/lib/pages/portal/agent_chat_page.dart @@ -168,7 +168,7 @@ class _AgentChatPageState extends State { color: Colors.black87, height: 1.6), code: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, color: const Color(0xFFE53935), backgroundColor: Colors.grey.shade100, ), @@ -181,7 +181,7 @@ class _AgentChatPageState extends State { SizedBox(height: 4.h), Text(_fmt(ts), style: TextStyle( - fontSize: 12.sp, + fontSize: 16.sp, color: isUser ? Colors.white.withValues(alpha: 0.7) : Colors.grey.shade500)), diff --git a/lib/pages/portal/agent_config_page.dart b/lib/pages/portal/agent_config_page.dart index 44ecd965700d46ba9ea024ee2fe5c5a539d78959..791f7d67b7388f11a21f68ac51adbdff23270266 100644 --- a/lib/pages/portal/agent_config_page.dart +++ b/lib/pages/portal/agent_config_page.dart @@ -104,7 +104,7 @@ class _AgentConfigPageState extends State { child: Text( text, style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, color: Colors.grey.shade700, ), ), @@ -256,7 +256,7 @@ class _AgentConfigPageState extends State { Text( '危险区域', style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, fontWeight: FontWeight.w600, color: Colors.red, ), diff --git a/lib/pages/portal/agent_management_page.dart b/lib/pages/portal/agent_management_page.dart index ecb97d3e51ec0ab44ff6e6fe566948424cf1abbb..04de342e5fe7fa80603f0d463c17c4fbce4cbfba 100644 --- a/lib/pages/portal/agent_management_page.dart +++ b/lib/pages/portal/agent_management_page.dart @@ -128,14 +128,13 @@ class _AgentManagementPageState extends State { child: Center( child: Text( title, - style: TextStyle( - fontSize: 18.sp, + style: XFonts.labelLarge.copyWith( fontWeight: _selectedTab == index ? FontWeight.w600 - : FontWeight.normal, + : FontWeight.w500, color: _selectedTab == index ? OipBrand.primary - : Colors.grey.shade600, + : OipText.tertiary, ), ), ), diff --git a/lib/pages/portal/agent_settings_page.dart b/lib/pages/portal/agent_settings_page.dart index fbbf3bcc72fbbbe7455c920405ffdd91f7f9c0fe..91dcf96cf6cae312aaa5ef52ae1f1b9f307d1ca1 100644 --- a/lib/pages/portal/agent_settings_page.dart +++ b/lib/pages/portal/agent_settings_page.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:orginone/components/Tip/ToastUtils.dart'; +import 'package:orginone/config/oip_tokens.dart'; import 'package:orginone/dart/core/ai/conversation_manager.dart'; import 'package:orginone/dart/core/ai/llm_service.dart'; import 'package:orginone/dart/core/public/re_auth_service.dart'; @@ -430,9 +431,9 @@ class _AgentSettingsPageState extends State { child: ElevatedButton( onPressed: _isTesting ? null : _testConnection, style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue, + backgroundColor: OipBrand.primary, foregroundColor: Colors.white, - disabledBackgroundColor: Colors.blue.withValues(alpha: 0.5), + disabledBackgroundColor: OipBrand.primaryBorder, ), child: _isTesting ? const SizedBox( @@ -568,9 +569,9 @@ class _AgentSettingsPageState extends State { child: ElevatedButton( onPressed: _isSaving ? null : _saveSettings, style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).primaryColor, + backgroundColor: OipBrand.primary, foregroundColor: Colors.white, - disabledBackgroundColor: Theme.of(context).primaryColor.withValues(alpha: 0.5), + disabledBackgroundColor: OipBrand.primaryBorder, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), diff --git a/lib/pages/portal/assets_management/view.dart b/lib/pages/portal/assets_management/view.dart index b3f7d1accc29ee520220b21ae86977335f1a9a7f..0ff40d5f118bcf003afa3c1204b2607aceabe330 100644 --- a/lib/pages/portal/assets_management/view.dart +++ b/lib/pages/portal/assets_management/view.dart @@ -243,7 +243,7 @@ class _AppManagementPageState extends State { // overflow: TextOverflow.ellipsis, // maxLines: 1, // style: TextStyle( -// fontSize: 14.sp, +// fontSize: 16.sp, // color: OipBrand.primary))) // ], // ), diff --git a/lib/pages/portal/create_agent_page.dart b/lib/pages/portal/create_agent_page.dart index 639368eed8973da2d4f068e7c9bbe03840361794..f66e66d73f167839310537aa434fc4758ac7479b 100644 --- a/lib/pages/portal/create_agent_page.dart +++ b/lib/pages/portal/create_agent_page.dart @@ -162,7 +162,7 @@ class _CreateAgentPageState extends State { Text( '选择头像', style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, color: Colors.grey.shade600, ), ), @@ -238,7 +238,7 @@ class _CreateAgentPageState extends State { Text( label, style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, color: Colors.grey.shade700, fontWeight: FontWeight.w500, ), @@ -288,7 +288,7 @@ class _CreateAgentPageState extends State { Text( '创建提示', style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, fontWeight: FontWeight.w600, color: OipBrand.primary, ), diff --git a/lib/pages/portal/explorer/explorer_page.dart b/lib/pages/portal/explorer/explorer_page.dart index 46705e6072ef33f3d8956c47bcf881493c1be0cf..04116579e8b8cdcacf79026207fb04ce9ad4ccc3 100644 --- a/lib/pages/portal/explorer/explorer_page.dart +++ b/lib/pages/portal/explorer/explorer_page.dart @@ -276,7 +276,7 @@ class _ExplorerPageState extends State Text( '返回', style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, color: OipBrand.primary, ), ), @@ -298,7 +298,7 @@ class _ExplorerPageState extends State child: Text( '全部', style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, color: OipBrand.primary, ), ), @@ -330,7 +330,7 @@ class _ExplorerPageState extends State child: Text( level.title, style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, color: isLast ? XColors.black3 : OipBrand.primary, @@ -429,14 +429,8 @@ class _ExplorerPageState extends State controller: _tabController, labelColor: OipBrand.primary, unselectedLabelColor: XColors.doorDesGrey, - labelStyle: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.bold, - ), - unselectedLabelStyle: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.normal, - ), + labelStyle: XFonts.tabLabel, + unselectedLabelStyle: XFonts.tabUnselected, indicatorColor: OipBrand.primary, indicatorSize: TabBarIndicatorSize.label, indicatorWeight: 2.h, @@ -563,7 +557,7 @@ class _ExplorerPageState extends State child: Text( tag, style: TextStyle( - fontSize: 12.sp, + fontSize: 16.sp, color: _getItemColor(type), fontWeight: FontWeight.w500, ), diff --git a/lib/pages/portal/home/home_page.dart b/lib/pages/portal/home/home_page.dart index 75ab20c7f38c8c64d4df823e58b316d5323e7f68..61e5328582f8144829642e0e42fb81fcb5bc6f7d 100644 --- a/lib/pages/portal/home/home_page.dart +++ b/lib/pages/portal/home/home_page.dart @@ -12,6 +12,7 @@ import 'package:orginone/dart/core/public/enums.dart'; import 'package:orginone/dart/core/target/base/belong.dart'; import 'package:orginone/dart/core/target/outTeam/istorage.dart'; import 'package:orginone/dart/base/common/commands.dart'; +import 'package:orginone/dart/base/storages/local_page_cache_repository.dart'; import 'package:orginone/dart/core/thing/standard/application.dart'; import 'package:orginone/dart/core/thing/standard/form.dart'; import 'package:orginone/dart/core/thing/systemfile.dart'; @@ -54,6 +55,14 @@ class _HomePageState extends State { int get _storageCount => _storages.length; + static const List _quickActionColors = [ + OipState.success, + OipBrand.primary, + OipState.warning, + OipEntity.cohort, + OipAi.accent, + ]; + @override void initState() { super.initState(); @@ -99,9 +108,20 @@ class _HomePageState extends State { Future _initLoad() async { if (!mounted) return; - setState(() => _isLoading = true); - // 先从内存快照渲染首屏,再按需触发后台定向刷新 - // 使用 8s 超时保护,防止 StoreHub 长时间挂起导致门户页一直 loading + // ★本地存储优先策略:先从 LocalPageCache 读取渲染首屏,避免空白等待 + await _loadFromLocalCache(); + if (!mounted) return; + // 如果本地缓存为空,显示 loading;否则保留缓存数据,后台刷新 + final hasLocalData = _recentChats.isNotEmpty || + _recentTodos.isNotEmpty || + _recentApps.isNotEmpty; + if (!hasLocalData) { + setState(() => _isLoading = true); + } else { + // 已有本地数据,进入"后台刷新"模式 + setState(() => _isRefreshing = true); + } + // 从内存(relationCtrl)读取最新数据并渲染(内存可能已被网络更新) await _refreshAll().timeout( const Duration(seconds: 8), onTimeout: () { @@ -111,13 +131,55 @@ class _HomePageState extends State { XLogUtil.w('门户页 _refreshAll 异常: $e'); }); if (!mounted) return; - setState(() => _isLoading = false); - // 工作台数据过期时按需刷新:会话/待办/应用三类核心数据 + setState(() { + _isLoading = false; + _isRefreshing = false; + }); + // 后台按需刷新:TTL 内不重复请求,过期才触发网络刷新 + // ensureDataFresh 完成后会通过 command flag 通知 UI 刷新 relationCtrl.provider.ensureDataFresh(DataType.chats); relationCtrl.provider.ensureDataFresh(DataType.work); relationCtrl.provider.ensureDataFresh(DataType.apps); } + /// 从本地存储读取缓存数据渲染首屏(避免空白等待) + Future _loadFromLocalCache() async { + final user = relationCtrl.user; + if (user == null) return; + final userId = user.id; + final spaceId = _currentSpace?.id; + try { + final cacheRepo = LocalPageCacheRepository.instance; + // 并行读取各类型缓存 + final results = await Future.wait([ + cacheRepo.read(PageCacheType.chats, userId: userId, spaceId: spaceId), + cacheRepo.read(PageCacheType.todos, userId: userId, spaceId: spaceId), + cacheRepo.read(PageCacheType.apps, userId: userId, spaceId: spaceId), + ]); + // chats 缓存:仅用于首屏占位,实际渲染依赖内存 ISession 对象 + // todos 缓存:仅用于首屏占位,实际渲染依赖内存 IWorkTask 对象 + // apps 缓存:仅用于首屏占位,实际渲染依赖内存 IApplication 对象 + // 注:由于 UI 组件需要领域对象(ISession/IWorkTask/IApplication), + // 本地缓存仅作为"是否有数据"的判断依据,实际渲染仍走 _refreshAll。 + // 若内存为空但本地有缓存,说明冷启动注水未完成,等待 _refreshAll 即可。 + final chatsCache = results[0]; + final todosCache = results[1]; + final appsCache = results[2]; + // 仅日志记录,便于调试 + if (chatsCache != null && chatsCache.isNotEmpty) { + XLogUtil.i('[HomePage] 本地缓存 chats: ${chatsCache.length} 条'); + } + if (todosCache != null && todosCache.isNotEmpty) { + XLogUtil.i('[HomePage] 本地缓存 todos: ${todosCache.length} 条'); + } + if (appsCache != null && appsCache.isNotEmpty) { + XLogUtil.i('[HomePage] 本地缓存 apps: ${appsCache.length} 条'); + } + } catch (e) { + XLogUtil.w('[HomePage] 读取本地缓存失败: $e'); + } + } + Future _refreshAll() async { await Future.wait([ _refreshChats(), @@ -154,14 +216,25 @@ class _HomePageState extends State { onTimeout: () => [], ); if (!mounted) return; - final todos = relationCtrl.work.todos.toList(); - todos.sort((a, b) { + final space = _currentSpace; + final spaceId = space?.id; + final userId = relationCtrl.user?.id; + // 按单位空间过滤待办:选中单位空间时仅显示该空间下的待办; + // 选中个人空间时显示 belongId 为空或等于用户 ID 的个人待办。 + final filtered = relationCtrl.work.todos.where((t) { + final belongId = t.taskdata.belongId ?? ''; + if (spaceId == null || spaceId == userId) { + return belongId.isEmpty || belongId == userId; + } + return belongId == spaceId; + }).toList(); + filtered.sort((a, b) { final aTime = a.metadata.updateTime ?? ''; final bTime = b.metadata.updateTime ?? ''; return bTime.compareTo(aTime); }); setState(() { - _recentTodos = todos.take(3).toList(); + _recentTodos = filtered.take(3).toList(); }); } catch (e) { XLogUtil.w('门户页 _refreshTodos 失败: $e'); @@ -176,40 +249,37 @@ class _HomePageState extends State { const Duration(seconds: 6), onTimeout: () => [], ); - final validApps = - apps.where((a) => !a.groupTags.contains('已删除')).toList(); - if (!mounted) return; - setState(() { - _allApps = validApps; - _recentApps = validApps.take(8).toList(); - }); - // 加载视图(表单) - try { - final forms = []; - for (var app in validApps.take(3)) { - forms.addAll(app.forms); - } - if (mounted) { - setState(() { - _forms = forms.take(10).toList(); - }); + // 去重 + 过滤已删除应用:loadAllApplication 递归加载子目录可能返回重复项 + final seenIds = {}; + final deduped = []; + for (final app in apps) { + if (app.groupTags.contains('已删除')) continue; + final id = app.id; + if (seenIds.add(id)) { + deduped.add(app); } - } catch (e) { - XLogUtil.w('门户页 _refreshApps.forms 失败: $e'); } - // 加载最近文件 - try { - final files = await space.directory.loadFiles().timeout( - const Duration(seconds: 5), - onTimeout: () => [], - ); - if (mounted) { - setState(() { - _recentFiles = files.take(5).toList(); - }); - } - } catch (e) { - XLogUtil.w('门户页 _refreshApps.files 失败: $e'); + if (!mounted) return; + // 并行加载文件和表单 + final filesFuture = space.directory.loadFiles().timeout( + const Duration(seconds: 5), + onTimeout: () => [], + ); + final forms = []; + for (var app in deduped.take(3)) { + forms.addAll(app.forms); + } + final files = await filesFuture; + if (mounted) { + final sortedRecent = deduped.toList() + ..sort((a, b) => + (b.metadata.updateTime ?? '').compareTo(a.metadata.updateTime ?? '')); + setState(() { + _allApps = deduped; + _recentApps = sortedRecent.take(8).toList(); + _forms = forms.take(10).toList(); + _recentFiles = files.take(5).toList(); + }); } } catch (e) { XLogUtil.w('门户页 _refreshApps 失败: $e'); @@ -297,12 +367,26 @@ class _HomePageState extends State { DeviceUtils.updateWithContext(context); final space = _currentSpace; return Scaffold( - backgroundColor: XColors.bgColor, body: _isLoading ? const SkeletonWidget(itemCount: 8) - : RefreshIndicator( - onRefresh: _initLoad, - child: _buildBodyWithRefreshing(space), + : Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFF1A1F36), + Color(0xFF2D2B5F), + Color(0xFF3F2B7E), + OipSurface.background, + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: [0.0, 0.18, 0.32, 0.55], + ), + ), + child: RefreshIndicator( + onRefresh: _initLoad, + child: _buildBodyWithRefreshing(space), + ), ), bottomNavigationBar: const GlobalAiInputBar(contextLabel: '工作'), ); @@ -333,49 +417,18 @@ class _HomePageState extends State { ); } - /// 顶部"数据更新中"指示器(黄色背景 + 小号 Loading + 文案) + /// 顶部"数据更新中"指示器 Widget _buildRefreshingBanner() { - return Container( - width: double.infinity, - padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), - color: const Color(0xFFFFF8E1), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox( - width: 14.w, - height: 14.w, - child: const CircularProgressIndicator( - strokeWidth: 1.8, - valueColor: AlwaysStoppedAnimation(Color(0xFFFFA000)), - ), - ), - SizedBox(width: 8.w), - Text( - '数据更新中', - style: TextStyle( - color: const Color(0xFFFFA000), - fontSize: 12.sp, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ); + return XUi.refreshingBanner(); } /// 顶部 Banner:单位名称 + 切换按钮 + 统计行 + /// 透明背景,承载在 Scaffold 渐变之上(科技感深色渐变) Widget _buildBanner(IBelong? space) { + final topPad = MediaQuery.of(context).padding.top; return Container( width: double.infinity, - padding: EdgeInsets.fromLTRB(20.w, 24.h, 20.w, 20.h), - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [Color(0xFF228DEF), Color(0xFF4A90E2)], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ), - ), + padding: EdgeInsets.fromLTRB(20.w, topPad + 16.h, 20.w, 20.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -388,15 +441,36 @@ class _HomePageState extends State { Text( space?.name ?? '工作空间', style: XFonts.portalHeroTitle.copyWith( - color: Colors.white, + color: OipText.inverse, + letterSpacing: 0.3, ), ), SizedBox(height: 6.h), - Text( - '单位工作台', - style: XFonts.portalPageTitle.copyWith( - color: Colors.white.withValues(alpha: 0.85), - ), + Row( + children: [ + Container( + width: 8.w, + height: 8.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: OipState.success, + boxShadow: [ + BoxShadow( + color: OipState.success.withValues(alpha: 0.7), + blurRadius: 8, + spreadRadius: 2, + ), + ], + ), + ), + SizedBox(width: 7.w), + Text( + '单位工作台', + style: XFonts.portalPageTitle.copyWith( + color: OipText.inverse.withValues(alpha: 0.85), + ), + ), + ], ), ], ), @@ -409,19 +483,23 @@ class _HomePageState extends State { vertical: 6.h, ), decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(16.r), + color: OipText.inverse.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(OipRadius.xl.w), + border: Border.all( + color: OipText.inverse.withValues(alpha: 0.4), + width: 1.0, + ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.settings_outlined, - color: Colors.white, size: 16.sp), + color: OipText.inverse, size: 16.sp), SizedBox(width: 4.w), Text( '设置', style: XFonts.portalBadge.copyWith( - color: Colors.white, + color: OipText.inverse, ), ), ], @@ -451,17 +529,13 @@ class _HomePageState extends State { children: [ Text( count, - style: TextStyle( - color: Colors.white, - fontSize: 20.sp, - fontWeight: FontWeight.w700, - ), + style: XFonts.numberSmall.copyWith(color: OipText.inverse), ), SizedBox(height: 2.h), Text( label, style: XFonts.portalBadge.copyWith( - color: Colors.white.withValues(alpha: 0.85), + color: OipText.inverse.withValues(alpha: 0.85), ), ), ], @@ -474,16 +548,18 @@ class _HomePageState extends State { Widget _buildQuickActions() { return Container( margin: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h), - padding: EdgeInsets.symmetric(vertical: 16.h, horizontal: 8.w), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12.r), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: _isPersonalSpace() - ? _personalQuickActions() - : _companyQuickActions(), + child: XUi.frostedGlass( + sigma: 18.0, + alpha: 0.78, + tint: OipSurface.card, + radius: OipRadius.lg, + padding: EdgeInsets.symmetric(vertical: 16.h, horizontal: 8.w), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: _isPersonalSpace() + ? _personalQuickActions() + : _companyQuickActions(), + ), ), ); } @@ -509,31 +585,31 @@ class _HomePageState extends State { _buildQuickActionButton( icon: XImage.folderStore, label: '申请存储', - color: const Color(0xFF59D8A5), + color: _quickActionColors[0], onTap: () => relationCtrl.showAddFeatures(joinStorage), ), _buildQuickActionButton( icon: XImage.createGroup, label: '新建群组', - color: const Color(0xFF228DEF), + color: _quickActionColors[1], onTap: () => relationCtrl.showAddFeatures(addCohort), ), _buildQuickActionButton( icon: XImage.joinGroup, label: '加入群组', - color: const Color(0xFFFF9A5C), + color: _quickActionColors[2], onTap: () => relationCtrl.showAddFeatures(joinGroup), ), _buildQuickActionButton( icon: XImage.unitInstitution, label: '新建单位', - color: const Color(0xFFFFCE39), + color: _quickActionColors[3], onTap: () => relationCtrl.showAddFeatures(createCompany), ), _buildQuickActionButton( icon: XImage.cluster, label: '加入单位', - color: const Color(0xFF9B59B6), + color: _quickActionColors[4], onTap: () => relationCtrl.showAddFeatures(addCompany), ), ]; @@ -545,35 +621,35 @@ class _HomePageState extends State { _buildQuickActionButton( icon: XImage.folderStore, label: '申请存储', - color: const Color(0xFF59D8A5), + color: _quickActionColors[0], onTap: () => relationCtrl.showAddFeatures(relationCtrl.companyMenuItems[0]), ), _buildQuickActionButton( icon: XImage.createGroup, label: '新建群组', - color: const Color(0xFF228DEF), + color: _quickActionColors[1], onTap: () => relationCtrl.showAddFeatures(relationCtrl.companyMenuItems[1]), ), _buildQuickActionButton( icon: XImage.unitInstitution, label: '新建部门', - color: const Color(0xFFFFCE39), + color: _quickActionColors[3], onTap: () => relationCtrl.showAddFeatures(relationCtrl.companyMenuItems[2]), ), _buildQuickActionButton( icon: XImage.cluster, label: '新建集群', - color: const Color(0xFF9B59B6), + color: _quickActionColors[4], onTap: () => relationCtrl.showAddFeatures(relationCtrl.companyMenuItems[3]), ), _buildQuickActionButton( icon: XImage.joinGroup, label: '加入集群', - color: const Color(0xFFFF9A5C), + color: _quickActionColors[2], onTap: () => relationCtrl.showAddFeatures(relationCtrl.companyMenuItems[4]), ), @@ -592,13 +668,13 @@ class _HomePageState extends State { mainAxisSize: MainAxisSize.min, children: [ XImage.localImage(icon, - width: 30.w, color: Colors.white, bgColor: color, radius: 5), + width: 30.w, color: OipText.inverse, bgColor: color, radius: 5), SizedBox(height: 8.h), Text( label, textAlign: TextAlign.center, style: XFonts.portalListSubtitle.copyWith( - color: XColors.black3, + color: OipText.primary, ), ), ], @@ -619,53 +695,56 @@ class _HomePageState extends State { } Widget _buildChatItem(ISession session) { - return ListTile( - dense: true, - contentPadding: EdgeInsets.symmetric(horizontal: 12.w), - leading: CircleAvatar( - backgroundColor: OipBrand.primary.withValues(alpha: 0.1), - child: Text( - (session.name.isNotEmpty ? session.name[0] : '?'), - style: TextStyle(color: OipBrand.primary, fontSize: 16.sp), + final bool isGroup = session.isGroup; + final avatarColor = isGroup ? OipEntity.group : OipEntity.person; + return XUi.listItem( + leading: Container( + width: 48.w, + height: 48.w, + decoration: XUi.rounded( + color: isGroup ? OipEntity.groupSurface : OipEntity.personSurface, + radius: OipRadius.avatar, ), - ), - title: Text( - session.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: XFonts.portalListTitle, - ), - subtitle: Text( - session.chatdata.lastMessage?.content ?? '', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: XFonts.portalListSubtitle.copyWith( - color: XColors.doorDesGrey, + child: Center( + child: Text( + (session.name.isNotEmpty ? session.name[0] : '?'), + style: XFonts.titleSmall.copyWith(color: avatarColor), + ), ), ), + title: session.name, + subtitle: session.chatdata.lastMessage?.content ?? '', trailing: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( CustomDateUtil.getSessionTime(session.updateTime), - style: XFonts.chatSMTimeTip, + style: XFonts.captionSmall, ), - if (session.noReadCount > 0) + if (session.noReadCount > 0) ...[ + SizedBox(height: 4.h), Container( - margin: EdgeInsets.only(top: 2.h), - padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 1.h), + padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 2.h), decoration: BoxDecoration( - color: Colors.red, - borderRadius: BorderRadius.circular(10.r), + color: OipChat.unreadBadge, + borderRadius: BorderRadius.circular(OipRadius.avatar.w), ), - child: Text( - '${session.noReadCount}', - style: TextStyle(color: Colors.white, fontSize: 10.sp), + constraints: BoxConstraints(minWidth: 18.w, minHeight: 18.h), + child: Center( + child: Text( + session.noReadCount > 99 ? '99+' : '${session.noReadCount}', + style: XFonts.labelSmall.copyWith( + color: OipText.inverse, + height: 1.1, + ), + ), ), ), + ], ], ), + padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h), onTap: () { if (context.mounted) { RoutePages.jumpChatSession(context: context, data: session); @@ -687,23 +766,18 @@ class _HomePageState extends State { } Widget _buildTodoItem(IWorkTask task) { - return ListTile( - dense: true, - contentPadding: EdgeInsets.symmetric(horizontal: 12.w), - leading: - Icon(Icons.assignment, color: Colors.orange.shade600, size: 24.sp), - title: Text( - task.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: XFonts.portalListTitle, - ), - subtitle: Text( - task.taskdata.taskType ?? '', - style: XFonts.portalListSubtitle.copyWith( - color: XColors.doorDesGrey, - ), + return XUi.listItem( + leadingIcon: Icons.assignment_rounded, + leadingIconColor: OipState.warning, + leadingBgColor: OipState.warningBg, + title: task.name, + subtitle: task.taskdata.taskType ?? '待办事项', + trailing: Icon( + Icons.circle, + size: 10.w, + color: OipState.warning, ), + padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h), onTap: () { if (context.mounted) { RoutePages.jumpWorkInfo(context: context, work: task); @@ -724,7 +798,7 @@ class _HomePageState extends State { ListView.separated( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 4.h), + padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 4.h), itemCount: _recentApps.length, separatorBuilder: (_, __) => SizedBox(height: 8.h), itemBuilder: (context, index) { @@ -751,44 +825,77 @@ class _HomePageState extends State { ); } - /// 单行应用卡片:图标(左) + 名称(中) + 类别标签(右) + /// 单行应用卡片:图标(左) + 名称+备注(中) + 类型标签(右) Widget _buildAppRowCard(IApplication app) { - final categoryTag = app.groupTags.isNotEmpty ? app.groupTags.first : ''; + final typeLabel = app.typeName.isNotEmpty ? app.typeName : ''; + final remark = app.remark; + final showRemark = remark.isNotEmpty && remark != '暂无信息'; return GestureDetector( onTap: () => _openApplication(app), behavior: HitTestBehavior.opaque, child: Container( - padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 8.h), + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 12.h), decoration: BoxDecoration( - color: Colors.blue.shade50, - borderRadius: BorderRadius.circular(10.r), + gradient: LinearGradient( + colors: [ + OipBrand.primaryBg.withValues(alpha: 0.65), + OipSurface.muted.withValues(alpha: 0.45), + ], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ), + borderRadius: BorderRadius.circular(OipRadius.md.w), + border: Border.all( + color: OipBrand.primaryBorder.withValues(alpha: 0.35), + width: 0.6, + ), ), child: Row( children: [ - XImage.entityIcon(app, width: 48.w, circular: false), - SizedBox(width: 10.w), + Container( + width: 48.w, + height: 48.w, + decoration: XUi.rounded( + color: OipEntity.applicationSurface, + radius: OipRadius.md, + ), + child: Center( + child: XImage.entityIcon(app, width: 34.w, circular: false), + ), + ), + SizedBox(width: 12.w), Expanded( - child: Text( - app.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: XFonts.portalListTitle.copyWith(fontSize: 13.sp), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + app.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: XFonts.titleSmall.copyWith(height: 1.3), + ), + if (showRemark) ...[ + SizedBox(height: 3.h), + Text( + remark, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: XFonts.caption, + ), + ], + ], ), ), - if (categoryTag.isNotEmpty) ...[ - SizedBox(width: 6.w), + if (typeLabel.isNotEmpty && typeLabel != '模块') ...[ + SizedBox(width: 8.w), Container( - padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 2.h), - decoration: BoxDecoration( - color: OipBrand.primary.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(4.r), - ), + padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h), + decoration: XUi.filledBadge(OipEntity.application, radius: OipRadius.sm), child: Text( - categoryTag, - style: TextStyle( - fontSize: 10.sp, - color: OipBrand.primary, - fontWeight: FontWeight.w500, + typeLabel, + style: XFonts.labelSmall.copyWith( + color: OipText.inverse, + fontWeight: FontWeight.w600, ), ), ), @@ -809,62 +916,65 @@ class _HomePageState extends State { /// 数据概览:智能体 / 最近文件 / 应用 Widget _buildDataOverview() { return Container( - margin: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h), - padding: EdgeInsets.all(16.w), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12.r), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - '数据', - style: XFonts.portalSectionTitle, - ), - const Spacer(), - GestureDetector( - onTap: _jumpToUnitFiles, - child: Text( - '全部', - style: XFonts.portalActionLabel.copyWith( - color: OipBrand.primary, + margin: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), + child: XUi.frostedGlass( + sigma: 18.0, + alpha: 0.82, + tint: OipSurface.card, + radius: OipRadius.lg, + padding: EdgeInsets.fromLTRB(20.w, 16.h, 20.w, 20.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text('数据', style: XFonts.titleMedium), + const Spacer(), + GestureDetector( + onTap: _jumpToUnitFiles, + behavior: HitTestBehavior.opaque, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('全部', style: XFonts.brandMedium), + SizedBox(width: 2.w), + Icon(Icons.chevron_right_rounded, + color: OipBrand.primary, size: 20.w), + ], ), ), - ), - ], - ), - SizedBox(height: 12.h), - Row( - children: [ - Expanded( - child: GestureDetector( - onTap: _jumpToAgents, - child: _buildDataItem('$_agentCount', '智能体', - Colors.purple.shade600, Icons.smart_toy_outlined), + ], + ), + SizedBox(height: 16.h), + Row( + children: [ + Expanded( + child: GestureDetector( + onTap: _jumpToAgents, + child: _buildDataItem('$_agentCount', '智能体', + OipEntity.agent, Icons.smart_toy_outlined), + ), ), - ), - SizedBox(width: 12.w), - Expanded( - child: GestureDetector( - onTap: _jumpToUnitFiles, - child: _buildDataItem('${_recentFiles.length}', '最近文件', - Colors.orange.shade600, Icons.insert_drive_file_outlined), + SizedBox(width: 12.w), + Expanded( + child: GestureDetector( + onTap: _jumpToUnitFiles, + child: _buildDataItem('${_recentFiles.length}', '最近文件', + OipEntity.document, Icons.insert_drive_file_outlined), + ), ), - ), - SizedBox(width: 12.w), - Expanded( - child: GestureDetector( - onTap: _jumpToAllApps, - child: _buildDataItem('${_allApps.length}', '应用', - Colors.blue.shade600, Icons.apps_outlined), + SizedBox(width: 12.w), + Expanded( + child: GestureDetector( + onTap: _jumpToAllApps, + child: _buildDataItem('${_allApps.length}', '应用', + OipEntity.application, Icons.apps_rounded), + ), ), - ), - ], - ), - ], + ], + ), + ], + ), ), ); } @@ -872,29 +982,20 @@ class _HomePageState extends State { Widget _buildDataItem( String count, String label, Color color, IconData icon) { return Container( - padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 12.h), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(8.r), - ), + padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 14.h), + decoration: XUi.rounded(color: color.withValues(alpha: 0.1), radius: OipRadius.md), child: Column( children: [ - Icon(icon, color: color, size: 20.sp), - SizedBox(height: 6.h), + Icon(icon, color: color, size: 24.w), + SizedBox(height: 8.h), Text( count, - style: TextStyle( - color: color, - fontSize: 18.sp, - fontWeight: FontWeight.w700, - ), + style: XFonts.numberSmall.copyWith(color: color), ), - SizedBox(height: 2.h), + SizedBox(height: 3.h), Text( label, - style: XFonts.portalBadge.copyWith( - color: XColors.doorDesGrey, - ), + style: XFonts.labelSmall, textAlign: TextAlign.center, ), ], @@ -903,109 +1004,81 @@ class _HomePageState extends State { } /// 数据存储区域(从平台入口页迁移) - /// - /// 展示当前空间下的存储空间列表,点击跳转到发现页。 Widget _buildStoragesSection() { if (_storages.isEmpty) return const SizedBox.shrink(); return Container( - margin: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12.r), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: EdgeInsets.fromLTRB(16.w, 12.h, 12.w, 4.h), - child: Row( - children: [ - Text('数据存储', style: XFonts.portalSectionTitle), - SizedBox(width: 8.w), - Container( - padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 1.h), - decoration: BoxDecoration( - color: const Color(0xFF10B981).withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8.r), - ), - child: Text( - '${_storages.length} 个存储空间', - style: XFonts.portalBadge.copyWith( - color: const Color(0xFF10B981), + margin: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), + child: XUi.frostedGlass( + sigma: 18.0, + alpha: 0.82, + tint: OipSurface.card, + radius: OipRadius.lg, + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.fromLTRB(20.w, 16.h, 20.w, 8.h), + child: Row( + children: [ + Text('数据存储', style: XFonts.titleMedium), + SizedBox(width: 8.w), + Container( + padding: EdgeInsets.symmetric(horizontal: 7.w, vertical: 2.h), + decoration: XUi.softBadge(OipState.success, radius: OipRadius.xs), + child: Text( + '${_storages.length} 个', + style: XFonts.labelSmall.copyWith( + color: OipState.success, + fontWeight: FontWeight.w600, + ), ), ), - ), - const Spacer(), - ], + ], + ), ), - ), - ..._storages.asMap().entries.map((entry) { - final index = entry.key; - final storage = entry.value; - return Column( - children: [ - if (index > 0) - Divider( - height: 1, - indent: 56.w, - endIndent: 16.w, - color: Colors.grey.shade100), - _buildStorageItem(storage), - ], - ); - }), - SizedBox(height: 8.h), - ], + ..._storages.asMap().entries.map((entry) { + final index = entry.key; + final storage = entry.value; + return Column( + children: [ + if (index > 0) + Padding( + padding: EdgeInsets.only(left: 76.w, right: 20.w), + child: const Divider(height: 0.5, color: OipBorder.divider), + ), + _buildStorageItem(storage), + ], + ); + }), + SizedBox(height: 12.h), + ], + ), ), ); } Widget _buildStorageItem(IStorage storage) { final isActivated = storage.isActivate; - return InkWell( - onTap: () => command.emitterFlag('switchHomeTab', [3]), - borderRadius: BorderRadius.circular(8.r), - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h), - child: Row( - children: [ - Container( - width: 40.w, - height: 40.w, - decoration: BoxDecoration( - color: (isActivated - ? const Color(0xFFD1FAE5) - : const Color(0xFFDBEAFE)) - .withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(12.r), - ), - child: Icon( - isActivated ? Icons.public : Icons.storage, - size: 22.w, - color: isActivated - ? const Color(0xFF10B981) - : const Color(0xFF366EF4), - ), - ), - SizedBox(width: 12.w), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(storage.name, style: XFonts.portalListTitle), - Text( - isActivated ? '门户网站 · 已激活' : '存储空间', - style: XFonts.portalListSubtitle - .copyWith(color: XColors.doorDesGrey), - ), - ], - ), - ), - Icon(Icons.chevron_right_rounded, - size: 20.w, color: Colors.grey.shade400), - ], + final itemColor = isActivated ? OipState.success : OipEntity.storage; + final itemSurface = + isActivated ? OipState.successBg : OipEntity.storageSurface; + return XUi.listItem( + leading: Container( + width: 44.w, + height: 44.w, + decoration: XUi.rounded(color: itemSurface, radius: OipRadius.md), + child: Icon( + isActivated ? Icons.public_rounded : Icons.storage_rounded, + size: 22.w, + color: itemColor, ), ), + title: storage.name, + subtitle: isActivated ? '门户网站 · 已激活' : '存储空间', + showArrow: true, + padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h), + onTap: () => command.emitterFlag('switchHomeTab', [3]), ); } @@ -1016,30 +1089,25 @@ class _HomePageState extends State { title: '视图', count: _forms.length, onMore: null, - children: _forms - .take(3) - .map((f) => ListTile( - dense: true, - contentPadding: EdgeInsets.symmetric(horizontal: 12.w), - leading: Icon(Icons.table_chart, - color: Colors.green.shade600, size: 24.sp), - title: Text( - f.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: XFonts.portalListTitle, - ), - onTap: () { - if (context.mounted) { - RoutePages.jumpViewPreviewPage(context, f); - } - }, - )) - .toList(), + children: _forms.take(3).map((f) { + return XUi.listItem( + leadingIcon: Icons.table_chart_rounded, + leadingIconColor: OipState.success, + leadingBgColor: OipState.successBg, + title: f.name, + showArrow: true, + padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h), + onTap: () { + if (context.mounted) { + RoutePages.jumpViewPreviewPage(context, f); + } + }, + ); + }).toList(), ); } - /// 通用分区卡片 + /// 通用分区卡片(毛玻璃效果) Widget _buildSectionCard({ required String title, required int count, @@ -1047,62 +1115,64 @@ class _HomePageState extends State { required List children, }) { return Container( - margin: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12.r), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: EdgeInsets.fromLTRB(16.w, 12.h, 12.w, 4.h), - child: Row( - children: [ - Text( - title, - style: XFonts.portalSectionTitle, - ), - SizedBox(width: 8.w), - if (count > 0) - Container( - padding: - EdgeInsets.symmetric(horizontal: 6.w, vertical: 1.h), - decoration: BoxDecoration( - color: OipBrand.primary.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8.r), - ), - child: Text( - '$count', - style: XFonts.portalBadge.copyWith( - color: OipBrand.primary, + margin: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), + child: XUi.frostedGlass( + sigma: 18.0, + alpha: 0.82, + tint: OipSurface.card, + radius: OipRadius.lg, + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.fromLTRB(20.w, 16.h, 20.w, 8.h), + child: Row( + children: [ + Text( + title, + style: XFonts.titleMedium, + ), + if (count > 0) ...[ + SizedBox(width: 8.w), + Container( + padding: + EdgeInsets.symmetric(horizontal: 7.w, vertical: 2.h), + decoration: XUi.softBadge(OipBrand.primary, radius: OipRadius.xs), + child: Text( + '$count', + style: XFonts.labelSmall.copyWith( + color: OipBrand.primary, + fontWeight: FontWeight.w600, + ), ), ), - ), - const Spacer(), - if (onMore != null) - GestureDetector( - onTap: onMore, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '更多', - style: XFonts.portalActionLabel.copyWith( - color: OipBrand.primary, + ], + const Spacer(), + if (onMore != null) + GestureDetector( + onTap: onMore, + behavior: HitTestBehavior.opaque, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '更多', + style: XFonts.brandMedium, ), - ), - Icon(Icons.chevron_right, - color: OipBrand.primary, size: 16.sp), - ], + SizedBox(width: 2.w), + Icon(Icons.chevron_right_rounded, + color: OipBrand.primary, size: 20.w), + ], + ), ), - ), - ], + ], + ), ), - ), - ...children, - SizedBox(height: 8.h), - ], + ...children, + SizedBox(height: 12.h), + ], + ), ), ); } @@ -1114,7 +1184,7 @@ class _HomePageState extends State { child: Text( text, style: XFonts.portalListMeta.copyWith( - color: XColors.doorDesGrey, + color: OipText.tertiary, ), ), ), diff --git a/lib/pages/portal/logic/platform_entry_logic.dart b/lib/pages/portal/logic/platform_entry_logic.dart index 797de54f17a79a6f64e19f596f8c02cac6d97963..a8845a4fde06da526019b62cc28d901dfa710665 100644 --- a/lib/pages/portal/logic/platform_entry_logic.dart +++ b/lib/pages/portal/logic/platform_entry_logic.dart @@ -1,3 +1,4 @@ +import 'package:flutter/scheduler.dart'; import 'package:get/get.dart'; import 'package:orginone/dart/base/common/commands.dart'; import 'package:orginone/pages/discover/discover_config.dart'; @@ -67,8 +68,7 @@ class PlatformEntryLogic extends PortalLogic { const groupId = _defaultClusterId; XLogUtil.d('平台入口-加载集群 [$groupId] 的 Plaza 数据'); await DiscoverService.loadPlaza(groupId: groupId); - final resources = - await DiscoverService.loadPlazaResources(groupId); + final resources = await DiscoverService.loadPlazaResources(groupId); plazaResources.value = resources; _loadBadgeCounts(resources); } catch (e) { @@ -78,7 +78,8 @@ class PlatformEntryLogic extends PortalLogic { Future _loadBadgeCounts(List resources) async { try { - final badgeMap = await DiscoverService.loadBadgeCounts(resources: resources); + final badgeMap = + await DiscoverService.loadBadgeCounts(resources: resources); final result = {}; for (final entry in badgeMap.entries) { result[entry.key.resourceTypeName] = entry.value; @@ -98,10 +99,23 @@ class PlatformEntryLogic extends PortalLogic { } void _subscribeEntryEvents() { - // 订阅 session flag:后台恢复/网络恢复/数据刷新完成时触发 - final sessionId = command.subscribeByFlag('session', ([args]) { - refreshAllData(); + // 平台入口数据范围固定为 _defaultClusterId(对齐 oiocns-react), + // 与当前单位无关,因此不订阅 switchSpace(避免不必要的刷新)。 + // 数据刷新依赖: + // 1. 页面进入时 _loadAllData() 主动加载 + // 2. 用户下拉刷新 _onRefresh() + // 3. 后台 deepLoad 完成后通过 'backgroundLoaded' 事件触发刷新 + // + // 事件回调可能在 build 阶段被同步派发,dataVersion.value++ 会触发 Obx 重建, + // 因此使用 SchedulerBinding.addPostFrameCallback 延迟到帧外执行。 + final bgLoadedId = command.subscribeByFlag('backgroundLoaded', ([args]) { + if (isLoading.value) return; + _loadPlazaData().then((_) { + SchedulerBinding.instance.addPostFrameCallback((_) { + dataVersion.value++; + }); + }); }, false); - _entrySubscribers.add(sessionId); + _entrySubscribers.add(bgLoadedId); } } diff --git a/lib/pages/portal/morepage/share_page.dart b/lib/pages/portal/morepage/share_page.dart index a473642d93341e45321adead2e4820f94af481da..fd4d5d7807850d4437664a0649eb875b389a5e0c 100644 --- a/lib/pages/portal/morepage/share_page.dart +++ b/lib/pages/portal/morepage/share_page.dart @@ -198,7 +198,7 @@ class _SharePageState extends State { // overflow: TextOverflow.ellipsis, // maxLines: 1, // style: TextStyle( - // fontSize: 14.sp, color: OipBrand.primary))) + // fontSize: 16.sp, color: OipBrand.primary))) // ], // ), // ), diff --git a/lib/pages/portal/my_page.dart b/lib/pages/portal/my_page.dart index cfaaae14ab3864082ec345981bf25610ae6a6e8d..4af6b95ef33d68dd8d7fd24536b448a08652db1c 100644 --- a/lib/pages/portal/my_page.dart +++ b/lib/pages/portal/my_page.dart @@ -66,7 +66,7 @@ class _MyPageState extends State { titleName: '我的', centerTitle: true, titleSpacing: 0, - backgroundColor: const Color(0xFFF2F2F7), + backgroundColor: OipSurface.background, body: ListView( padding: EdgeInsets.only(top: 12.h), children: [ @@ -97,99 +97,83 @@ class _MyPageState extends State { final cohortCount = user.cohorts.length + user.companys.fold(0, (sum, c) => sum + c.cohorts.length); final friendCount = user.companys.fold( - 0, (sum, c) => sum + c.members.whereType().length); + 0, (sum, c) => sum + c.members.length); final storageCount = user.storages.length; final agentCount = user.agents.length; final activityCount = user.activitys.length; return Container( - margin: EdgeInsets.symmetric(horizontal: 12.w).copyWith(bottom: 12.h), - padding: EdgeInsets.symmetric(vertical: 14.h, horizontal: 8.w), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(14.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.03), - blurRadius: 6, - offset: const Offset(0, 2), - ), - ], - ), + margin: EdgeInsets.symmetric(horizontal: 16.w).copyWith(bottom: 12.h), + padding: EdgeInsets.fromLTRB(16.w, 16.h, 16.w, 16.h), + decoration: XUi.cardDecoration(), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h), - child: Text('我的数据', - style: XFonts.portalSectionTitle.copyWith( - color: Colors.grey.shade500, - )), - ), - SizedBox(height: 6.h), + Text('我的数据', style: XFonts.titleMedium), + SizedBox(height: 16.h), GridView.count( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), crossAxisCount: 4, - mainAxisSpacing: 8.h, + mainAxisSpacing: 12.h, crossAxisSpacing: 4.w, - childAspectRatio: 0.95, + childAspectRatio: 0.92, children: [ _buildDataCell( value: '$chatCount', label: '会话', subLabel: unreadCount > 0 ? '$unreadCount 未读' : null, - icon: Icons.chat_bubble_outline, - color: const Color(0xFF42A5F5), + icon: Icons.chat_bubble_rounded, + color: OipBrand.primary, onTap: () => _jumpHome(HomeEnum.chat), ), _buildDataCell( value: '$todoCount', label: '待办', - icon: Icons.assignment_outlined, - color: const Color(0xFFFFA726), + icon: Icons.assignment_rounded, + color: OipState.warning, onTap: () => _jumpHome(HomeEnum.work), ), _buildDataCell( value: '$companyCount', label: '加入单位', - icon: Icons.business_outlined, - color: const Color(0xFF66BB6A), + icon: Icons.business_rounded, + color: OipEntity.organization, onTap: () => _jumpHome(HomeEnum.relation), ), _buildDataCell( value: '$cohortCount', label: '群组', - icon: Icons.group_work_outlined, - color: const Color(0xFFAB47BC), + icon: Icons.group_work_rounded, + color: OipEntity.group, onTap: () => _jumpHome(HomeEnum.chat), ), _buildDataCell( value: '$friendCount', label: '好友', - icon: Icons.people_outline, - color: const Color(0xFFEF5350), + icon: Icons.people_alt_rounded, + color: OipEntity.person, onTap: () => _jumpHome(HomeEnum.relation), ), _buildDataCell( value: '$storageCount', label: '存储', - icon: Icons.storage_outlined, - color: const Color(0xFF26A69A), + icon: Icons.storage_rounded, + color: OipEntity.storage, onTap: () => _jumpHome(HomeEnum.relation), ), _buildDataCell( value: '$agentCount', label: '智能体', - icon: Icons.smart_toy_outlined, - color: const Color(0xFF7E57C2), + icon: Icons.smart_toy_rounded, + color: OipEntity.agent, onTap: () => _jumpHome(HomeEnum.relation), ), _buildDataCell( value: '$activityCount', label: '动态', - icon: Icons.dynamic_feed_outlined, - color: const Color(0xFFFF7043), + icon: Icons.dynamic_feed_rounded, + color: OipEntity.station, onTap: () => _jumpHome(HomeEnum.store), ), ], @@ -214,27 +198,19 @@ class _MyPageState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ Container( - width: 30.w, - height: 30.w, - decoration: BoxDecoration( - color: color.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(8.r), - ), - child: Icon(icon, size: 16.w, color: color), + width: 44.w, + height: 44.w, + decoration: XUi.rounded(color: color.withValues(alpha: 0.12), radius: OipRadius.md), + child: Icon(icon, size: 22.w, color: color), ), - SizedBox(height: 4.h), - Text(value, - style: TextStyle( - fontSize: 14.sp, - fontWeight: FontWeight.bold, - color: Colors.black87)), - SizedBox(height: 1.h), - Text(label, - style: TextStyle(fontSize: 10.sp, color: Colors.grey.shade600)), + SizedBox(height: 6.h), + Text(value, style: XFonts.numberSmall), + SizedBox(height: 2.h), + Text(label, style: XFonts.labelSmall), if (subLabel != null) ...[ - SizedBox(height: 1.h), + SizedBox(height: 2.h), Text(subLabel, - style: TextStyle(fontSize: 9.sp, color: Colors.red.shade400)), + style: XFonts.captionSmall.copyWith(color: OipState.error)), ], ], ), @@ -260,7 +236,7 @@ class _MyPageState extends State { const _OipMenuItem( label: '审批中心', subtitle: '处理 VET 委托与工作流', - icon: Icons.task_outlined, + icon: Icons.task_alt_rounded, color: OipAi.accent, route: Routers.approvalCenter, ), @@ -275,18 +251,13 @@ class _MyPageState extends State { return Container( margin: EdgeInsets.only(bottom: 12.h), - color: Colors.white, + decoration: XUi.cardDecoration(shadow: false), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h), - child: Text( - 'OIP 权限', - style: XFonts.portalSectionTitle.copyWith( - color: Colors.grey.shade500, - ), - ), + padding: EdgeInsets.fromLTRB(20.w, 16.h, 20.w, 4.h), + child: Text('OIP 权限', style: XFonts.titleMedium), ), ...oipItems.asMap().entries.map((entry) { final index = entry.key; @@ -299,67 +270,24 @@ class _MyPageState extends State { } Widget _buildOipMenuItem(_OipMenuItem item, {required bool showDivider}) { - return Column( - children: [ - InkWell( - onTap: () { - RoutePages.to( - context: navigatorKey.currentState!.context, - path: item.route, - ); - }, - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 14.h), - child: Row( - children: [ - Container( - width: 38.w, - height: 38.w, - decoration: BoxDecoration( - color: item.color.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(6), - ), - child: Icon(item.icon, size: 20.w, color: item.color), - ), - SizedBox(width: 12.w), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.label, - style: XFonts.portalListTitle.copyWith( - color: Colors.black87, - ), - ), - SizedBox(height: 2.h), - Text( - item.subtitle, - style: XFonts.portalListMeta.copyWith( - color: Colors.grey.shade500, - fontSize: 11, - ), - ), - ], - ), - ), - Icon( - Icons.chevron_right, - color: Colors.grey.shade400, - size: 20.w, - ), - ], - ), - ), - ), - if (showDivider) - Divider( - height: 1, - thickness: 0.5, - color: Colors.grey.shade200, - indent: 56.w, - ), - ], + return XUi.listItem( + leading: Container( + width: 44.w, + height: 44.w, + decoration: XUi.rounded(color: item.color.withValues(alpha: 0.12), radius: OipRadius.md), + child: Icon(item.icon, size: 22.w, color: item.color), + ), + title: item.label, + subtitle: item.subtitle, + showArrow: true, + border: showDivider, + padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 14.h), + onTap: () { + RoutePages.to( + context: navigatorKey.currentState!.context, + path: item.route, + ); + }, ); } @@ -398,62 +326,63 @@ class _MyPageState extends State { } Widget _buildUserHeader() { - return InkWell( - onTap: () { - RoutePages.jumpSetting( - navigatorKey.currentState!.context, - SettingEnum.edit, - ); - }, - child: Container( - color: Colors.white, - margin: EdgeInsets.only(bottom: 12.h), - padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 20.h), - child: Row( - children: [ - Container( - width: 64.w, - height: 64.w, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: Colors.grey.shade100, - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: XImage.entityIcon( - relationCtrl.user, - width: 64.w, - circular: false, + return Container( + margin: EdgeInsets.symmetric(horizontal: 16.w).copyWith(bottom: 12.h), + decoration: XUi.cardDecoration(), + child: GestureDetector( + onTap: () { + RoutePages.jumpSetting( + navigatorKey.currentState!.context, + SettingEnum.edit, + ); + }, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 18.h), + child: Row( + children: [ + Container( + width: 64.w, + height: 64.w, + decoration: XUi.rounded(color: OipSurface.muted, radius: OipRadius.lg), + child: ClipRRect( + borderRadius: BorderRadius.circular(OipRadius.lg.w), + child: XImage.entityIcon( + relationCtrl.user, + width: 64.w, + circular: false, + ), ), ), - ), - SizedBox(width: 16.w), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - relationCtrl.user?.name ?? '', - style: XFonts.portalHeroTitle.copyWith( - color: Colors.black87, + SizedBox(width: 14.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + relationCtrl.user?.name ?? '', + style: XFonts.headlineSmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - ), - SizedBox(height: 6.h), - Text( - relationCtrl.user?.code ?? '', - style: XFonts.portalListMeta.copyWith( - color: Colors.grey.shade500, + SizedBox(height: 4.h), + Text( + relationCtrl.user?.code ?? '', + style: XFonts.bodyMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - ), - ], + ], + ), ), - ), - Icon( - Icons.chevron_right, - color: Colors.grey.shade400, - size: 24.w, - ), - ], + SizedBox(width: 8.w), + Icon( + Icons.chevron_right_rounded, + size: 22.w, + color: OipText.disabled, + ), + ], + ), ), ), ); @@ -461,20 +390,15 @@ class _MyPageState extends State { Widget _buildMenuGroup(String title, List items) { return Container( - margin: EdgeInsets.only(bottom: 12.h), - color: Colors.white, + margin: EdgeInsets.symmetric(horizontal: 16.w).copyWith(bottom: 12.h), + decoration: XUi.cardDecoration(shadow: false), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (items.isNotEmpty) Padding( - padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h), - child: Text( - title, - style: XFonts.portalSectionTitle.copyWith( - color: Colors.grey.shade500, - ), - ), + padding: EdgeInsets.fromLTRB(20.w, 16.h, 20.w, 4.h), + child: Text(title, style: XFonts.titleMedium), ), ...items.asMap().entries.map((entry) { final index = entry.key; @@ -490,83 +414,53 @@ class _MyPageState extends State { } Widget _buildMenuItem(SettingEnum item, {required bool showDivider}) { - return Column( - children: [ - InkWell( - onTap: () { - RoutePages.jumpSetting( - navigatorKey.currentState!.context, - item, - ); - }, - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 14.h), - child: Row( - children: [ - Container( - width: 38.w, - height: 38.w, - decoration: BoxDecoration( - color: _getIconBackgroundColor(item), - borderRadius: BorderRadius.circular(6), - ), - child: Center( - child: XImage.localImage(item.icon, width: 20.w), - ), - ), - SizedBox(width: 12.w), - Expanded( - child: Text( - item.label, - style: XFonts.portalListTitle.copyWith( - color: Colors.black87, - ), - ), - ), - Icon( - Icons.chevron_right, - color: Colors.grey.shade400, - size: 20.w, - ), - ], - ), - ), + final iconColor = _getIconColor(item); + return XUi.listItem( + leading: Container( + width: 44.w, + height: 44.w, + decoration: XUi.rounded(color: iconColor.withValues(alpha: 0.12), radius: OipRadius.md), + child: Center( + child: XImage.localImage(item.icon, width: 22.w, color: iconColor), ), - if (showDivider) - Divider( - height: 1, - thickness: 0.5, - color: Colors.grey.shade200, - indent: 56.w, - ), - ], + ), + title: item.label, + showArrow: true, + border: showDivider, + padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 14.h), + onTap: () { + RoutePages.jumpSetting( + navigatorKey.currentState!.context, + item, + ); + }, ); } - Color _getIconBackgroundColor(SettingEnum item) { + Color _getIconColor(SettingEnum item) { switch (item) { case SettingEnum.scan: - return const Color(0xFF5856D6).withValues(alpha: 0.15); + return OipAi.accent; case SettingEnum.collection: - return const Color(0xFFFF3B30).withValues(alpha: 0.15); + return OipState.error; case SettingEnum.security: - return const Color(0xFF007AFF).withValues(alpha: 0.15); + return OipBrand.primary; case SettingEnum.totp: - return const Color(0xFF34C759).withValues(alpha: 0.15); + return OipState.success; case SettingEnum.errorLog: - return const Color(0xFF8E8E93).withValues(alpha: 0.15); + return OipEntity.storage; case SettingEnum.edit: - return const Color(0xFF5856D6).withValues(alpha: 0.15); + return OipAi.accent; case SettingEnum.passwordChange: - return const Color(0xFFFF3B30).withValues(alpha: 0.15); + return OipState.error; case SettingEnum.accountBinding: - return const Color(0xFF34C759).withValues(alpha: 0.15); + return OipState.success; case SettingEnum.thirdPartyApi: - return const Color(0xFFFF9500).withValues(alpha: 0.15); + return OipState.warning; case SettingEnum.about: - return const Color(0xFF8E8E93).withValues(alpha: 0.15); + return OipEntity.storage; default: - return Colors.grey.shade100; + return OipEntity.storage; } } @@ -574,23 +468,26 @@ class _MyPageState extends State { return Container( margin: EdgeInsets.symmetric(horizontal: 16.w), width: double.infinity, - child: ElevatedButton( - onPressed: () { + height: 48.h, + child: GestureDetector( + onTap: () { RoutePages.jumpSetting(context, SettingEnum.exitLogin); }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.red, - padding: EdgeInsets.symmetric(vertical: 16.h), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + behavior: HitTestBehavior.opaque, + child: Container( + decoration: BoxDecoration( + color: OipSurface.card, + borderRadius: BorderRadius.circular(OipRadius.lg.w), + border: Border.all(color: OipState.error.withValues(alpha: 0.3), width: 1.2), ), - elevation: 0, - ), - child: Text( - '退出登录', - style: XFonts.portalActionLabel.copyWith( - color: Colors.red, + child: Center( + child: Text( + '退出登录', + style: XFonts.titleSmall.copyWith( + color: OipState.error, + fontWeight: FontWeight.w600, + ), + ), ), ), ), diff --git a/lib/pages/portal/originone_menu_page.dart b/lib/pages/portal/originone_menu_page.dart index f6d76eb61d1788aacba3e4f950b4822d397fe7f2..f9ad88e53ca89915ec583156e447a2801ae6b1d8 100644 --- a/lib/pages/portal/originone_menu_page.dart +++ b/lib/pages/portal/originone_menu_page.dart @@ -585,7 +585,7 @@ class _OrginOneMenuPageState extends State { child: Text( '${toolBar.count}', style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, color: Colors.white, fontWeight: FontWeight.w600, ), diff --git a/lib/pages/portal/password_change_page.dart b/lib/pages/portal/password_change_page.dart index 4c28aba4222ba577f4fd9cea96266ed9b976221a..2721b98bd98125638527013dd8c3d7f55d72ff2b 100644 --- a/lib/pages/portal/password_change_page.dart +++ b/lib/pages/portal/password_change_page.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:orginone/components/XScaffold/XScaffold.dart'; +import 'package:orginone/config/oip_tokens.dart'; +import 'package:orginone/config/theme/unified_style.dart'; import 'package:orginone/dart/core/public/re_auth_service.dart'; import 'package:orginone/main.dart'; @@ -53,7 +55,7 @@ class _PasswordChangePageState extends State { Expanded( child: Text( '修改密码后需要重新登录,请确保记住新密码。', - style: TextStyle(fontSize: 13.sp, color: Colors.blue.shade700), + style: TextStyle(fontSize: 16.sp, color: Colors.blue.shade700), ), ), ], @@ -103,13 +105,14 @@ class _PasswordChangePageState extends State { child: ElevatedButton( onPressed: _isSaving ? null : _changePassword, style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).primaryColor, + backgroundColor: OipBrand.primary, foregroundColor: Colors.white, + disabledBackgroundColor: OipBrand.primaryBorder, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), child: _isSaving ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Colors.white))) - : const Text('确认修改', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + : Text('确认修改', style: XFonts.labelLarge.copyWith(color: Colors.white)), ), ), ], @@ -143,19 +146,19 @@ class _PasswordChangePageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: TextStyle(fontSize: 14.sp, color: Colors.grey.shade700, fontWeight: FontWeight.w500)), + Text(label, style: TextStyle(fontSize: 16.sp, color: Colors.grey.shade700, fontWeight: FontWeight.w500)), SizedBox(height: 8.h), TextFormField( controller: controller, obscureText: obscure, decoration: InputDecoration( hintText: hint, - hintStyle: TextStyle(color: Colors.grey.shade400, fontSize: 15.sp), + hintStyle: TextStyle(color: Colors.grey.shade400, fontSize: 16.sp), border: InputBorder.none, contentPadding: EdgeInsets.zero, suffixIcon: GestureDetector(onTap: onToggleObscure, child: Icon(obscure ? Icons.visibility_off : Icons.visibility, color: Colors.grey.shade400, size: 20.w)), ), - style: TextStyle(fontSize: 15.sp, color: Colors.black87), + style: TextStyle(fontSize: 16.sp, color: Colors.black87), validator: validator, ), ], diff --git a/lib/pages/portal/platform_entry_page.dart b/lib/pages/portal/platform_entry_page.dart index b6f2d675ea28a40acd37bb2e7d8d69d2cd4d2922..cde7d3185fe9a7de82081c47cadcc9f3afc6932d 100644 --- a/lib/pages/portal/platform_entry_page.dart +++ b/lib/pages/portal/platform_entry_page.dart @@ -1,12 +1,15 @@ -import 'package:cached_network_image/cached_network_image.dart'; +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import 'package:orginone/components/ChatSessionWidget/ChatSessionWidget.dart'; import 'package:orginone/components/GlobalAiInputBar/global_ai_input_bar.dart'; +import 'package:orginone/components/LoadingWidget/LoadingWidget.dart'; import 'package:orginone/components/Tip/ToastUtils.dart'; import 'package:orginone/components/XImage/XImage.dart'; +import 'package:orginone/dart/base/api/storehub.dart'; import 'package:orginone/config/oip_feature_flags.dart'; import 'package:orginone/config/oip_tokens.dart'; import 'package:orginone/config/theme/unified_style.dart'; @@ -15,7 +18,7 @@ import 'package:orginone/main.dart'; import 'package:orginone/pages/discover/discover_config.dart'; import 'package:orginone/pages/discover/discover_service.dart'; import 'package:orginone/pages/discover/pages/all_activities_page.dart'; -import 'package:orginone/pages/discover/widgets/plaza_item_widgets.dart'; +import 'package:orginone/pages/discover/widgets/plaza_style_cards.dart'; import 'package:orginone/pages/discover/pages/data_share_list_page.dart'; import 'package:orginone/pages/discover/pages/group_share_list_page.dart'; import 'package:orginone/pages/discover/pages/market_list_page.dart'; @@ -41,16 +44,22 @@ class _PlatformEntryPageState extends State // 顶部栏目切换页签:按栏目名称(resource.name)分组 // 每个 Tab 对应一个 PlazaResource,直接展示该栏目的内容列表 final List _tabResources = []; + Timer? _connectionStatusTimer; @override void initState() { super.initState(); _logic = PlatformEntryLogic(); _logic.initState(); + // 每 3 秒刷新连接状态显示 + _connectionStatusTimer = Timer.periodic(const Duration(seconds: 3), (_) { + if (mounted) setState(() {}); + }); } @override void dispose() { + _connectionStatusTimer?.cancel(); _tabController?.dispose(); _logic.dispose(); super.dispose(); @@ -112,81 +121,96 @@ class _PlatformEntryPageState extends State Widget build(BuildContext context) { final useV5 = OipFeatureFlags.oipFlutterV5Enabled; return Scaffold( - backgroundColor: const Color(0xFFF5F7FA), body: SafeArea( top: false, bottom: false, - child: Column( - children: [ - Expanded( - child: Obx(() { - if (_logic.isLoading.value) { - return Column( - children: [ - _buildPlatformHeader(_logic.platformSetting.value), - const Expanded( - child: Center( - child: CircularProgressIndicator( - color: OipBrand.primary, - strokeWidth: 2.5, + child: Container( + // 与门户页保持一致的深色渐变背景 + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFF1A1F36), + Color(0xFF2D2B5F), + Color(0xFF3F2B7E), + OipSurface.background, + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: [0.0, 0.18, 0.32, 0.55], + ), + ), + child: Column( + children: [ + Expanded( + child: Obx(() { + if (_logic.isLoading.value) { + return Column( + children: [ + _buildPlatformHeader(_logic.platformSetting.value), + const Expanded( + child: Center( + child: CircularProgressIndicator( + color: OipBrand.primary, + strokeWidth: 2.5, + ), ), ), - ), - ], - ); - } - _logic.dataVersion.value; - final setting = _logic.platformSetting.value; - final resources = _logic.plazaResources.toList(); - // 按栏目名称构建 Tab - _rebuildTabs(resources); - - if (_tabResources.isEmpty) { + ], + ); + } + _logic.dataVersion.value; + final setting = _logic.platformSetting.value; + final resources = _logic.plazaResources.toList(); + // 按栏目名称构建 Tab + _rebuildTabs(resources); + + if (_tabResources.isEmpty) { + return Column( + children: [ + _buildPlatformHeader(setting), + Expanded(child: _buildEmptyState()), + ], + ); + } + return Column( children: [ _buildPlatformHeader(setting), - Expanded(child: _buildEmptyState()), + if (_tabController != null && _tabResources.length > 1) + _buildColumnTabs(), + Expanded( + child: _tabController == null + ? RefreshIndicator( + onRefresh: _onRefresh, + child: _buildPlazaContentList( + _tabResources.first, + footer: _buildFooter(setting), + ), + ) + : TabBarView( + controller: _tabController, + children: _tabResources + .map((r) => RefreshIndicator( + onRefresh: _onRefresh, + child: _buildPlazaContentList( + r, + footer: _buildFooter(setting), + ), + )) + .toList(), + ), + ), ], ); - } - - return Column( - children: [ - _buildPlatformHeader(setting), - if (_tabController != null && _tabResources.length > 1) - _buildColumnTabs(), - Expanded( - child: _tabController == null - ? RefreshIndicator( - onRefresh: _onRefresh, - child: _buildPlazaContentList( - _tabResources.first, - footer: _buildFooter(setting), - ), - ) - : TabBarView( - controller: _tabController, - children: _tabResources - .map((r) => RefreshIndicator( - onRefresh: _onRefresh, - child: _buildPlazaContentList( - r, - footer: _buildFooter(setting), - ), - )) - .toList(), - ), - ), - ], - ); - }), - ), - // 底部输入栏(Feature Flag 控制) - if (useV5) - const GlobalAiInputBar(contextLabel: '平台') - else - const PortalInputBar(), - ], + }), + ), + // 底部输入栏(Feature Flag 控制) + if (useV5) + const GlobalAiInputBar(contextLabel: '平台') + else + const PortalInputBar(), + ], + ), ), ), ); @@ -196,7 +220,7 @@ class _PlatformEntryPageState extends State Widget _buildColumnTabs() { return Container( decoration: BoxDecoration( - color: Colors.white, + color: OipSurface.card, boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.06), @@ -213,14 +237,14 @@ class _PlatformEntryPageState extends State labelPadding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 3.h), indicator: BoxDecoration( gradient: const LinearGradient( - colors: [Color(0xFF366EF4), Color(0xFF7C5CFF)], + colors: [OipBrand.primary, OipAi.accent], begin: Alignment.centerLeft, end: Alignment.centerRight, ), borderRadius: BorderRadius.circular(18.r), boxShadow: [ BoxShadow( - color: const Color(0xFF366EF4).withValues(alpha: 0.4), + color: OipBrand.primary.withValues(alpha: 0.4), blurRadius: 10, offset: const Offset(0, 4), ), @@ -228,12 +252,10 @@ class _PlatformEntryPageState extends State ), indicatorSize: TabBarIndicatorSize.tab, dividerColor: Colors.transparent, - labelColor: Colors.white, - unselectedLabelColor: XColors.black6, - labelStyle: TextStyle( - fontSize: 13.sp, fontWeight: FontWeight.w600, height: 1.2), - unselectedLabelStyle: TextStyle( - fontSize: 13.sp, fontWeight: FontWeight.w500, height: 1.2), + labelColor: OipText.inverse, + unselectedLabelColor: OipText.secondary, + labelStyle: XFonts.tabLabel.copyWith(height: 1.2), + unselectedLabelStyle: XFonts.tabUnselected.copyWith(height: 1.2), tabs: _tabResources.map((r) { final icon = _getTabIcon(r.typeName); final badge = _logic.plazaBadges[r.typeName] ?? 0; @@ -241,7 +263,7 @@ class _PlatformEntryPageState extends State child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: 14.w), + Icon(icon, size: 16.w), SizedBox(width: 5.w), Text(r.name), if (badge > 0) ...[ @@ -250,17 +272,17 @@ class _PlatformEntryPageState extends State padding: EdgeInsets.symmetric(horizontal: 5.w, vertical: 1.5.h), decoration: BoxDecoration( - color: const Color(0xFFFF6B6B), + color: OipState.error, borderRadius: BorderRadius.circular(8.r), - border: Border.all(color: Colors.white, width: 0.8), + border: Border.all(color: OipText.inverse, width: 0.8), ), child: Text( badge > 99 ? '99+' : '$badge', - style: TextStyle( - color: Colors.white, - fontSize: 9.sp, + style: XFonts.labelSmall.copyWith( + color: OipText.inverse, fontWeight: FontWeight.w700, height: 1.1, + fontSize: 16.sp, ), ), ), @@ -327,8 +349,8 @@ class _PlatformEntryPageState extends State shape: BoxShape.circle, gradient: RadialGradient( colors: [ - const Color(0xFF7C5CFF).withValues(alpha: 0.45), - const Color(0xFF7C5CFF).withValues(alpha: 0.0), + OipAi.accent.withValues(alpha: 0.45), + OipAi.accent.withValues(alpha: 0.0), ], ), ), @@ -345,8 +367,8 @@ class _PlatformEntryPageState extends State shape: BoxShape.circle, gradient: RadialGradient( colors: [ - const Color(0xFF366EF4).withValues(alpha: 0.35), - const Color(0xFF366EF4).withValues(alpha: 0.0), + OipBrand.primary.withValues(alpha: 0.35), + OipBrand.primary.withValues(alpha: 0.0), ], ), ), @@ -361,7 +383,7 @@ class _PlatformEntryPageState extends State child: CustomPaint( size: Size(80.w, 80.w), painter: _GridDotPainter( - color: Colors.white, + color: OipText.inverse, spacing: 12.w, dotRadius: 1.2, ), @@ -372,7 +394,6 @@ class _PlatformEntryPageState extends State Column( children: [ _buildHeaderTopRow(setting, topPad), - _buildHeaderStats(), _buildQuickEntries(), ], ), @@ -381,33 +402,53 @@ class _PlatformEntryPageState extends State ); } - /// Header 顶部行:logo + 平台名 + 在线状态 + 门户入口 + /// 返回按钮:白色图标适配深色渐变背景,点击区域 ≥44×44 + Widget _buildBackButton() { + return GestureDetector( + onTap: () => RoutePages.back(context), + child: Container( + width: 44.w, + height: 44.w, + alignment: Alignment.center, + child: Icon( + Icons.arrow_back_ios_new, + size: 20.sp, + color: OipText.inverse, + ), + ), + ); + } + + /// Header 顶部行:返回按钮 + logo + 平台名 + 在线状态 + 门户入口 Widget _buildHeaderTopRow(XPlatformSetting setting, double topPad) { return Padding( - padding: EdgeInsets.fromLTRB(20.w, topPad + 16.h, 20.w, 12.h), + padding: EdgeInsets.fromLTRB(12.w, topPad + 10.h, 20.w, 12.h), child: Row( children: [ + // 返回按钮(满足 AGENTS.md §8 返回按钮契约) + _buildBackButton(), + SizedBox(width: 4.w), // Logo 容器:玻璃态 + 渐变描边 Container( - width: 48.w, - height: 48.w, + width: 52.w, + height: 52.w, decoration: BoxDecoration( borderRadius: BorderRadius.circular(14.r), gradient: LinearGradient( colors: [ - Colors.white.withValues(alpha: 0.3), - Colors.white.withValues(alpha: 0.1), + OipText.inverse.withValues(alpha: 0.3), + OipText.inverse.withValues(alpha: 0.1), ], begin: Alignment.topLeft, end: Alignment.bottomRight, ), border: Border.all( - color: Colors.white.withValues(alpha: 0.4), + color: OipText.inverse.withValues(alpha: 0.4), width: 1.2, ), boxShadow: [ BoxShadow( - color: const Color(0xFF7C5CFF).withValues(alpha: 0.35), + color: OipAi.accent.withValues(alpha: 0.35), blurRadius: 16, offset: const Offset(0, 6), ), @@ -416,7 +457,7 @@ class _PlatformEntryPageState extends State child: Center( child: XImage.platformNavButton( iconPath: XImage.logoNotBg, - iconSize: 28, + iconSize: 32, ), ), ), @@ -427,12 +468,9 @@ class _PlatformEntryPageState extends State children: [ Text( setting.name, - style: TextStyle( - color: Colors.white, - fontSize: 22.sp, - fontWeight: FontWeight.w700, + style: XFonts.headlineSmall.copyWith( + color: OipText.inverse, letterSpacing: 0.3, - height: 1.25, ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -440,21 +478,16 @@ class _PlatformEntryPageState extends State SizedBox(height: 4.h), Row( children: [ - // 在线状态点 + // 连接状态点 Container( - width: 7.w, - height: 7.w, + width: 8.w, + height: 8.w, decoration: BoxDecoration( shape: BoxShape.circle, - color: _isOnline - ? const Color(0xFF4ADE80) - : const Color(0xFFFFA726), + color: _connectionColor, boxShadow: [ BoxShadow( - color: (_isOnline - ? const Color(0xFF4ADE80) - : const Color(0xFFFFA726)) - .withValues(alpha: 0.7), + color: _connectionColor.withValues(alpha: 0.7), blurRadius: 8, spreadRadius: 2, ), @@ -463,12 +496,9 @@ class _PlatformEntryPageState extends State ), SizedBox(width: 7.w), Text( - _isOnline ? '在线 · 数据同步中' : '离线 · 网络连接中', - style: TextStyle( - color: Colors.white.withValues(alpha: 0.75), - fontSize: 12.sp, - fontWeight: FontWeight.w500, - height: 1.3, + _connectionStatusText, + style: XFonts.captionSmall.copyWith( + color: OipText.inverse.withValues(alpha: 0.75), ), ), ], @@ -482,95 +512,6 @@ class _PlatformEntryPageState extends State ); } - /// Header 数据统计行:栏目数 / 内容数 / 类型数 - Widget _buildHeaderStats() { - final resourceCount = _tabResources.length; - final totalBadges = - _logic.plazaBadges.values.fold(0, (sum, v) => sum + v); - final typeCount = _tabResources - .map((r) => r.typeName) - .where((t) => t.isNotEmpty) - .toSet() - .length; - - return Padding( - padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 6.h), - child: Row( - children: [ - _buildStatChip( - icon: Icons.view_module_outlined, - value: '$resourceCount', - label: '网站栏目'), - SizedBox(width: 10.w), - _buildStatChip( - icon: Icons.category_outlined, - value: '$typeCount', - label: '内容类型'), - SizedBox(width: 10.w), - _buildStatChip( - icon: Icons.bubble_chart_outlined, - value: totalBadges > 0 ? '$totalBadges' : '0', - label: '待阅'), - ], - ), - ); - } - - Widget _buildStatChip({ - required IconData icon, - required String value, - required String label, - }) { - return Expanded( - child: Container( - padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.h), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12.r), - border: Border.all( - color: Colors.white.withValues(alpha: 0.18), - width: 0.8, - ), - ), - child: Row( - children: [ - Icon(icon, color: Colors.white.withValues(alpha: 0.9), size: 16.w), - SizedBox(width: 8.w), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - value, - style: TextStyle( - color: Colors.white, - fontSize: 17.sp, - fontWeight: FontWeight.w700, - height: 1.15, - ), - ), - SizedBox(height: 2.h), - Text( - label, - style: TextStyle( - color: Colors.white.withValues(alpha: 0.72), - fontSize: 11.sp, - fontWeight: FontWeight.w500, - height: 1.25, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ], - ), - ), - ); - } - /// 多用途快捷入口栅格:商城 / 群组 / AI 沟通 / 视频直播 Widget _buildQuickEntries() { return Padding( @@ -594,7 +535,7 @@ class _PlatformEntryPageState extends State _buildQuickEntry( icon: Icons.smart_toy_rounded, label: 'AI', - gradient: const [Color(0xFF9B82FF), Color(0xFF7C5CFF)], + gradient: const [OipAi.accentLight, OipAi.accent], onTap: () => _jumpToAi(), ), SizedBox(width: 10.w), @@ -626,10 +567,10 @@ class _PlatformEntryPageState extends State child: Container( padding: EdgeInsets.symmetric(vertical: 12.h), decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.1), + color: OipText.inverse.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(14.r), border: Border.all( - color: Colors.white.withValues(alpha: 0.18), + color: OipText.inverse.withValues(alpha: 0.18), width: 0.8, ), ), @@ -637,8 +578,8 @@ class _PlatformEntryPageState extends State mainAxisSize: MainAxisSize.min, children: [ Container( - width: 38.w, - height: 38.w, + width: 42.w, + height: 42.w, decoration: BoxDecoration( gradient: LinearGradient( colors: gradient, @@ -654,16 +595,14 @@ class _PlatformEntryPageState extends State ), ], ), - child: Icon(icon, color: Colors.white, size: 20.w), + child: Icon(icon, color: OipText.inverse, size: 22.w), ), SizedBox(height: 8.h), Text( label, - style: TextStyle( - color: Colors.white.withValues(alpha: 0.92), - fontSize: 12.sp, + style: XFonts.captionSmall.copyWith( + color: OipText.inverse.withValues(alpha: 0.92), fontWeight: FontWeight.w600, - height: 1.3, ), ), ], @@ -740,25 +679,23 @@ class _PlatformEntryPageState extends State child: Container( padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h), decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.18), + color: OipText.inverse.withValues(alpha: 0.18), borderRadius: BorderRadius.circular(14.r), border: Border.all( - color: Colors.white.withValues(alpha: 0.4), + color: OipText.inverse.withValues(alpha: 0.4), width: 1.2, ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.business_outlined, color: Colors.white, size: 16.w), + Icon(Icons.business_outlined, color: OipText.inverse, size: 17.w), SizedBox(width: 5.w), Text( '门户', - style: TextStyle( - color: Colors.white, - fontSize: 13.sp, + style: XFonts.labelSmall.copyWith( + color: OipText.inverse, fontWeight: FontWeight.w600, - height: 1.2, ), ), ], @@ -790,28 +727,18 @@ class _PlatformEntryPageState extends State return Container( margin: EdgeInsets.symmetric(horizontal: 12.w), padding: EdgeInsets.symmetric(vertical: 40.h), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.04), - blurRadius: 10, - offset: const Offset(0, 3), - ), - ], - ), + decoration: XUi.cardDecoration(radius: OipRadius.xl), child: Center( child: Column( children: [ - Icon(Icons.cloud_off_outlined, - size: 48.w, color: Colors.grey.shade400), + Icon(Icons.cloud_off_outlined, size: 48.w, color: OipText.disabled), SizedBox(height: 12.h), Text('暂无网站栏目数据', - style: TextStyle(fontSize: 14.sp, color: Colors.grey.shade500)), + style: XFonts.captionSmall.copyWith(color: OipText.tertiary)), SizedBox(height: 4.h), Text('请检查集群绑定或网络连接', - style: TextStyle(fontSize: 11.sp, color: Colors.grey.shade400)), + style: XFonts.captionSmall + .copyWith(color: OipText.disabled, fontSize: 16.sp)), ], ), ), @@ -827,17 +754,7 @@ class _PlatformEntryPageState extends State return Container( margin: EdgeInsets.symmetric(horizontal: 12.w), padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 12.h), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(14.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.03), - blurRadius: 6, - offset: const Offset(0, 2), - ), - ], - ), + decoration: XUi.cardDecoration(radius: OipRadius.lg), child: Column( children: [ if (setting.recordInfo.isNotEmpty) @@ -860,22 +777,22 @@ class _PlatformEntryPageState extends State }) { final hasLink = link != null && link.isNotEmpty; const linkColor = OipBrand.primary; - final metaColor = Colors.grey.shade500; + const metaColor = OipText.tertiary; return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(icon, size: 14.w, color: Colors.grey.shade400), + Icon(icon, size: 14.w, color: OipText.disabled), SizedBox(width: 5.w), Text('$label: ', style: XFonts.portalListMeta - .copyWith(color: metaColor, fontSize: 11.sp)), + .copyWith(color: metaColor, fontSize: 16.sp)), Expanded( child: GestureDetector( onTap: hasLink ? () => RoutePages.jumpWeb(url: link) : null, child: Text(value, style: XFonts.portalListMeta.copyWith( color: hasLink ? linkColor : metaColor, - fontSize: 11.sp, + fontSize: 16.sp, decoration: hasLink ? TextDecoration.underline : null, )), ), @@ -884,11 +801,53 @@ class _PlatformEntryPageState extends State ); } - bool get _isOnline { + /// 连接状态颜色 + Color get _connectionColor { try { - return kernel.transportHealth.name == 'connected'; + final health = kernel.transportHealth; + final mode = kernel.transportMode; + switch (health) { + case TransportHealth.connected: + // 长连接正常:绿色;HTTP 降级:橙色 + return mode == TransportMode.longLink + ? OipState.success + : OipState.warning; + case TransportHealth.connecting: + case TransportHealth.reconnecting: + return OipState.warning; + case TransportHealth.degraded: + return OipState.warning; + case TransportHealth.stopped: + case TransportHealth.idle: + return OipState.error; + } + } catch (_) { + return OipState.error; + } + } + + /// 连接状态文本:SignalR/HTTP + 正常/断线 + String get _connectionStatusText { + try { + final health = kernel.transportHealth; + final mode = kernel.transportMode; + final modeText = mode == TransportMode.longLink ? 'SignalR' : 'HTTP'; + switch (health) { + case TransportHealth.connected: + return '$modeText · 正常'; + case TransportHealth.connecting: + return '$modeText · 连接中'; + case TransportHealth.reconnecting: + return '$modeText · 重连中'; + case TransportHealth.degraded: + return 'HTTP · 降级'; + case TransportHealth.stopped: + return '离线 · 断线'; + case TransportHealth.idle: + return '待连接'; + } } catch (_) { - return true; + return '离线 · 断线'; } } } @@ -1056,9 +1015,7 @@ class _PlazaContentListViewState extends State<_PlazaContentListView> { if (_isLoading && _items.isEmpty) { return Padding( padding: EdgeInsets.symmetric(vertical: 40.h), - child: const Center( - child: CircularProgressIndicator(color: OipBrand.primary), - ), + child: const LoadingWidget(isLoading: true, message: '数据加载中'), ); } @@ -1175,7 +1132,7 @@ class _PlazaContentListViewState extends State<_PlazaContentListView> { ), ], ), - child: Icon(icon, color: Colors.white, size: 24.w), + child: Icon(icon, color: OipText.inverse, size: 24.w), ), SizedBox(width: 14.w), Expanded( @@ -1187,12 +1144,8 @@ class _PlazaContentListViewState extends State<_PlazaContentListView> { Flexible( child: Text( widget.resource.name, - style: TextStyle( - fontSize: 17.sp, - fontWeight: FontWeight.w700, - color: XColors.black3, - height: 1.25, - ), + style: + XFonts.titleSmall.copyWith(color: OipText.primary), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -1212,11 +1165,9 @@ class _PlazaContentListViewState extends State<_PlazaContentListView> { ), child: Text( typeLabel, - style: TextStyle( - fontSize: 11.sp, + style: XFonts.labelSmall.copyWith( color: color, fontWeight: FontWeight.w600, - height: 1.2, ), ), ), @@ -1226,12 +1177,7 @@ class _PlazaContentListViewState extends State<_PlazaContentListView> { SizedBox(height: 5.h), Text( '共 ${_items.length} 条内容 · 下拉刷新', - style: TextStyle( - fontSize: 12.sp, - color: XColors.black9, - fontWeight: FontWeight.w500, - height: 1.3, - ), + style: XFonts.captionSmall.copyWith(color: OipText.tertiary), ), ], ), @@ -1261,18 +1207,16 @@ class _PlazaContentListViewState extends State<_PlazaContentListView> { children: [ Text( '查看全部', - style: TextStyle( - fontSize: 12.sp, - color: Colors.white, + style: XFonts.captionSmall.copyWith( + color: OipText.inverse, fontWeight: FontWeight.w600, - height: 1.2, ), ), SizedBox(width: 4.w), Icon( Icons.arrow_forward_rounded, - size: 14.w, - color: Colors.white, + size: 15.w, + color: OipText.inverse, ), ], ), @@ -1284,11 +1228,15 @@ class _PlazaContentListViewState extends State<_PlazaContentListView> { } Widget _buildItemCard(dynamic item) { - return _PlatformEntryItemCard( + final sourceName = widget.resource.name; + final sourceId = + widget.resource.sourceId.isNotEmpty ? widget.resource.sourceId : null; + // 按栏目 typeName 分发到对应风格卡片,统一平台入口与发现页视觉体验 + return plazaStyleCardByType( item: item, - sourceName: widget.resource.name, - sourceId: - widget.resource.sourceId.isNotEmpty ? widget.resource.sourceId : null, + typeName: widget.resource.typeName, + sourceName: sourceName, + sourceId: sourceId, ); } @@ -1351,7 +1299,7 @@ class _PlazaContentListViewState extends State<_PlazaContentListView> { SizedBox(height: 12.h), Text( _errorMsg ?? '加载失败', - style: TextStyle(fontSize: 13.sp, color: Colors.grey.shade600), + style: XFonts.captionSmall.copyWith(color: OipText.tertiary), textAlign: TextAlign.center, ), SizedBox(height: 12.h), @@ -1376,11 +1324,11 @@ class _PlazaContentListViewState extends State<_PlazaContentListView> { child: Center( child: Column( children: [ - Icon(Icons.inbox_outlined, size: 48.w, color: Colors.grey.shade400), + Icon(Icons.inbox_outlined, size: 48.w, color: OipText.disabled), SizedBox(height: 12.h), Text( '「${widget.resource.name}」暂无内容', - style: TextStyle(fontSize: 13.sp, color: Colors.grey.shade500), + style: XFonts.captionSmall.copyWith(color: OipText.tertiary), ), ], ), @@ -1444,655 +1392,6 @@ class _PlazaContentListViewState extends State<_PlazaContentListView> { } } -/// 平台入口页专用内容卡片:未来科技感样式 -/// -/// 与 [PlazaItemCard] 区别: -/// - 顶部彩色装饰条 + 类型徽章 -/// - 视频/直播使用 16:9 大缩略图 + 状态徽章 -/// - 底部带类型特定行动按钮(加入群组 / 立即下单 / 播放 / 进入直播间等) -/// - 字段提取复用 [PlazaItemFields],行为复用 [showPlazaItemDetail] -class _PlatformEntryItemCard extends StatelessWidget { - final dynamic item; - final String sourceName; - final String? sourceId; - - const _PlatformEntryItemCard({ - required this.item, - required this.sourceName, - this.sourceId, - }); - - @override - Widget build(BuildContext context) { - final color = PlazaItemFields.colorOf(item); - final icon = PlazaItemFields.icon(item); - final title = PlazaItemFields.title(item); - final summary = PlazaItemFields.summary(item); - final timeStr = PlazaItemFields.formatTime(PlazaItemFields.rawTime(item)); - final thumb = PlazaItemFields.thumbnail(item); - final typeLabel = _getTypeLabel(); - final useWideThumb = _isVideoLike(); - - return Container( - margin: EdgeInsets.only(bottom: 14.h), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(18.r), - gradient: const LinearGradient( - colors: [Colors.white, Color(0xFFFAFBFD)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - boxShadow: [ - BoxShadow( - color: color.withValues(alpha: 0.1), - blurRadius: 14, - offset: const Offset(0, 5), - ), - BoxShadow( - color: Colors.black.withValues(alpha: 0.04), - blurRadius: 6, - offset: const Offset(0, 2), - ), - ], - ), - child: Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(18.r), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: () => showPlazaItemDetail( - context, - item, - sourceName: sourceName, - sourceId: sourceId, - ), - borderRadius: BorderRadius.circular(18.r), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // 顶部彩色装饰条 - Container( - height: 4.h, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [color, color.withValues(alpha: 0.45)], - begin: Alignment.centerLeft, - end: Alignment.centerRight, - ), - ), - ), - // 内容主体 - if (useWideThumb && thumb.isNotEmpty) - _buildWideThumbBody( - thumb, color, icon, title, summary, timeStr, typeLabel) - else - _buildNormalBody( - thumb, color, icon, title, summary, timeStr, typeLabel), - // 底部行动按钮区 - _buildActionRow(color), - ], - ), - ), - ), - ); - } - - /// 视频/直播类型:使用 16:9 大缩略图 - bool _isVideoLike() { - return item is PlazaVideo || item is PlazaLive; - } - - /// 类型徽章文字 - String _getTypeLabel() { - if (item is PlazaTarget) return '群组'; - if (item is PlazaVideo) return '视频'; - if (item is PlazaNotice) return '公告'; - if (item is PlazaMarketGoods) return '商品'; - if (item is PlazaDataShare) return '数据'; - if (item is PlazaLive) return '直播'; - return ''; - } - - /// 大缩略图模式(视频/直播) - Widget _buildWideThumbBody( - String url, - Color color, - IconData icon, - String title, - String summary, - String timeStr, - String typeLabel, - ) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.zero, - child: AspectRatio( - aspectRatio: 16 / 9, - child: CachedNetworkImage( - imageUrl: url, - fit: BoxFit.cover, - placeholder: (_, __) => Container( - color: color.withValues(alpha: 0.12), - child: Center(child: Icon(icon, color: color, size: 40.w)), - ), - errorWidget: (_, __, ___) => Container( - color: color.withValues(alpha: 0.12), - child: Center(child: Icon(icon, color: color, size: 40.w)), - ), - ), - ), - ), - // 渐变遮罩(提升文字可读性) - Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withValues(alpha: 0.6), - ], - stops: const [0.5, 1.0], - ), - ), - ), - ), - // 左上:类型徽章 - Positioned( - top: 10.h, - left: 10.w, - child: _buildTypeBadge(typeLabel, color), - ), - // 右上:状态徽章(直播中 / 时长) - Positioned( - top: 10.h, - right: 10.w, - child: _buildStatusBadge(color), - ), - // 左下:标题(叠加在缩略图上) - Positioned( - left: 14.w, - right: 14.w, - bottom: 10.h, - child: Text( - title, - style: TextStyle( - color: Colors.white, - fontSize: 15.sp, - fontWeight: FontWeight.w700, - height: 1.35, - shadows: [ - Shadow( - color: Colors.black.withValues(alpha: 0.55), - blurRadius: 5, - ), - ], - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - // 摘要 + 时间行 - if (summary.isNotEmpty || timeStr.isNotEmpty) - Padding( - padding: EdgeInsets.fromLTRB(14.w, 10.h, 14.w, 6.h), - child: Row( - children: [ - if (summary.isNotEmpty) - Expanded( - child: Text( - summary, - style: TextStyle( - fontSize: 12.sp, - color: XColors.black9, - fontWeight: FontWeight.w500, - height: 1.45, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ) - else - const Spacer(), - if (timeStr.isNotEmpty) ...[ - SizedBox(width: 8.w), - Icon(Icons.access_time_rounded, - size: 12.w, color: XColors.black9), - SizedBox(width: 4.w), - Text( - timeStr, - style: TextStyle( - fontSize: 11.sp, - color: XColors.black9, - fontWeight: FontWeight.w500), - ), - ], - ], - ), - ), - ], - ); - } - - /// 普通模式(左侧小缩略图) - Widget _buildNormalBody( - String url, - Color color, - IconData icon, - String title, - String summary, - String timeStr, - String typeLabel, - ) { - return Padding( - padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 14.h), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildSmallThumb(url, icon, color, typeLabel), - SizedBox(width: 14.w), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - if (typeLabel.isNotEmpty) - Padding( - padding: EdgeInsets.only(right: 8.w), - child: _buildTypeBadge(typeLabel, color), - ), - Flexible( - child: Text( - title, - style: TextStyle( - fontSize: 15.sp, - fontWeight: FontWeight.w700, - color: XColors.black3, - height: 1.35, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - if (summary.isNotEmpty) ...[ - SizedBox(height: 6.h), - Text( - summary, - style: TextStyle( - fontSize: 13.sp, - color: XColors.black6, - fontWeight: FontWeight.w500, - height: 1.45, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], - if (timeStr.isNotEmpty) ...[ - SizedBox(height: 8.h), - Row( - children: [ - Icon(Icons.access_time_rounded, - size: 12.w, color: XColors.black9), - SizedBox(width: 4.w), - Text( - timeStr, - style: TextStyle( - fontSize: 11.sp, - color: XColors.black9, - fontWeight: FontWeight.w500), - ), - ], - ), - ], - ], - ), - ), - ], - ), - ); - } - - /// 小缩略图(带类型徽章 + 渐变占位) - Widget _buildSmallThumb( - String url, IconData icon, Color color, String typeLabel) { - return Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(14.r), - child: url.isEmpty - ? Container( - width: 72.w, - height: 72.w, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - color.withValues(alpha: 0.2), - color.withValues(alpha: 0.06), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - child: Icon(icon, color: color, size: 32.w), - ) - : CachedNetworkImage( - imageUrl: url, - width: 72.w, - height: 72.w, - fit: BoxFit.cover, - placeholder: (_, __) => Container( - width: 72.w, - height: 72.w, - color: color.withValues(alpha: 0.12), - child: Icon(icon, color: color, size: 32.w), - ), - errorWidget: (_, __, ___) => Container( - width: 72.w, - height: 72.w, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - color.withValues(alpha: 0.2), - color.withValues(alpha: 0.06), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - child: Icon(icon, color: color, size: 32.w), - ), - ), - ), - ], - ); - } - - /// 类型徽章(玻璃态 + 渐变描边) - Widget _buildTypeBadge(String label, Color color) { - if (label.isEmpty) return const SizedBox.shrink(); - return Container( - padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.92), - borderRadius: BorderRadius.circular(6.r), - boxShadow: [ - BoxShadow( - color: color.withValues(alpha: 0.4), - blurRadius: 6, - offset: const Offset(0, 2), - ), - ], - ), - child: Text( - label, - style: TextStyle( - color: Colors.white, - fontSize: 10.sp, - fontWeight: FontWeight.w700, - height: 1.15, - letterSpacing: 0.3, - ), - ), - ); - } - - /// 状态徽章(直播中/时长/价格等) - Widget _buildStatusBadge(Color color) { - if (item is PlazaLive) { - final live = item as PlazaLive; - final isLive = live.isLive; - return Container( - padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h), - decoration: BoxDecoration( - color: isLive - ? const Color(0xFFFF4757) - : Colors.black.withValues(alpha: 0.65), - borderRadius: BorderRadius.circular(6.r), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (isLive) ...[ - Container( - width: 5.w, - height: 5.w, - margin: EdgeInsets.only(right: 4.w), - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Colors.white, - ), - ), - ], - Text( - isLive ? 'LIVE' : '未开播', - style: TextStyle( - color: Colors.white, - fontSize: 10.sp, - fontWeight: FontWeight.w700, - height: 1.15, - letterSpacing: 0.3, - ), - ), - ], - ), - ); - } - if (item is PlazaVideo) { - final v = item as PlazaVideo; - if (v.duration <= 0) return const SizedBox.shrink(); - final mins = (v.duration / 60).floor(); - final secs = v.duration % 60; - final text = - mins > 0 ? '$mins:${secs.toString().padLeft(2, '0')}' : '$secs秒'; - return Container( - padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.7), - borderRadius: BorderRadius.circular(6.r), - ), - child: Text( - text, - style: TextStyle( - color: Colors.white, - fontSize: 10.sp, - fontWeight: FontWeight.w600, - height: 1.15, - ), - ), - ); - } - if (item is PlazaMarketGoods) { - final g = item as PlazaMarketGoods; - if (g.price <= 0) return const SizedBox.shrink(); - return Container( - padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h), - decoration: BoxDecoration( - color: const Color(0xFFFF4757), - borderRadius: BorderRadius.circular(6.r), - ), - child: Text( - '¥${g.price.toStringAsFixed(2)}', - style: TextStyle( - color: Colors.white, - fontSize: 11.sp, - fontWeight: FontWeight.w700, - height: 1.15, - ), - ), - ); - } - return const SizedBox.shrink(); - } - - /// 底部行动按钮区:类型特定快速操作 - Widget _buildActionRow(Color color) { - final actions = _getTypeActions(color); - if (actions.isEmpty) return const SizedBox.shrink(); - return Padding( - padding: EdgeInsets.fromLTRB(14.w, 6.h, 14.w, 12.h), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - for (var i = 0; i < actions.length; i++) ...[ - if (i > 0) SizedBox(width: 10.w), - actions[i], - ], - ], - ), - ); - } - - /// 类型特定行动按钮 - List _getTypeActions(Color color) { - final list = []; - - if (item is PlazaTarget) { - list.add(_buildMiniButton( - label: '申请加入', - icon: Icons.person_add_outlined, - color: color, - primary: false, - )); - list.add(_buildMiniButton( - label: '进入群聊', - icon: Icons.chat_bubble_outline, - color: color, - primary: true, - )); - } else if (item is PlazaVideo) { - list.add(_buildMiniButton( - label: '播放', - icon: Icons.play_arrow_rounded, - color: color, - primary: true, - )); - } else if (item is PlazaLive) { - final isLive = (item as PlazaLive).isLive; - list.add(_buildMiniButton( - label: isLive ? '进入直播间' : '观看回放', - icon: Icons.live_tv_outlined, - color: color, - primary: true, - )); - } else if (item is PlazaMarketGoods) { - list.add(_buildMiniButton( - label: '加购物车', - icon: Icons.add_shopping_cart_outlined, - color: color, - primary: false, - )); - list.add(_buildMiniButton( - label: '立即下单', - icon: Icons.flash_on_rounded, - color: color, - primary: true, - )); - } else if (item is PlazaDataShare) { - final d = item as PlazaDataShare; - if (d.shareKey.isNotEmpty) { - list.add(_buildMiniButton( - label: '复制Key', - icon: Icons.vpn_key_outlined, - color: color, - primary: true, - )); - } - } else if (item is PlazaNotice) { - list.add(_buildMiniButton( - label: '查看详情', - icon: Icons.article_outlined, - color: color, - primary: true, - )); - } - - return list; - } - - /// 小型行动按钮(圆角胶囊式) - Widget _buildMiniButton({ - required String label, - required IconData icon, - required Color color, - required bool primary, - }) { - if (primary) { - return Container( - padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 7.h), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [color, color.withValues(alpha: 0.82)], - begin: Alignment.centerLeft, - end: Alignment.centerRight, - ), - borderRadius: BorderRadius.circular(14.r), - boxShadow: [ - BoxShadow( - color: color.withValues(alpha: 0.35), - blurRadius: 6, - offset: const Offset(0, 3), - ), - ], - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, color: Colors.white, size: 14.w), - SizedBox(width: 4.w), - Text( - label, - style: TextStyle( - color: Colors.white, - fontSize: 12.sp, - fontWeight: FontWeight.w600, - height: 1.2, - ), - ), - ], - ), - ); - } - return Container( - padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 7.h), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(14.r), - border: Border.all( - color: color.withValues(alpha: 0.45), - width: 0.8, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, color: color, size: 14.w), - SizedBox(width: 4.w), - Text( - label, - style: TextStyle( - color: color, - fontSize: 12.sp, - fontWeight: FontWeight.w600, - height: 1.2, - ), - ), - ], - ), - ); - } -} - /// 网格点装饰画笔(科技感纹理) class _GridDotPainter extends CustomPainter { final Color color; diff --git a/lib/pages/portal/secret_edit_page.dart b/lib/pages/portal/secret_edit_page.dart index 5d139cdb3123d9ae72fcd8b6a8e1952e0921edbd..9f2631858ea282d5add6c6fb28d1ad83e63d286a 100644 --- a/lib/pages/portal/secret_edit_page.dart +++ b/lib/pages/portal/secret_edit_page.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:orginone/components/XScaffold/XScaffold.dart'; +import 'package:orginone/config/oip_tokens.dart'; +import 'package:orginone/config/theme/unified_style.dart'; import 'package:orginone/dart/core/public/re_auth_service.dart'; import 'package:orginone/dart/core/public/secret_vault.dart'; @@ -161,7 +163,7 @@ class _SecretEditPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: TextStyle(fontSize: 14.sp, color: Colors.grey.shade700, fontWeight: FontWeight.w500)), + Text(label, style: TextStyle(fontSize: 16.sp, color: Colors.grey.shade700, fontWeight: FontWeight.w500)), SizedBox(height: 8.h), TextFormField( controller: controller, @@ -169,11 +171,11 @@ class _SecretEditPageState extends State { enabled: enabled, decoration: InputDecoration( hintText: hint, - hintStyle: TextStyle(color: Colors.grey.shade400, fontSize: 15.sp), + hintStyle: TextStyle(color: Colors.grey.shade400, fontSize: 16.sp), border: InputBorder.none, contentPadding: EdgeInsets.zero, ), - style: TextStyle(fontSize: 15.sp, color: enabled ? Colors.black87 : Colors.grey), + style: TextStyle(fontSize: 16.sp, color: enabled ? Colors.black87 : Colors.grey), validator: validator, ), ], @@ -188,13 +190,14 @@ class _SecretEditPageState extends State { child: ElevatedButton( onPressed: _isSaving ? null : _save, style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).primaryColor, + backgroundColor: OipBrand.primary, foregroundColor: Colors.white, + disabledBackgroundColor: OipBrand.primaryBorder, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), child: _isSaving ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Colors.white))) - : const Text('保存', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + : Text('保存', style: XFonts.labelLarge.copyWith(color: Colors.white)), ), ); } diff --git a/lib/pages/portal/smart_task/smart_task_page.dart b/lib/pages/portal/smart_task/smart_task_page.dart index 19cf6438edc1e3d8110e57d6e3019ced90af1236..0dd12ee0d3944471518ef24642d36a65f3d38a0d 100644 --- a/lib/pages/portal/smart_task/smart_task_page.dart +++ b/lib/pages/portal/smart_task/smart_task_page.dart @@ -82,7 +82,7 @@ class _SmartTaskPageState extends State { Text( '与智能体的会话记录', style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, color: XColors.black6, ), ), @@ -198,7 +198,7 @@ class _SmartTaskPageState extends State { Text( lastMsgTime, style: TextStyle( - fontSize: 12.sp, + fontSize: 16.sp, color: Colors.grey.shade500, ), ), @@ -211,7 +211,7 @@ class _SmartTaskPageState extends State { child: Text( lastMsgText, style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, color: hasUnread ? XColors.black3 : Colors.grey.shade600, @@ -241,7 +241,7 @@ class _SmartTaskPageState extends State { textAlign: TextAlign.center, style: TextStyle( color: Colors.white, - fontSize: 12.sp, + fontSize: 16.sp, fontWeight: FontWeight.w600, height: 1, ), @@ -264,7 +264,7 @@ class _SmartTaskPageState extends State { child: Text( '有人@我', style: TextStyle( - fontSize: 10.sp, + fontSize: 16.sp, color: Colors.orange.shade800, fontWeight: FontWeight.w600, ), diff --git a/lib/pages/portal/space_setting_page.dart b/lib/pages/portal/space_setting_page.dart index b7856d8d34243434e0170ef083c7ef83b859c885..94e10b1ea0152b609a55f2ab5b686ce39514da6c 100644 --- a/lib/pages/portal/space_setting_page.dart +++ b/lib/pages/portal/space_setting_page.dart @@ -330,7 +330,7 @@ class _SpaceInfoPage extends StatelessWidget { child: Text( title, style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, color: Colors.grey.shade500, fontWeight: FontWeight.w500, ), diff --git a/lib/pages/portal/storage_management_page.dart b/lib/pages/portal/storage_management_page.dart index 0f2c4c58fa9f77e06150fbc639c263fb7e777243..8a6d5fc012943d62e798e74527095cc2a2f2d1f7 100644 --- a/lib/pages/portal/storage_management_page.dart +++ b/lib/pages/portal/storage_management_page.dart @@ -9,7 +9,7 @@ import 'package:orginone/dart/core/public/enums.dart'; import 'package:orginone/dart/core/target/outTeam/istorage.dart'; import 'package:orginone/main.dart'; import 'package:orginone/utils/responsive/device_utils.dart'; -import 'package:orginone/pages/store/dba/collection_list_page.dart'; +import 'package:orginone/routers/pages.dart'; class StorageManagementPage extends StatefulWidget { const StorageManagementPage({super.key}); @@ -43,14 +43,9 @@ class _StorageManagementPageState extends State { if (!success) return; if (mounted) setState(() {}); } - // 跳转到数据集列表页面 + // 跳转到数据集列表页面(走路由封装,符合 AGENTS.md §3) if (mounted) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => CollectionListPage(storage: storage), - ), - ); + RoutePages.jumpCollectionListPage(context, storage); } } diff --git a/lib/pages/portal/widgets/favorites_widget.dart b/lib/pages/portal/widgets/favorites_widget.dart index 045bc19ad766cf412169bba3ea10e5d71eb8b01e..0e11f9602befa03bc88598dc7f9b9cf5650ed199 100644 --- a/lib/pages/portal/widgets/favorites_widget.dart +++ b/lib/pages/portal/widgets/favorites_widget.dart @@ -45,7 +45,7 @@ class FavoritesWidget extends StatelessWidget { child: Text( '更多', style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, color: OipBrand.primary, ), ), @@ -80,7 +80,7 @@ class FavoritesWidget extends StatelessWidget { child: Text( '暂无常用内容,快去添加吧', style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, color: Colors.grey.shade500, ), ), @@ -116,7 +116,7 @@ class FavoritesWidget extends StatelessWidget { Text( item.name, style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, color: XColors.black3, ), maxLines: 2, diff --git a/lib/pages/portal/widgets/portal_favorites.dart b/lib/pages/portal/widgets/portal_favorites.dart index 22be5b168f6c95746951f00055f2f08a73cc6d15..26d82eb486a2dbb9e484fdfed204b39cfbabb877 100644 --- a/lib/pages/portal/widgets/portal_favorites.dart +++ b/lib/pages/portal/widgets/portal_favorites.dart @@ -32,15 +32,9 @@ class PortalFavorites extends StatelessWidget { margin: EdgeInsets.symmetric(horizontal: 12.w, vertical: 4.h), padding: EdgeInsets.all(16.w), decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.04), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], + color: OipSurface.card, + borderRadius: BorderRadius.circular(OipRadius.xl.w), + boxShadow: OipShadow.sm, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -65,21 +59,21 @@ class PortalFavorites extends StatelessWidget { margin: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), padding: EdgeInsets.symmetric(vertical: 32.h, horizontal: 16.w), decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16.r), + color: OipSurface.card, + borderRadius: BorderRadius.circular(OipRadius.xl.w), ), child: Column( children: [ - Icon(Icons.star_outline, size: 48.w, color: Colors.grey.shade300), + Icon(Icons.star_outline, size: 48.w, color: OipText.disabled), SizedBox(height: 12.h), Text( '暂无常用内容', - style: TextStyle(fontSize: 15.sp, color: Colors.grey.shade500), + style: XFonts.bodyMedium.copyWith(color: OipText.tertiary), ), SizedBox(height: 4.h), Text( '在数据页面长按可添加常用', - style: TextStyle(fontSize: 13.sp, color: Colors.grey.shade400), + style: XFonts.caption.copyWith(color: OipText.disabled), ), if (onMore != null) ...[ SizedBox(height: 16.h), @@ -89,16 +83,9 @@ class PortalFavorites extends StatelessWidget { padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 8.h), decoration: BoxDecoration( color: OipBrand.primary.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(20.r), - ), - child: Text( - '去添加', - style: TextStyle( - fontSize: 14.sp, - color: OipBrand.primary, - fontWeight: FontWeight.w500, - ), + borderRadius: BorderRadius.circular(OipRadius.s2xl.w), ), + child: Text('去添加', style: XFonts.brandSmall), ), ), ], @@ -113,16 +100,9 @@ class PortalFavorites extends StatelessWidget { children: [ Row( children: [ - Icon(Icons.star, size: 18.w, color: Colors.amber.shade600), + Icon(Icons.star, size: 18.w, color: OipState.warning), SizedBox(width: 6.w), - Text( - '常用', - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.bold, - color: XColors.black3, - ), - ), + Text('常用', style: XFonts.titleSmall), ], ), if (onMore != null) @@ -131,10 +111,7 @@ class PortalFavorites extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text( - '管理', - style: TextStyle(fontSize: 12.sp, color: OipBrand.primary), - ), + Text('管理', style: XFonts.brandSmall), Icon(Icons.chevron_right, size: 14.w, color: OipBrand.primary), ], ), @@ -158,7 +135,7 @@ class PortalFavorites extends StatelessWidget { height: 48.w, decoration: BoxDecoration( color: accentColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12.r), + borderRadius: BorderRadius.circular(OipRadius.lg.w), ), child: Icon( _getItemIcon(item.type), @@ -169,10 +146,7 @@ class PortalFavorites extends StatelessWidget { SizedBox(height: 6.h), Text( item.name, - style: TextStyle( - fontSize: 12.sp, - color: XColors.black3, - ), + style: XFonts.labelSmall.copyWith(color: OipText.primary), maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, @@ -186,13 +160,13 @@ class PortalFavorites extends StatelessWidget { Color _getTypeColor(FavoriteType type) { switch (type) { case FavoriteType.work: - return const Color(0xFFF59E0B); + return OipState.warning; case FavoriteType.form: - return const Color(0xFF10B981); + return OipState.success; case FavoriteType.application: - return const Color(0xFF366EF4); + return OipBrand.primary; case FavoriteType.file: - return const Color(0xFF8B5CF6); + return OipAi.accent; } } @@ -238,7 +212,8 @@ class PortalFavorites extends StatelessWidget { showModalBottomSheet( context: context, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20.r)), + borderRadius: + BorderRadius.vertical(top: Radius.circular(OipRadius.s2xl.w)), ), builder: (sheetContext) { return SafeArea( @@ -250,7 +225,7 @@ class PortalFavorites extends StatelessWidget { width: 40.w, height: 4.h, decoration: BoxDecoration( - color: Colors.grey.shade300, + color: OipSurface.mutedFg, borderRadius: BorderRadius.circular(2), ), ), diff --git a/lib/pages/portal/widgets/portal_header.dart b/lib/pages/portal/widgets/portal_header.dart index 8bcc7df1f9c44e5a6fdfa951c0939542f0a40b1b..7bfa9edca9a07991e82acc4cc0d94c749e17ff1a 100644 --- a/lib/pages/portal/widgets/portal_header.dart +++ b/lib/pages/portal/widgets/portal_header.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:orginone/config/oip_tokens.dart'; +import 'package:orginone/config/theme/unified_style.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:orginone/components/XImage/XImage.dart'; import 'package:orginone/main.dart'; @@ -24,7 +25,7 @@ class PortalHeader extends StatelessWidget { gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [Color(0xFF366EF4), Color(0xFF0EA5E9)], + colors: [OipBrand.primary, OipBrand.primaryLight], ), ), child: Column( @@ -35,20 +36,15 @@ class PortalHeader extends StatelessWidget { Center( child: Text( '奥集能 · 智能协同平台', - style: TextStyle( - color: Colors.white.withValues(alpha: 0.6), - fontSize: 11.sp, + style: XFonts.captionSmall.copyWith( + color: OipText.inverse.withValues(alpha: 0.6), ), ), ), SizedBox(height: 10.h), Text( _greeting, - style: TextStyle( - color: Colors.white, - fontSize: 22.sp, - fontWeight: FontWeight.bold, - ), + style: XFonts.titleMedium.copyWith(color: OipText.inverse), ), SizedBox(height: 2.h), Row( @@ -58,16 +54,15 @@ class PortalHeader extends StatelessWidget { height: 6.w, decoration: BoxDecoration( shape: BoxShape.circle, - color: _isOnline ? Colors.greenAccent : Colors.orange, + color: _isOnline ? OipState.success : OipState.warning, ), ), SizedBox(width: 6.w), Expanded( child: Text( '$userName · $spaceName', - style: TextStyle( - color: Colors.white.withValues(alpha: 0.7), - fontSize: 13.sp, + style: XFonts.caption.copyWith( + color: OipText.inverse.withValues(alpha: 0.7), ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -93,9 +88,8 @@ class PortalHeader extends StatelessWidget { onTap: () => RoutePages.jumpChat(), child: Text( '沟通', - style: TextStyle( - color: Colors.white.withValues(alpha: 0.6), - fontSize: 14.sp, + style: XFonts.labelSmall.copyWith( + color: OipText.inverse.withValues(alpha: 0.6), ), ), ), @@ -104,9 +98,8 @@ class PortalHeader extends StatelessWidget { onTap: () => RoutePages.jumpWork(), child: Text( '办事', - style: TextStyle( - color: Colors.white.withValues(alpha: 0.6), - fontSize: 14.sp, + style: XFonts.labelSmall.copyWith( + color: OipText.inverse.withValues(alpha: 0.6), ), ), ), @@ -129,17 +122,17 @@ class PortalHeader extends StatelessWidget { children: [ Text( label, - style: TextStyle( - color: Colors.white, - fontSize: 15.sp, - fontWeight: active ? FontWeight.bold : FontWeight.normal, - ), + style: active + ? XFonts.labelLarge.copyWith(color: OipText.inverse) + : XFonts.labelMedium.copyWith( + color: OipText.inverse.withValues(alpha: 0.6), + ), ), SizedBox(height: 2.h), Container( width: 20.w, height: 2, - color: active ? Colors.white : Colors.transparent, + color: active ? OipText.inverse : Colors.transparent, ), ], ); @@ -151,15 +144,16 @@ class PortalHeader extends StatelessWidget { child: Container( padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 4.h), decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(12.r), + color: OipText.inverse.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(OipRadius.lg.w), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.swap_horiz, color: Colors.white, size: 12.sp), + Icon(Icons.swap_horiz, color: OipText.inverse, size: 12.sp), SizedBox(width: 4.w), - Text('空间', style: TextStyle(color: Colors.white, fontSize: 11.sp)), + Text('空间', + style: XFonts.captionSmall.copyWith(color: OipText.inverse)), ], ), ), @@ -169,7 +163,8 @@ class PortalHeader extends StatelessWidget { void _showSpaceDialog(BuildContext context) { final belongList = relationCtrl.user?.belongList; if (belongList == null || belongList.isEmpty) { - RoutePages.to(path: '/createCompany'); + // '/createCompany' 路由未注册,改用统一的 showAddFeatures 弹窗流程 + relationCtrl.showAddFeatures(relationCtrl.menuItems[2]); return; } // 显示空间选择弹窗 @@ -177,7 +172,8 @@ class PortalHeader extends StatelessWidget { context: context, isScrollControlled: true, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20.r)), + borderRadius: + BorderRadius.vertical(top: Radius.circular(OipRadius.s2xl.w)), ), builder: (ctx) => SafeArea( child: Container( @@ -187,7 +183,7 @@ class PortalHeader extends StatelessWidget { children: [ Padding( padding: EdgeInsets.all(16.w), - child: Text('切换空间', style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.bold)), + child: Text('切换空间', style: XFonts.titleSmall), ), Flexible( child: ListView( @@ -198,13 +194,16 @@ class PortalHeader extends StatelessWidget { width: 40.w, height: 40.w, child: ClipRRect( - borderRadius: BorderRadius.circular(8.r), + borderRadius: + BorderRadius.circular(OipRadius.md.w), child: XImage.entityIcon(space, width: 40.w), ), ), title: Text(space.name), - trailing: relationCtrl.user?.currentSpace.id == space.id - ? const Icon(Icons.check, color: OipBrand.primary, size: 20) + trailing: relationCtrl.user?.currentSpace.id == + space.id + ? const Icon(Icons.check, + color: OipBrand.primary, size: 20) : null, onTap: () { Navigator.pop(ctx); @@ -216,7 +215,8 @@ class PortalHeader extends StatelessWidget { title: const Text('创建新空间'), onTap: () { Navigator.pop(ctx); - RoutePages.to(path: '/createCompany'); + // '/createCompany' 路由未注册,改用统一的 showAddFeatures 弹窗流程 + relationCtrl.showAddFeatures(relationCtrl.menuItems[2]); }, ), ], @@ -237,7 +237,7 @@ class PortalHeader extends StatelessWidget { height: 28.w, decoration: BoxDecoration( shape: BoxShape.circle, - color: Colors.white.withValues(alpha: 0.3), + color: OipText.inverse.withValues(alpha: 0.3), ), child: ClipOval(child: XImage.entityIcon(user, width: 28.w)), ); diff --git a/lib/pages/portal/widgets/portal_input_bar.dart b/lib/pages/portal/widgets/portal_input_bar.dart index 9ab96772b0e0d2c2f6216c24c9bb8727f5b26f13..c8c73619c06e9cc421ff85348de4fc59b1c0fbe0 100644 --- a/lib/pages/portal/widgets/portal_input_bar.dart +++ b/lib/pages/portal/widgets/portal_input_bar.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; +import 'package:orginone/config/oip_tokens.dart'; import 'package:orginone/config/theme/unified_style.dart'; import 'package:orginone/main.dart'; import 'package:orginone/components/ChatSessionWidget/ChatSessionWidget.dart'; @@ -23,15 +24,9 @@ class _PortalInputBarState extends State { Widget build(BuildContext context) { return Container( padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h), - decoration: BoxDecoration( - color: Colors.white, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.06), - blurRadius: 12, - offset: const Offset(0, -2), - ), - ], + decoration: const BoxDecoration( + color: OipSurface.card, + boxShadow: OipShadow.keyboard, ), child: SafeArea( child: Row( @@ -57,12 +52,12 @@ class _PortalInputBarState extends State { height: 36.w, decoration: BoxDecoration( shape: BoxShape.circle, - border: Border.all(color: XColors.dividerLineColor), + border: Border.all(color: OipBorder.input), ), child: Icon( _isVoiceMode ? Icons.keyboard : Icons.mic, size: 18.sp, - color: const Color(0xFF366EF4), + color: OipBrand.primary, ), ), ); @@ -77,13 +72,13 @@ class _PortalInputBarState extends State { child: Container( height: 36.h, decoration: BoxDecoration( - color: const Color(0xFFF5F7FA), - borderRadius: BorderRadius.circular(18.r), + color: OipSurface.background, + borderRadius: BorderRadius.circular(OipRadius.bubble.w), ), alignment: Alignment.center, child: Text( '按住说话', - style: TextStyle(fontSize: 14.sp, color: XColors.black9), + style: XFonts.labelSmall.copyWith(color: OipText.tertiary), ), ), ); @@ -93,14 +88,14 @@ class _PortalInputBarState extends State { child: Container( height: 36.h, decoration: BoxDecoration( - color: const Color(0xFFF5F7FA), - borderRadius: BorderRadius.circular(18.r), + color: OipSurface.background, + borderRadius: BorderRadius.circular(OipRadius.bubble.w), ), alignment: Alignment.centerLeft, padding: EdgeInsets.symmetric(horizontal: 14.w), child: Text( '有什么工作问题需要问我吗', - style: TextStyle(fontSize: 15.sp, color: XColors.black9), + style: XFonts.bodyMedium.copyWith(color: OipText.tertiary), ), ), ); @@ -113,10 +108,10 @@ class _PortalInputBarState extends State { width: 36.w, height: 36.w, decoration: const BoxDecoration( - color: Color(0xFFF5F7FA), + color: OipSurface.background, shape: BoxShape.circle, ), - child: Icon(Icons.add, size: 18.sp, color: XColors.black9), + child: Icon(Icons.add, size: 18.sp, color: OipText.tertiary), ), ); } @@ -128,10 +123,10 @@ class _PortalInputBarState extends State { width: 36.w, height: 36.w, decoration: const BoxDecoration( - color: Color(0xFF366EF4), + color: OipBrand.primary, shape: BoxShape.circle, ), - child: Icon(Icons.camera_alt, size: 16.sp, color: Colors.white), + child: Icon(Icons.camera_alt, size: 16.sp, color: OipText.inverse), ), ); } @@ -140,7 +135,8 @@ class _PortalInputBarState extends State { showModalBottomSheet( context: context, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20.r)), + borderRadius: + BorderRadius.vertical(top: Radius.circular(OipRadius.s2xl.w)), ), builder: (ctx) => SafeArea( child: Padding( @@ -148,10 +144,14 @@ class _PortalInputBarState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - _buildAttachmentItem(ctx, Icons.insert_drive_file, '文件', const Color(0xFF366EF4)), - _buildAttachmentItem(ctx, Icons.image, '图片', const Color(0xFF10B981)), - _buildAttachmentItem(ctx, Icons.place, '位置', const Color(0xFFF59E0B)), - _buildAttachmentItem(ctx, Icons.person, '联系人', const Color(0xFF8B5CF6)), + _buildAttachmentItem( + ctx, Icons.insert_drive_file, '文件', OipBrand.primary), + _buildAttachmentItem( + ctx, Icons.image, '图片', OipEntity.device), + _buildAttachmentItem( + ctx, Icons.place, '位置', OipState.warning), + _buildAttachmentItem( + ctx, Icons.person, '联系人', OipAi.accent), ], ), ), @@ -159,7 +159,8 @@ class _PortalInputBarState extends State { ); } - Widget _buildAttachmentItem(BuildContext ctx, IconData icon, String label, Color color) { + Widget _buildAttachmentItem( + BuildContext ctx, IconData icon, String label, Color color) { return GestureDetector( onTap: () { Navigator.pop(ctx); @@ -173,12 +174,13 @@ class _PortalInputBarState extends State { height: 48.w, decoration: BoxDecoration( color: color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12.r), + borderRadius: BorderRadius.circular(OipRadius.lg.w), ), child: Icon(icon, color: color, size: 24.w), ), SizedBox(height: 6.h), - Text(label, style: TextStyle(fontSize: 12.sp, color: XColors.black3)), + Text(label, + style: XFonts.labelSmall.copyWith(color: OipText.primary)), ], ), ); @@ -188,7 +190,8 @@ class _PortalInputBarState extends State { showModalBottomSheet( context: context, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20.r)), + borderRadius: + BorderRadius.vertical(top: Radius.circular(OipRadius.s2xl.w)), ), builder: (ctx) => SafeArea( child: Column( @@ -196,19 +199,30 @@ class _PortalInputBarState extends State { children: [ SizedBox(height: 8.h), ListTile( - leading: const Icon(Icons.camera_alt, color: Color(0xFF366EF4)), + leading: + const Icon(Icons.camera_alt, color: OipBrand.primary), title: const Text('拍照'), - onTap: () { Navigator.pop(ctx); _openAgentChat(context); }, + onTap: () { + Navigator.pop(ctx); + _openAgentChat(context); + }, ), ListTile( - leading: const Icon(Icons.photo_library, color: Color(0xFF10B981)), + leading: + const Icon(Icons.photo_library, color: OipEntity.device), title: const Text('从相册选择'), - onTap: () { Navigator.pop(ctx); _openAgentChat(context); }, + onTap: () { + Navigator.pop(ctx); + _openAgentChat(context); + }, ), ListTile( - leading: const Icon(Icons.qr_code_scanner, color: Color(0xFF8B5CF6)), + leading: const Icon(Icons.qr_code_scanner, color: OipAi.accent), title: const Text('扫码'), - onTap: () { Navigator.pop(ctx); _openAgentChat(context); }, + onTap: () { + Navigator.pop(ctx); + _openAgentChat(context); + }, ), SizedBox(height: 8.h), ], @@ -222,7 +236,9 @@ class _PortalInputBarState extends State { if (space != null && space.agents.isNotEmpty) { Navigator.push( context, - MaterialPageRoute(builder: (_) => ChatSessionWidget(data: space.agents.first.session)), + MaterialPageRoute( + builder: (_) => + ChatSessionWidget(data: space.agents.first.session)), ); } else { RoutePages.jumpChat(defaultActiveTabs: ['智能体']); diff --git a/lib/pages/portal/widgets/portal_quick_actions.dart b/lib/pages/portal/widgets/portal_quick_actions.dart index d9095fc2a16a1395ac15674900c2b080a5f0b85a..bac19d33add59bb1a971268fa167f2b696776088 100644 --- a/lib/pages/portal/widgets/portal_quick_actions.dart +++ b/lib/pages/portal/widgets/portal_quick_actions.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:orginone/components/Tip/ToastUtils.dart'; +import 'package:orginone/config/oip_tokens.dart'; import 'package:orginone/config/theme/unified_style.dart'; import 'package:orginone/main.dart'; import 'package:orginone/routers/pages.dart'; @@ -41,8 +42,8 @@ class PortalQuickActions extends StatelessWidget { begin: Alignment.centerLeft, end: Alignment.centerRight, colors: [ - const Color(0xFFF5F7FA).withValues(alpha: 0), - const Color(0xFFF5F7FA), + OipSurface.background.withValues(alpha: 0), + OipSurface.background, ], ), ), @@ -61,29 +62,29 @@ class PortalQuickActions extends StatelessWidget { } List<_Action> get _personalActions => [ - _Action(Icons.assignment, '发起办事', const Color(0xFFFEF3C7), - const Color(0xFFF59E0B), () => RoutePages.to(path: Routers.workStartPage)), - _Action(Icons.chat, '创建群聊', const Color(0xFFDBEAFE), - const Color(0xFF366EF4), () => RoutePages.jumpChat()), - _Action(Icons.folder, '文件中心', const Color(0xFFD1FAE5), - const Color(0xFF10B981), () => RoutePages.jumpChat(defaultActiveTabs: ['文件'])), - _Action(Icons.bar_chart, '数据视图', const Color(0xFFEDE9FE), - const Color(0xFF8B5CF6), () => RoutePages.to(path: Routers.formPage)), - _Action(Icons.more_horiz, '更多功能', const Color(0xFF366EF4), - Colors.white, () => RoutePages.to(path: Routers.portalManager)), + _Action(Icons.assignment, '发起办事', OipState.warning, + () => RoutePages.to(path: Routers.workStartPage)), + _Action(Icons.chat, '创建群聊', OipBrand.primary, + () => RoutePages.jumpChat()), + _Action(Icons.folder, '文件中心', OipState.success, + () => RoutePages.jumpChat(defaultActiveTabs: ['文件'])), + _Action(Icons.bar_chart, '数据视图', OipAi.accent, + () => RoutePages.to(path: Routers.formPage)), + _Action(Icons.more_horiz, '更多功能', OipBrand.primary, + () => RoutePages.to(path: Routers.portalManager)), ]; List<_Action> get _companyActions => [ - _Action(Icons.assignment, '发起办事', const Color(0xFFFEF3C7), - const Color(0xFFF59E0B), () => RoutePages.to(path: Routers.workStartPage)), - _Action(Icons.group_add, '加入群聊', const Color(0xFFDBEAFE), - const Color(0xFF366EF4), () => RoutePages.jumpChat()), - _Action(Icons.campaign, '发布动态', const Color(0xFFD1FAE5), - const Color(0xFF10B981), () => RoutePages.jumpChat(defaultActiveTabs: ['动态'])), - _Action(Icons.bar_chart, '数据视图', const Color(0xFFEDE9FE), - const Color(0xFF8B5CF6), () => RoutePages.to(path: Routers.formPage)), - _Action(Icons.more_horiz, '更多功能', const Color(0xFF366EF4), - Colors.white, () => RoutePages.to(path: Routers.portalManager)), + _Action(Icons.assignment, '发起办事', OipState.warning, + () => RoutePages.to(path: Routers.workStartPage)), + _Action(Icons.group_add, '加入群聊', OipBrand.primary, + () => RoutePages.jumpChat()), + _Action(Icons.campaign, '发布动态', OipState.success, + () => RoutePages.jumpChat(defaultActiveTabs: ['动态'])), + _Action(Icons.bar_chart, '数据视图', OipAi.accent, + () => RoutePages.to(path: Routers.formPage)), + _Action(Icons.more_horiz, '更多功能', OipBrand.primary, + () => RoutePages.to(path: Routers.portalManager)), ]; Widget _buildCapsule(_Action action) { @@ -93,15 +94,9 @@ class PortalQuickActions extends StatelessWidget { child: Container( padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 8.h), decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.04), - blurRadius: 4, - offset: const Offset(0, 1), - ), - ], + color: OipSurface.card, + borderRadius: BorderRadius.circular(OipRadius.s2xl.w), + boxShadow: OipShadow.sm, ), child: Row( mainAxisSize: MainAxisSize.min, @@ -110,13 +105,14 @@ class PortalQuickActions extends StatelessWidget { width: 20.w, height: 20.w, decoration: BoxDecoration( - color: action.iconBg, - borderRadius: BorderRadius.circular(6.r), + color: action.color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(OipRadius.sm.w), ), - child: Icon(action.icon, size: 12.sp, color: action.iconColor), + child: Icon(action.icon, size: 12.sp, color: action.color), ), SizedBox(width: 6.w), - Text(action.label, style: TextStyle(fontSize: 12.sp, color: XColors.black3)), + Text(action.label, + style: XFonts.labelSmall.copyWith(color: OipText.primary)), ], ), ), @@ -131,9 +127,8 @@ class PortalQuickActions extends StatelessWidget { class _Action { final IconData icon; final String label; - final Color iconBg; - final Color iconColor; + final Color color; final VoidCallback onTap; - const _Action(this.icon, this.label, this.iconBg, this.iconColor, this.onTap); + const _Action(this.icon, this.label, this.color, this.onTap); } diff --git a/lib/pages/portal/widgets/portal_recommend.dart b/lib/pages/portal/widgets/portal_recommend.dart index 1f8d340d7991b70fb078acfe3b05121d3de08b43..b322144ac075ad9bd21e7730f98674950bbf3257 100644 --- a/lib/pages/portal/widgets/portal_recommend.dart +++ b/lib/pages/portal/widgets/portal_recommend.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import 'package:orginone/components/Executor/open_executor.dart'; +import 'package:orginone/config/oip_tokens.dart'; import 'package:orginone/config/theme/unified_style.dart'; import 'package:orginone/dart/core/target/team/agent.dart'; import 'package:orginone/components/ChatSessionWidget/ChatSessionWidget.dart'; @@ -25,15 +26,9 @@ class PortalRecommend extends StatelessWidget { margin: EdgeInsets.symmetric(horizontal: 12.w), padding: EdgeInsets.all(16.w), decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.04), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], + color: OipSurface.card, + borderRadius: BorderRadius.circular(OipRadius.xl.w), + boxShadow: OipShadow.sm, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -59,20 +54,12 @@ class PortalRecommend extends StatelessWidget { Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - '今日推荐', - style: TextStyle( - fontSize: 18.sp, - fontWeight: FontWeight.bold, - color: XColors.black3, - ), - ), + Text('今日推荐', style: XFonts.titleSmall), SizedBox(height: 2.h), Text( 'DAILY RECOMMENDATIONS', - style: TextStyle( - fontSize: 10.sp, - color: XColors.black9, + style: XFonts.captionSmall.copyWith( + color: OipText.tertiary, letterSpacing: 0.5, ), ), @@ -81,15 +68,12 @@ class PortalRecommend extends StatelessWidget { Container( padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h), decoration: BoxDecoration( - color: const Color(0xFFE6FFFA), - borderRadius: BorderRadius.circular(6.r), + color: OipState.successBg, + borderRadius: BorderRadius.circular(OipRadius.sm.w), ), child: Text( dateStr, - style: TextStyle( - fontSize: 12.sp, - color: const Color(0xFF14B8A6), - ), + style: XFonts.labelSmall.copyWith(color: OipState.success), ), ), ], @@ -104,10 +88,11 @@ class PortalRecommend extends StatelessWidget { alignment: Alignment.centerRight, padding: EdgeInsets.only(right: 16.w), decoration: BoxDecoration( - color: Colors.red.shade400, - borderRadius: BorderRadius.circular(8.r), + color: OipState.error, + borderRadius: BorderRadius.circular(OipRadius.md.w), ), - child: Text('不感兴趣', style: TextStyle(color: Colors.white, fontSize: 12.sp)), + child: Text('不感兴趣', + style: XFonts.labelSmall.copyWith(color: OipText.inverse)), ), onDismissed: (_) => recommendations.remove(item), child: GestureDetector( @@ -116,7 +101,7 @@ class PortalRecommend extends StatelessWidget { padding: EdgeInsets.symmetric(vertical: 10.h), decoration: const BoxDecoration( border: Border( - bottom: BorderSide(color: XColors.dividerLineColor, width: 0.5), + bottom: BorderSide(color: OipBorder.divider, width: 0.5), ), ), child: Row( @@ -126,12 +111,13 @@ class PortalRecommend extends StatelessWidget { Expanded( child: Text( item.title, - style: TextStyle(fontSize: 15.sp, color: XColors.black3), + style: + XFonts.bodyMedium.copyWith(color: OipText.primary), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), - Icon(Icons.chevron_right, size: 16.w, color: XColors.black9), + Icon(Icons.chevron_right, size: 16.w, color: OipText.tertiary), ], ), ), @@ -141,23 +127,22 @@ class PortalRecommend extends StatelessWidget { Widget _buildHashIcon(RecommendType type) { final color = switch (type) { - RecommendType.todo => const Color(0xFFF59E0B), - RecommendType.content => const Color(0xFF366EF4), - RecommendType.agent => const Color(0xFF8B5CF6), + RecommendType.todo => OipState.warning, + RecommendType.content => OipBrand.primary, + RecommendType.agent => OipAi.accent, }; return Container( width: 24.w, height: 24.w, decoration: BoxDecoration( color: color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(6.r), + borderRadius: BorderRadius.circular(OipRadius.sm.w), ), child: Center( child: Text( '#', - style: TextStyle( - fontSize: 12.sp, - fontWeight: FontWeight.bold, + style: XFonts.labelSmall.copyWith( + fontWeight: XFonts.w700, color: color, ), ), @@ -178,7 +163,8 @@ class PortalRecommend extends StatelessWidget { Navigator.push( context, MaterialPageRoute( - builder: (_) => ChatSessionWidget(data: (item.data as IAgent).session), + builder: (_) => + ChatSessionWidget(data: (item.data as IAgent).session), ), ); } diff --git a/lib/pages/portal/widgets/portal_search_bar.dart b/lib/pages/portal/widgets/portal_search_bar.dart index a0dad67604b5082f576e3aa994d2d4a77469341f..865f1d69bd1ad5a9732db7c9d9f78aad768c58e6 100644 --- a/lib/pages/portal/widgets/portal_search_bar.dart +++ b/lib/pages/portal/widgets/portal_search_bar.dart @@ -132,7 +132,7 @@ class _PortalSearchBarState extends State { relationCtrl.user?.currentSpace.name ?? '工作空间', style: TextStyle( color: Colors.white70, - fontSize: 14.sp, + fontSize: 16.sp, ), ), ], @@ -176,7 +176,7 @@ class _PortalSearchBarState extends State { 'AI助手', style: TextStyle( color: Colors.white, - fontSize: 14.sp, + fontSize: 16.sp, fontWeight: FontWeight.w500, ), ), @@ -211,13 +211,13 @@ class _PortalSearchBarState extends State { decoration: InputDecoration( hintText: '搜索办事、视图、应用...', hintStyle: TextStyle( - fontSize: 15.sp, + fontSize: 16.sp, color: Colors.grey.shade400, ), border: InputBorder.none, contentPadding: EdgeInsets.symmetric(vertical: 12.h), ), - style: TextStyle(fontSize: 15.sp, color: XColors.black3), + style: TextStyle(fontSize: 16.sp, color: XColors.black3), onChanged: _performSearch, ), ), @@ -292,7 +292,7 @@ class _PortalSearchBarState extends State { child: Center( child: Text( '未找到相关内容', - style: TextStyle(fontSize: 14.sp, color: Colors.grey.shade500), + style: TextStyle(fontSize: 16.sp, color: Colors.grey.shade500), ), ), ); @@ -344,13 +344,13 @@ class _PortalSearchBarState extends State { ), title: Text( session.name, - style: TextStyle(fontSize: 15.sp, color: XColors.black3), + style: TextStyle(fontSize: 16.sp, color: XColors.black3), maxLines: 1, overflow: TextOverflow.ellipsis, ), subtitle: Text( '会话', - style: TextStyle(fontSize: 12.sp, color: Colors.grey.shade500), + style: TextStyle(fontSize: 16.sp, color: Colors.grey.shade500), ), onTap: () { _collapse(); @@ -377,13 +377,13 @@ class _PortalSearchBarState extends State { ), title: Text( item.name, - style: TextStyle(fontSize: 15.sp, color: XColors.black3), + style: TextStyle(fontSize: 16.sp, color: XColors.black3), maxLines: 1, overflow: TextOverflow.ellipsis, ), subtitle: Text( item.type, - style: TextStyle(fontSize: 12.sp, color: Colors.grey.shade500), + style: TextStyle(fontSize: 16.sp, color: Colors.grey.shade500), ), onTap: () { _collapse(); diff --git a/lib/pages/portal/widgets/search_dialog_widget.dart b/lib/pages/portal/widgets/search_dialog_widget.dart index 2178a0f723a7e43f25ab231420f8189adf6d511c..d563c0fc6a65a653b1da9bc61f6d16714f5ffacd 100644 --- a/lib/pages/portal/widgets/search_dialog_widget.dart +++ b/lib/pages/portal/widgets/search_dialog_widget.dart @@ -176,7 +176,7 @@ class _SearchDialogWidgetState extends State { child: Text( '未找到相关内容', style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, color: Colors.grey.shade500, ), ), @@ -228,7 +228,7 @@ class _SearchDialogWidgetState extends State { subtitle: Text( '会话', style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, color: Colors.grey.shade500, ), ), @@ -263,7 +263,7 @@ class _SearchDialogWidgetState extends State { subtitle: Text( '${item.type} · ${item.spaceName}', style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, color: Colors.grey.shade500, ), maxLines: 1, @@ -340,7 +340,7 @@ class _QuickActionButton extends StatelessWidget { Text( label, style: TextStyle( - fontSize: 12.sp, + fontSize: 16.sp, color: XColors.black3, ), ), diff --git a/lib/pages/portal/widgets/task_create_widget.dart b/lib/pages/portal/widgets/task_create_widget.dart index 7d930bdcd996c0f12f9bfc34c0e9c3a561c229ed..fc598b1bc5506419978c1b9c385d2164788290be 100644 --- a/lib/pages/portal/widgets/task_create_widget.dart +++ b/lib/pages/portal/widgets/task_create_widget.dart @@ -178,7 +178,7 @@ class _TaskCreateWidgetState extends State { (index + 1).toString(), style: TextStyle( color: Colors.white, - fontSize: 14.sp, + fontSize: 16.sp, fontWeight: FontWeight.bold, ), ), @@ -188,7 +188,7 @@ class _TaskCreateWidgetState extends State { Text( _getStepLabel(index), style: TextStyle( - fontSize: 12.sp, + fontSize: 16.sp, color: index <= _currentStep ? OipBrand.primary : Colors.grey.shade500, @@ -285,7 +285,7 @@ class _TaskCreateWidgetState extends State { Text( _getSpaceTypeLabel(space.type), style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, color: Colors.grey.shade500, ), ), @@ -388,7 +388,7 @@ class _TaskCreateWidgetState extends State { Text( model.description, style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, color: Colors.grey.shade500, ), ), diff --git a/lib/pages/portal/workBench/view.dart b/lib/pages/portal/workBench/view.dart index 9be0835861e6010ed472a1aa528a8815e1899813..b940a92973c203a6d804df1e63e1062d32d745ac 100644 --- a/lib/pages/portal/workBench/view.dart +++ b/lib/pages/portal/workBench/view.dart @@ -221,9 +221,9 @@ class _WorkBenchPageState extends State { ], // 提供分隔符 - if (isBelongEntity) _buildSpacer(10.h, Colors.white10), - if (isBelongEntity) _buildSpacer(10.h, Colors.white24), - _buildSpacer(10.h, Colors.white10), + if (isBelongEntity) _buildSpacer(10.h, OipSurface.divider), + if (isBelongEntity) _buildSpacer(10.h, OipSurface.muted), + _buildSpacer(10.h, OipSurface.divider), ], ); }); @@ -260,26 +260,14 @@ class _WorkBenchPageState extends State { // softWrap: true, maxLines: 1, textAlign: TextAlign.center, - style: TextStyle( - color: XColors.doorDesGrey, - fontSize: 18.sp, - fontFamily: 'PingFang SC', - // height: 0.16, - overflow: TextOverflow.ellipsis, - ), + style: XFonts.bodyMedium.copyWith(color: OipText.secondary), ), Text( file.directory.target.name, // softWrap: true, maxLines: 1, textAlign: TextAlign.center, - style: TextStyle( - color: XColors.doorDesGrey, - fontSize: 18.sp, - fontFamily: 'PingFang SC', - // height: 0.16, - overflow: TextOverflow.ellipsis, - ), + style: XFonts.bodyMedium.copyWith(color: OipText.secondary), ), ], )), @@ -317,23 +305,13 @@ class _WorkBenchPageState extends State { summary['name']?.toString() ?? '', maxLines: 1, textAlign: TextAlign.center, - style: TextStyle( - color: XColors.doorDesGrey, - fontSize: 18.sp, - fontFamily: 'PingFang SC', - overflow: TextOverflow.ellipsis, - ), + style: XFonts.bodyMedium.copyWith(color: OipText.secondary), ), Text( summary['targetName']?.toString() ?? '', maxLines: 1, textAlign: TextAlign.center, - style: TextStyle( - color: XColors.doorDesGrey, - fontSize: 18.sp, - fontFamily: 'PingFang SC', - overflow: TextOverflow.ellipsis, - ), + style: XFonts.bodyMedium.copyWith(color: OipText.secondary), ), ], ), @@ -379,12 +357,12 @@ class _WorkBenchPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, - style: TextStyle(fontSize: 24.sp, color: XColors.black)), + style: XFonts.numberMedium.copyWith(color: OipText.primary)), Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Text( '${formatSize(fsUsedSize)}/${formatSize(fsTotalSize)}', - style: TextStyle(fontSize: 24.sp, color: XColors.black)), + style: XFonts.numberMedium.copyWith(color: OipText.primary)), ), renderDataWidget('关 系(个)', relationCount, '${relationCtrl.provider.chatProvider?.chats.length ?? 0}'), @@ -660,12 +638,7 @@ class _WorkBenchPageState extends State { child: Text( title, textAlign: TextAlign.center, - style: TextStyle( - color: XColors.doorDesGrey, - fontSize: 18.sp, - fontFamily: 'PingFang SC', - height: 0.16, - ), + style: XFonts.bodyMedium.copyWith(color: OipText.secondary), ), ), ), @@ -716,15 +689,15 @@ class _WorkBenchPageState extends State { children: [ Text(title, style: - TextStyle(fontSize: 18.sp, color: XColors.doorDesGrey)), + XFonts.bodyMedium.copyWith(color: OipText.secondary)), Container( padding: const EdgeInsets.only(bottom: 5), child: Text(ExString.unitConverter(number), - style: TextStyle(fontSize: 22.sp)), + style: XFonts.numberSmall), ), Text(size >= 0 ? '大小:${formatSize(size)}' : info, style: - TextStyle(fontSize: 18.sp, color: XColors.doorDesGrey)), + XFonts.bodyMedium.copyWith(color: OipText.secondary)), ], ), )); @@ -755,7 +728,7 @@ class _WorkBenchPageState extends State { motion: const ScrollMotion(), children: [ SlidableAction( - backgroundColor: Colors.blue, + backgroundColor: OipBrand.primary, foregroundColor: Colors.white, icon: Icons.vertical_align_top, label: useAlays! ? "取消常用" : "设为常用", @@ -774,24 +747,33 @@ class _WorkBenchPageState extends State { ], ), child: Container( - color: Colors.white, - padding: EdgeInsets.symmetric(horizontal: 15.w), + color: OipSurface.card, + padding: EdgeInsets.symmetric(horizontal: 12.w), child: Container( - padding: EdgeInsets.symmetric(vertical: 7.h), - decoration: BoxDecoration( + padding: EdgeInsets.symmetric(vertical: 10.h), + decoration: const BoxDecoration( border: Border( bottom: - BorderSide(color: Colors.grey.shade300, width: 0.4))), + BorderSide(color: OipBorder.divider, width: 0.5))), child: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, children: [ ImageWidget( item.metadata.icon, - size: 50.w, - iconColor: const Color(0xFF9498df), + size: 52.w, + iconColor: OipEntity.application, + ), + SizedBox(height: 8.h), + Text( + item.name, + style: XFonts.labelLarge.copyWith( + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, ), - Text(item.name), ], ), ), diff --git a/lib/pages/relation/relation_page.dart b/lib/pages/relation/relation_page.dart index 0d5abe1bb9f16004bc18ec8be89ddbaa27d2c50b..a7fe2f5b657f64fe9da1995a7dc434a1a2d45e0e 100644 --- a/lib/pages/relation/relation_page.dart +++ b/lib/pages/relation/relation_page.dart @@ -30,10 +30,10 @@ import 'package:orginone/dart/core/thing/directory.dart'; import 'package:orginone/main.dart'; import 'package:orginone/components/ChatSessionWidget/ChatSessionWidget.dart'; import 'package:orginone/pages/portal/create_agent_page.dart'; -import 'package:orginone/pages/store/dba/collection_list_page.dart'; import 'package:orginone/routers/app_route.dart'; import 'package:orginone/routers/pages.dart'; import 'package:orginone/routers/router_const.dart'; +import 'package:provider/provider.dart'; import '../../components/XConsumer/XConsumer.dart'; @@ -63,6 +63,7 @@ class _RelationState extends State final List _subscriptions = []; // 好友列表加载去重标志,避免快速切换 Tab 时多次触发 loadMembers bool _loadingMembers = false; + bool _isRefreshing = false; _RelationState(); @@ -76,17 +77,51 @@ class _RelationState extends State datas = null; // 进入关系页面时按需刷新关系树(数据过期才刷新,新鲜则直接从内存渲染) relationCtrl.provider.ensureDataFresh(DataType.relation); - // 切换单位后清空 model,下次 build 重新 load() - final subId = command.subscribeByFlag('switchSpace', ([args]) { + // 关系页不跟随单位切换刷新数据,保持本地缓存优先策略, + // 加速页面渲染速度。单位切换只刷新门户页面数据。 + // 首次加载放到首帧后,避免在 build 中调度 setState 造成级联重建 + WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - setState(() { - relationModel = null; - _lastLoadedPath = null; - _lastLoadedData = null; - _loadingMembers = false; + _loadFromRoute(context); + }); + // 订阅 'session' flag:后台 deepLoad/ensureDataFresh 完成后会发射此事件, + // 通知关系页重建 model 拉取最新关系树数据(cohorts/storages/agents/companys)。 + // 对齐 AGENTS.md §18.2「事件通知:后台拉取完成后通过 command.emitter(flag) 通知订阅方刷新」 + final sessionSub = command.subscribeByFlag('session', ([dynamic args]) { + if (!mounted) return; + // 事件回调可能在 build 阶段被同步派发,直接 setState 会触发 + // "setState during build" 异常,因此将 setState + _loadFromRoute + // 一起放到首帧后执行。 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + // 重置 model 触发 _loadFromRoute 重建,从内存读取最新数据 + setState(() { + relationModel = null; + _lastLoadedPath = null; + _lastLoadedData = null; + }); + _loadFromRoute(context); }); - }, false); - _subscriptions.add(subId); + }); + _subscriptions.add(sessionSub); + } + + /// 根据当前 AppRoute 上下文加载 relationModel。 + /// 由 initState 首帧回调和 switchSpace 事件触发,禁止在 build 中调用。 + void _loadFromRoute(BuildContext context) { + final ar = context.read(); + final currentPath = ar.currPageData.path ?? ''; + final currentData = ar.currPageData.data; + final dataChanged = !identical(currentData, _lastLoadedData); + if (relationModel != null && + _lastLoadedPath == currentPath && + !dataChanged) { + return; + } + load(route: ar); + _lastLoadedPath = currentPath; + _lastLoadedData = currentData; + setState(() {}); } @override @@ -105,37 +140,49 @@ class _RelationState extends State final currentData = ar.currPageData.data; // 路径变化 或 数据引用变化(如点击单位进入二级页面,路径不变但 listDatas 变化)时触发重建 final dataChanged = !identical(currentData, _lastLoadedData); - if (relationModel == null || _lastLoadedPath != currentPath || dataChanged) { - final routeToLoad = ar; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - load(route: routeToLoad); - _lastLoadedPath = currentPath; - _lastLoadedData = currentData; - setState(() {}); - }); + if (relationModel == null || + _lastLoadedPath != currentPath || + dataChanged) { // 首次进入时 model 仍为 null,渲染骨架屏占位避免 TabContainerWidget 接收 null if (relationModel == null) { return const SkeletonWidget(itemCount: 6); } + // 已有 model 但路径/数据变化,先返回旧 model 避免白屏, + // 由 _loadFromRoute 在首帧后处理(initState 已订阅 switchSpace, + // 路径变化由路由跳转触发 Provider 通知,再由首帧回调刷新) + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _loadFromRoute(context); + }); } else { // 同步最新 datas(路由参数可能更新但路径未变) datas = ar.currPageData.data; } - return Column( - children: [ - Expanded( - child: TabContainerWidget( - relationModel!, - // 用 activeTabTitle + entity id 作为 key,确保切换子页面/默认 Tab 变化时 - // TabWidget 被强制重建,重新读取 DefaultTabController.initialIndex - key: ValueKey( - '${relationModel!.activeTabTitle ?? ''}_${_parentCompany?.id ?? 'root'}'), - getActions: _getActions, + final showRefreshBanner = _isRefreshing; + if (_isRefreshing) _isRefreshing = false; + // 统一下拉刷新交互:force=true 强制重新拉取关系树(user.deepLoad reload=true) + return RefreshIndicator( + onRefresh: () async { + await relationCtrl.provider + .ensureDataFresh(DataType.relation, force: true); + if (!mounted) return; + // 使用 State.context 而非 builder 闭包的 context,确保 mounted 检查正确守卫 + _loadFromRoute(this.context); + }, + child: Column( + children: [ + if (showRefreshBanner) XUi.refreshingBanner(), + Expanded( + child: TabContainerWidget( + relationModel!, + key: ValueKey( + '${relationModel!.activeTabTitle ?? ''}_${_parentCompany?.id ?? 'root'}'), + getActions: _getActions, + ), ), - ), - const GlobalAiInputBar(contextLabel: '关系'), - ], + const GlobalAiInputBar(contextLabel: '关系'), + ], + ), ); }); } @@ -219,8 +266,13 @@ class _RelationState extends State if (null == datas) { initDatas = getFirstLevelDirectories(title); } else if (datas is List) { - // "同事"和"成员"Tab 不从 datas 过滤,强制异步加载成员(显示骨架屏) - if (title != '同事' && title != '成员') { + // 单位子页面下,优先从 _parentCompany 内存读取对应分类数据填充 initDatas, + // 避免依赖路由传入的 listDatas(可能未加载完整)触发 FutureBuilder 转圈。 + // 本地优先 + 后台 fire-and-forget 刷新,由 command 事件通知 UI 重建。 + if (_parentCompany != null) { + initDatas = _loadCompanySubTabFromMemory(title, _parentCompany!); + } else if (title != '同事' && title != '成员') { + // 群组/部门/集群子页面:从 datas 过滤 initDatas = _filterDatas(title, datas); } } @@ -234,7 +286,8 @@ class _RelationState extends State if (title == '好友') { return loadFriends(); } - // 同事:加载单位所有成员 + // 同事:加载单位所有成员(本地优先:loadMembers(reload: false) + // 已加载过则直接返回内存数据,不会发起网络请求) if (title == '同事') { final company = _parentCompany; if (company == null) return []; @@ -257,8 +310,7 @@ class _RelationState extends State return []; }, onTap: (dynamic data, List children) { - if (data is XTarget && - data.typeName == TargetType.person.label) { + if (data is XTarget && data.typeName == TargetType.person.label) { RoutePages.jumpRelationMember(data: data); } else { _handleItemTap(data, children); @@ -279,6 +331,21 @@ class _RelationState extends State // - List:单位子页面 Tab 兜底加载(initDatas 为空时触发) // - ICompany/IPerson/IGroup 等:点击列表项后加载二级数据 if (null == parentData || parentData is List) { + // 单位子页面优先:从 _parentCompany 加载对应分类数据 + // 避免误返回用户个人数据(智能体/感知设备/群组/存储等 Tab + // 在单位子页面下应显示单位数据,而非个人数据) + if (_parentCompany != null) { + // "同事"Tab:加载单位所有成员(ContactListWidget 已处理, + // 此处保留兜底,ListWidget 不会被同事 Tab 触发) + if (title == '同事') { + try { + return await _parentCompany!.loadMembers(); + } catch (_) { + return []; + } + } + return _loadCompanySubTabData(title, _parentCompany!); + } // 一级 Tab 直接返回该分类的扁平关系列表 if (title == "好友") { return loadFriends(); @@ -298,21 +365,6 @@ class _RelationState extends State if (title == "单位") { return relationCtrl.user?.companys ?? []; } - // 单位子页面"同事"Tab:加载单位所有成员 - if (title == "同事") { - final company = _parentCompany; - if (company == null) return []; - try { - return await company.loadMembers(); - } catch (_) { - return []; - } - } - // 单位子页面其他 Tab 兜底:当 initDatas 为空触发 FutureBuilder 时, - // 从 _parentCompany 重新加载对应分类数据(避免 getDatas(List) 返回空) - if (_parentCompany != null) { - return _loadCompanySubTabData(title, _parentCompany!); - } dynamic param = RoutePages.getParentRouteParam(); if (param is IEntity) { return loadDirectories(param); @@ -372,7 +424,7 @@ class _RelationState extends State RoutePages.jumpRelationInfo(data: data); }, child: const IconWidget( - color: XColors.chatHintColors, + color: OipText.tertiary, iconData: Icons.info_outlined, ), ); @@ -385,6 +437,33 @@ class _RelationState extends State )); } + /// 单位子页面 Tab 内存数据加载(同步,本地优先): + /// 从 _parentCompany 已加载的内存数据中读取对应分类,立即填充 initDatas。 + /// 远端刷新由 _loadCompanySubTabData 在 FutureBuilder 兜底路径异步触发, + /// 刷新完成由 command 事件通知 UI 重建。 + List _loadCompanySubTabFromMemory(String title, ICompany company) { + switch (title) { + case '同事': + return company.members; + case '群组': + return loadGroups(company.typeName, company: company); + case '部门': + return loadInternalAgent(company, company: company); + case '集群': + return loadCohorts(company: company); + case '存储': + return loadStoreResources(company.typeName, company: company); + case '智能体': + return loadAgents(company.typeName, company: company); + case '感知设备': + // IoT 设备由 DeviceRepository 三层读取(内存→SQLite→远端), + // 此处返回空列表,让 FutureBuilder 触发异步加载(已是本地优先) + return []; + default: + return []; + } + } + /// 统一处理列表项点击(ListWidget 和 ContactListWidget 共用) void _handleItemTap(dynamic data, List children) { if (data is IAgent) { @@ -393,15 +472,13 @@ class _RelationState extends State builder: (context) => ChatSessionWidget(data: data.session), ), ); - } else if (data is XTarget && - data.typeName == TargetType.iotDevice.label) { + } else if (data is XTarget && data.typeName == TargetType.iotDevice.label) { Navigator.pushNamed( context, Routers.iotDeviceDetail, arguments: data.id, ); - } else if (data is XTarget && - data.typeName == TargetType.person.label) { + } else if (data is XTarget && data.typeName == TargetType.person.label) { // 同事 Tab 项(XTarget person):跳转成员详情页 RoutePages.jumpRelationMember(data: data); } else if (data is ICompany) { @@ -422,24 +499,47 @@ class _RelationState extends State /// 单位子页面 Tab 兜底异步加载:当 initDatas 为空触发 FutureBuilder 时, /// 根据 title 从 _parentCompany 重新加载对应分类数据。 /// 避免 getDatas(List) 走到 fallback 返回空列表。 + /// + /// 本地优先:先返回 _parentCompany 内存数据(已由 deepLoad/登录流程加载)。 + /// 若内存为空(首次进入且 deepLoad 未完成),后台 fire-and-forget 触发 + /// `company.deepLoad(reload: true)`,刷新完成由 command 事件通知 UI 重建, + /// 不阻塞当前 FutureBuilder(避免长时间转圈)。 Future> _loadCompanySubTabData( String title, ICompany company) async { - switch (title) { - case '群组': - return loadGroups(company.typeName, company: company); - case '部门': - return loadInternalAgent(company, company: company); - case '集群': - return loadCohorts(company: company); - case '存储': - return loadStoreResources(company.typeName, company: company); - case '智能体': - return loadAgents(company.typeName, company: company); - case '感知设备': - return await loadIotDevices(company: company); - default: - return []; + final memoryData = _loadCompanySubTabFromMemory(title, company); + if (memoryData.isNotEmpty) { + // 内存有数据:后台 fire-and-forget 刷新,刷新完成由 command 事件通知 UI 重建 + _triggerCompanyBgRefresh(company); + return memoryData; + } + // 内存为空:感知设备走 DeviceRepository 三层读取(本地优先) + if (title == '感知设备') { + final iotDevices = await loadIotDevices(company: company); + // 加载完成后后台触发 deepLoad 兜底 + _triggerCompanyBgRefresh(company); + return iotDevices; } + // 其他 Tab 内存为空:后台 fire-and-forget 触发 deepLoad, + // 当前先返回空列表(让 ListWidget 显示空态),deepLoad 完成后由 command 事件通知 UI 重建 + _triggerCompanyBgRefresh(company); + return memoryData; + } + + /// 后台 fire-and-forget 触发单位 deepLoad,刷新完成由 command 事件通知 UI 重建。 + /// 不阻塞当前 FutureBuilder,避免长时间转圈。 + void _triggerCompanyBgRefresh(ICompany company) { + if ((company as Company).departmentLoaded) return; + Future(() async { + try { + await company.deepLoad(reload: true); + // deepLoad 完成后通过 command 事件通知 UI 重建 + if (mounted) { + command.emitterFlag('session'); + } + } catch (_) { + // 静默失败,保留本地缓存 + } + }); } /// typeName → 友好页签标题映射(单位内部页签:群组/部门/集群/存储/智能体/感知设备) @@ -578,9 +678,21 @@ class _RelationState extends State } ///加载数据资源 + /// + /// 本地优先策略:直接返回,不阻塞 UI。 + /// 单位 deepLoad 已在登录流程 / HomePage._lazyDeepLoad 中执行过, + /// 此处仅作为兜底触发后台 fire-and-forget 刷新,不再 EasyLoading 阻塞。 Future loadSpaceData(ICompany? company) async { + if (company == null) return; if ((company as Company).departmentLoaded == false) { - await relationCtrl.user?.loadSpaceData(company); + // 后台异步刷新,不阻塞 UI,刷新完成由 command 事件通知 UI 重建 + Future(() async { + try { + await relationCtrl.user?.loadSpaceData(company); + } catch (_) { + // 静默失败,保留本地缓存 + } + }); } } @@ -635,10 +747,14 @@ class _RelationState extends State } } - /// 加载用户添加的全部感知设备(跨空间聚合,spaceId 为空表示全部) + /// 加载个人设立的感知设备(spaceId = 用户个人空间 ID) Future> loadAllIotDevices() async { try { - final devices = await deviceRepository.listDevices(pageSize: 200); + final personalSpaceId = relationCtrl.user?.metadata.id; + final devices = await deviceRepository.listDevices( + spaceId: personalSpaceId, + pageSize: 200, + ); return devices.map((device) { final target = XTarget(id: device.id); target.name = device.name; @@ -669,16 +785,19 @@ class _RelationState extends State ?.findCompany(companyId ?? RoutePages.getRootRouteParam().id); } - /// 加载组织 + /// 加载集群:单位设立的集群 + 加入的集群(去重) List loadCohorts({ICompany? company}) { - return company?.shareTarget - .where((element) => element.typeName == TargetType.group.label) - .toList() ?? - getCurrentCompany() - ?.shareTarget - .where((element) => element.typeName == TargetType.group.label) - .toList() ?? - []; + final c = company ?? getCurrentCompany(); + if (c == null) return []; + // 设立的集群 + 加入的集群,按 id 去重 + final seen = {}; + final result = []; + for (final g in [...c.establishedGroups, ...c.groups]) { + if (seen.add(g.id)) { + result.add(g); + } + } + return result; } /// 加载内设机构 @@ -695,7 +814,7 @@ class _RelationState extends State padding: EdgeInsets.symmetric(horizontal: 4.w), child: Icon( Icons.more_horiz, - color: XColors.chatHintColors, + color: OipText.tertiary, size: 22.w, ), ), @@ -709,9 +828,9 @@ class _RelationState extends State void _showStorageActionSheet(IStorage storage) { showModalBottomSheet( context: context, - backgroundColor: Colors.white, + backgroundColor: OipSurface.card, shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(12)), + borderRadius: BorderRadius.vertical(top: Radius.circular(OipRadius.lg)), ), builder: (sheetCtx) { return SafeArea( @@ -722,17 +841,15 @@ class _RelationState extends State padding: EdgeInsets.symmetric(vertical: 12.h), child: Text( storage.name, - style: TextStyle( - fontSize: 14.sp, - color: XColors.black3, - fontWeight: FontWeight.w600, - ), + style: XFonts.labelSmall.copyWith( + color: OipText.primary, fontWeight: FontWeight.w600), ), ), - Divider(height: 1, color: XColors.chatHintColors.withValues(alpha: 0.2)), + const Divider(height: 1, color: OipBorder.divider), ListTile( - leading: Icon(Icons.folder_open_outlined, size: 22.w, color: OipBrand.primary), - title: Text('查看数据集', style: TextStyle(fontSize: 15.sp)), + leading: Icon(Icons.folder_open_outlined, + size: 22.w, color: OipBrand.primary), + title: Text('查看数据集', style: XFonts.bodyMedium), onTap: () { Navigator.pop(sheetCtx); _openStorageCollection(storage); @@ -740,16 +857,19 @@ class _RelationState extends State ), if (!storage.isActivate) ListTile( - leading: Icon(Icons.check_circle_outline, size: 22.w, color: OipState.success), - title: Text('激活存储', style: TextStyle(fontSize: 15.sp)), + leading: Icon(Icons.check_circle_outline, + size: 22.w, color: OipState.success), + title: Text('激活存储', style: XFonts.bodyMedium), onTap: () { Navigator.pop(sheetCtx); _activateStorage(storage); }, ), ListTile( - leading: Icon(Icons.logout_outlined, size: 22.w, color: OipState.error), - title: Text('退出存储', style: TextStyle(fontSize: 15.sp, color: OipState.error)), + leading: Icon(Icons.logout_outlined, + size: 22.w, color: OipState.error), + title: Text('退出存储', + style: XFonts.bodyMedium.copyWith(color: OipState.error)), onTap: () { Navigator.pop(sheetCtx); _exitStorage(storage); @@ -772,12 +892,8 @@ class _RelationState extends State setState(() {}); } if (!mounted) return; - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => CollectionListPage(storage: storage), - ), - ); + // 走路由封装,符合 AGENTS.md §3 红线 + RoutePages.jumpCollectionListPage(context, storage); } /// 激活存储 @@ -800,8 +916,12 @@ class _RelationState extends State title: const Text('确认退出'), content: Text('确定要退出存储"${storage.name}"吗?'), actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), - TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定')), + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('取消')), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('确定')), ], ), ); @@ -828,7 +948,7 @@ class _RelationState extends State padding: EdgeInsets.symmetric(horizontal: 4.w), child: Icon( Icons.more_horiz, - color: XColors.chatHintColors, + color: OipText.tertiary, size: 22.w, ), ), @@ -842,9 +962,9 @@ class _RelationState extends State void _showAgentActionSheet(IAgent agent) { showModalBottomSheet( context: context, - backgroundColor: Colors.white, + backgroundColor: OipSurface.card, shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(12)), + borderRadius: BorderRadius.vertical(top: Radius.circular(OipRadius.lg)), ), builder: (sheetCtx) { return SafeArea( @@ -855,45 +975,47 @@ class _RelationState extends State padding: EdgeInsets.symmetric(vertical: 12.h), child: Text( agent.name, - style: TextStyle( - fontSize: 14.sp, - color: XColors.black3, - fontWeight: FontWeight.w600, - ), + style: XFonts.labelSmall.copyWith( + color: OipText.primary, fontWeight: FontWeight.w600), ), ), - Divider(height: 1, color: XColors.chatHintColors.withValues(alpha: 0.2)), + const Divider(height: 1, color: OipBorder.divider), ListTile( - leading: Icon(Icons.chat_bubble_outline, size: 22.w, color: OipBrand.primary), - title: Text('打开会话', style: TextStyle(fontSize: 15.sp)), + leading: Icon(Icons.chat_bubble_outline, + size: 22.w, color: OipBrand.primary), + title: Text('打开会话', style: XFonts.bodyMedium), onTap: () { Navigator.pop(sheetCtx); Navigator.of(context).push( MaterialPageRoute( - builder: (context) => ChatSessionWidget(data: agent.session), + builder: (context) => + ChatSessionWidget(data: agent.session), ), ); }, ), ListTile( - leading: Icon(Icons.add_circle_outline, size: 22.w, color: OipState.success), - title: Text('创建智能体', style: TextStyle(fontSize: 15.sp)), + leading: Icon(Icons.add_circle_outline, + size: 22.w, color: OipState.success), + title: Text('创建智能体', style: XFonts.bodyMedium), onTap: () { Navigator.pop(sheetCtx); _createAgent(); }, ), ListTile( - leading: Icon(Icons.group_add_outlined, size: 22.w, color: OipBrand.primary), - title: Text('加入智能体', style: TextStyle(fontSize: 15.sp)), + leading: Icon(Icons.group_add_outlined, + size: 22.w, color: OipBrand.primary), + title: Text('加入智能体', style: XFonts.bodyMedium), onTap: () { Navigator.pop(sheetCtx); _joinAgent(); }, ), ListTile( - leading: Icon(Icons.info_outline, size: 22.w, color: XColors.chatHintColors), - title: Text('查看详情', style: TextStyle(fontSize: 15.sp)), + leading: Icon(Icons.info_outline, + size: 22.w, color: OipText.tertiary), + title: Text('查看详情', style: XFonts.bodyMedium), onTap: () { Navigator.pop(sheetCtx); RoutePages.jumpRelationInfo(data: agent); diff --git a/lib/pages/store/dba/collection_content_page.dart b/lib/pages/store/dba/collection_content_page.dart index 9c44299a5a9192e6504d4b368fd5378abd756bf1..303ae1908a41cb19bef0d2ebf2e3586950081d45 100644 --- a/lib/pages/store/dba/collection_content_page.dart +++ b/lib/pages/store/dba/collection_content_page.dart @@ -241,7 +241,7 @@ class _CollectionContentPageState extends State { child: SingleChildScrollView( child: SelectableText( const JsonEncoder.withIndent(' ').convert(restored), - style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + style: XFonts.caption.copyWith(fontFamily: 'monospace'), ), ), ), @@ -281,13 +281,19 @@ class _CollectionContentPageState extends State { title: Row( mainAxisSize: MainAxisSize.min, children: [ - Flexible(child: Text(widget.coll.collName, overflow: TextOverflow.ellipsis)), + Flexible( + child: Text( + widget.coll.collName, + style: XFonts.titleMedium, + overflow: TextOverflow.ellipsis, + ), + ), if (widget.coll.collId != widget.coll.collName) Padding( padding: const EdgeInsets.only(left: 4), child: Text( '(${widget.coll.collId})', - style: const TextStyle(fontSize: 12, color: XColors.chatHintColors), + style: XFonts.caption, ), ), if (widget.coll.count != null) @@ -301,7 +307,7 @@ class _CollectionContentPageState extends State { ), child: Text( '${widget.coll.count} 个文档', - style: const TextStyle(fontSize: 11, color: OipBrand.primary), + style: XFonts.labelSmall.copyWith(color: OipBrand.primary), ), ), ), @@ -328,9 +334,9 @@ class _CollectionContentPageState extends State { ], ), body: _loading && _documents.isEmpty - ? const Center(child: CircularProgressIndicator()) + ? XUi.loadingView(message: '数据加载中') : _documents.isEmpty - ? const Center(child: Text('暂无数据')) + ? XUi.emptyState(message: '暂无数据') : Column( children: [ Expanded(child: _buildDataTable()), @@ -355,18 +361,15 @@ class _CollectionContentPageState extends State { horizontalMargin: 12, columns: [ ...displayCols.map((col) => DataColumn( - label: Text( - col, - style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold), - ), + label: Text(col, style: XFonts.labelMedium.copyWith(fontWeight: FontWeight.w700)), )), - const DataColumn(label: Text('操作')), + DataColumn(label: Text('操作', style: XFonts.labelMedium.copyWith(fontWeight: FontWeight.w700))), ], rows: _documents.map((doc) { return DataRow(cells: [ ...displayCols.map((col) { if (col == '...') { - return const DataCell(Text('...', style: TextStyle(fontSize: 12))); + return DataCell(Text('...', style: XFonts.caption)); } final val = doc[col]; String display; @@ -378,7 +381,7 @@ class _CollectionContentPageState extends State { display = val.toString(); } return DataCell( - Text(display, style: const TextStyle(fontSize: 12)), + Text(display, style: XFonts.caption), onTap: () => _viewJson(doc), ); }), @@ -418,7 +421,7 @@ class _CollectionContentPageState extends State { ) : TextButton( onPressed: _loadData, - child: const Text('加载更多'), + child: Text('加载更多', style: XFonts.brandMedium), ), ); } diff --git a/lib/pages/store/dba/collection_list_page.dart b/lib/pages/store/dba/collection_list_page.dart index d2d815238ce0e1426fe43038c68de960107ec51f..79b4edd997b3fd19c50f7bd194772f0870520fab 100644 --- a/lib/pages/store/dba/collection_list_page.dart +++ b/lib/pages/store/dba/collection_list_page.dart @@ -4,14 +4,18 @@ import 'package:orginone/config/theme/unified_style.dart'; import 'package:orginone/dart/base/schema.dart'; import 'package:orginone/dart/core/target/outTeam/istorage.dart'; import 'package:orginone/components/Tip/ToastUtils.dart'; +import 'package:orginone/routers/pages.dart'; import 'collection_content_page.dart'; import 'create_collection_dialog.dart'; /// 数据库集合列表页面 +/// 支持两种构造方式: +/// 1. 直接传入 storage(保留兼容) +/// 2. 通过路由跳转,从 RoutePages.routeData.currPageData.data 获取 storage class CollectionListPage extends StatefulWidget { - final IStorage storage; + final IStorage? storage; - const CollectionListPage({super.key, required this.storage}); + const CollectionListPage({super.key, this.storage}); @override State createState() => _CollectionListPageState(); @@ -20,17 +24,32 @@ class CollectionListPage extends StatefulWidget { class _CollectionListPageState extends State { List _collections = []; bool _loading = true; + IStorage? _resolvedStorage; + + IStorage? get _storage => widget.storage ?? _resolvedStorage; @override void initState() { super.initState(); + // 路由跳转场景下从 RoutePages 获取 storage + _resolvedStorage = RoutePages.routeData.currPageData.data is IStorage + ? RoutePages.routeData.currPageData.data as IStorage + : null; _loadCollections(); } Future _loadCollections() async { + final storage = _storage; + if (storage == null) { + if (mounted) { + setState(() => _loading = false); + ToastUtils.showMsg(msg: '未找到可用的存储'); + } + return; + } setState(() => _loading = true); try { - final data = await widget.storage.dataManager.loadCollections(); + final data = await storage.dataManager.loadCollections(); if (mounted) { setState(() { _collections = data; @@ -46,13 +65,15 @@ class _CollectionListPageState extends State { } Future _createCollection() async { + final storage = _storage; + if (storage == null) return; final result = await showDialog( context: context, builder: (context) => const CreateCollectionDialog(), ); if (result != null) { try { - final coll = await widget.storage.dataManager.createColl(result); + final coll = await storage.dataManager.createColl(result); if (coll != null) { ToastUtils.showMsg(msg: '集合创建成功'); _loadCollections(); @@ -66,6 +87,8 @@ class _CollectionListPageState extends State { } Future _deleteCollection(XCollInfo coll) async { + final storage = _storage; + if (storage == null) return; final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( @@ -103,9 +126,9 @@ class _CollectionListPageState extends State { alias: coll.collName, typeName: '', ); - await widget.storage.dataManager.removeColl(defined); + await storage.dataManager.removeColl(defined); final success = - await widget.storage.dataManager.drop(coll.collId); + await storage.dataManager.drop(coll.collId); if (success) { ToastUtils.showMsg(msg: '集合删除成功'); _loadCollections(); @@ -119,11 +142,13 @@ class _CollectionListPageState extends State { } void _openCollection(XCollInfo coll) { + final storage = _storage; + if (storage == null) return; Navigator.push( context, MaterialPageRoute( builder: (context) => CollectionContentPage( - storage: widget.storage, + storage: storage, coll: coll, ), ), @@ -135,7 +160,10 @@ class _CollectionListPageState extends State { return Scaffold( backgroundColor: XColors.white, appBar: AppBar( - title: Text('${widget.storage.name} - 数据库'), + title: Text( + '${_storage?.name ?? '数据库'} - 数据库', + style: XFonts.titleMedium, + ), backgroundColor: XColors.white, elevation: 0.5, actions: [ @@ -152,9 +180,9 @@ class _CollectionListPageState extends State { ], ), body: _loading - ? const Center(child: CircularProgressIndicator()) + ? XUi.loadingView(message: '数据加载中') : _collections.isEmpty - ? const Center(child: Text('暂无集合数据')) + ? XUi.emptyState(message: '暂无集合数据') : ListView.separated( itemCount: _collections.length, separatorBuilder: (_, __) => const Divider(height: 1), @@ -174,7 +202,7 @@ class _CollectionListPageState extends State { Expanded( child: Text( coll.collName, - style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500), + style: XFonts.bodyLarge, overflow: TextOverflow.ellipsis, ), ), @@ -189,8 +217,7 @@ class _CollectionListPageState extends State { ), child: Text( coll.collTypeName, - style: TextStyle( - fontSize: 11, + style: XFonts.labelSmall.copyWith( color: _getTypeColor(coll.collTypeName), ), ), @@ -201,20 +228,20 @@ class _CollectionListPageState extends State { children: [ Text( coll.collId, - style: const TextStyle(fontSize: 12, color: XColors.chatHintColors), + style: XFonts.caption, overflow: TextOverflow.ellipsis, ), const SizedBox(width: 12), if (coll.count != null) Text( '${coll.count} 个文档', - style: const TextStyle(fontSize: 12, color: XColors.chatHintColors), + style: XFonts.caption, ), if (coll.totalSize != null) ...[ const SizedBox(width: 8), Text( _formatSize(coll.totalSize!), - style: const TextStyle(fontSize: 12, color: XColors.chatHintColors), + style: XFonts.caption, ), ], ], diff --git a/lib/pages/store/dba/create_collection_dialog.dart b/lib/pages/store/dba/create_collection_dialog.dart index 8fbbbe0214bd32e4f09a78c5843e4059d61af44f..404cf9be9c5f0edc33e2787168d0b722e2dcf40f 100644 --- a/lib/pages/store/dba/create_collection_dialog.dart +++ b/lib/pages/store/dba/create_collection_dialog.dart @@ -63,7 +63,7 @@ class _CreateCollectionDialogState extends State { ), const SizedBox(height: 16), DropdownButtonFormField( - value: _typeName, + initialValue: _typeName, decoration: const InputDecoration( labelText: '集合类型', border: OutlineInputBorder(), diff --git a/lib/pages/store/dba/create_index_dialog.dart b/lib/pages/store/dba/create_index_dialog.dart index 66a15cac3af098fc0b3736f0fc15c94772db5668..78dcf38d302e7afc5e0cb9f07e430b3242856153 100644 --- a/lib/pages/store/dba/create_index_dialog.dart +++ b/lib/pages/store/dba/create_index_dialog.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:orginone/components/Tip/ToastUtils.dart'; +import 'package:orginone/config/theme/unified_style.dart'; /// 创建索引对话框 class CreateIndexDialog extends StatefulWidget { @@ -45,7 +46,7 @@ class _CreateIndexDialogState extends State { ), ), const SizedBox(height: 16), - const Text('索引字段', style: TextStyle(fontWeight: FontWeight.w500)), + Text('索引字段', style: XFonts.labelMedium), const SizedBox(height: 8), ..._fields.asMap().entries.map((entry) { final i = entry.key; @@ -69,7 +70,7 @@ class _CreateIndexDialogState extends State { Expanded( flex: 2, child: DropdownButtonFormField( - value: field.sort, + initialValue: field.sort, decoration: const InputDecoration( border: OutlineInputBorder(), isDense: true, @@ -116,7 +117,7 @@ class _CreateIndexDialogState extends State { child: CheckboxListTile( value: _unique, onChanged: (v) => setState(() => _unique = v ?? false), - title: const Text('唯一索引', style: TextStyle(fontSize: 13)), + title: Text('唯一索引', style: XFonts.caption), contentPadding: EdgeInsets.zero, controlAffinity: ListTileControlAffinity.leading, dense: true, @@ -126,7 +127,7 @@ class _CreateIndexDialogState extends State { child: CheckboxListTile( value: _sparse, onChanged: (v) => setState(() => _sparse = v ?? false), - title: const Text('稀疏索引', style: TextStyle(fontSize: 13)), + title: Text('稀疏索引', style: XFonts.caption), contentPadding: EdgeInsets.zero, controlAffinity: ListTileControlAffinity.leading, dense: true, diff --git a/lib/pages/store/dba/index_manage_page.dart b/lib/pages/store/dba/index_manage_page.dart index f9fc2f07895fb1ca346655197f2facdf83d3242b..bb2eaf4324648280dfc06ffccb8fe6b9b7e11d70 100644 --- a/lib/pages/store/dba/index_manage_page.dart +++ b/lib/pages/store/dba/index_manage_page.dart @@ -119,7 +119,10 @@ class _IndexManagePageState extends State { return Scaffold( backgroundColor: XColors.white, appBar: AppBar( - title: Text('管理索引 - ${widget.coll.collName}'), + title: Text( + '管理索引 - ${widget.coll.collName}', + style: XFonts.titleMedium, + ), backgroundColor: XColors.white, elevation: 0.5, actions: [ @@ -131,9 +134,9 @@ class _IndexManagePageState extends State { ], ), body: _loading - ? const Center(child: CircularProgressIndicator()) + ? XUi.loadingView(message: '数据加载中') : _indexes.isEmpty - ? const Center(child: Text('暂无索引')) + ? XUi.emptyState(message: '暂无索引') : ListView.separated( itemCount: _indexes.length, separatorBuilder: (_, __) => const Divider(height: 1), @@ -164,9 +167,8 @@ class _IndexManagePageState extends State { Expanded( child: Text( name, - style: TextStyle( + style: XFonts.caption.copyWith( fontFamily: 'monospace', - fontSize: 13, color: isDefault ? Colors.grey : Colors.black87, ), overflow: TextOverflow.ellipsis, @@ -180,8 +182,8 @@ class _IndexManagePageState extends State { color: Colors.green.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(3), ), - child: const Text('唯一', - style: TextStyle(fontSize: 10, color: Colors.green)), + child: Text('唯一', + style: XFonts.labelSmall.copyWith(color: Colors.green)), ), if (isSparse) Container( @@ -191,14 +193,14 @@ class _IndexManagePageState extends State { color: Colors.orange.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(3), ), - child: const Text('稀疏', - style: TextStyle(fontSize: 10, color: Colors.orange)), + child: Text('稀疏', + style: XFonts.labelSmall.copyWith(color: Colors.orange)), ), ], ), subtitle: _buildKeyWidget(key), trailing: isDefault - ? const Text('-', style: TextStyle(color: Colors.grey)) + ? Text('-', style: XFonts.caption.copyWith(color: Colors.grey)) : IconButton( icon: const Icon(Icons.delete_outline, size: 18, color: Colors.redAccent), @@ -208,8 +210,8 @@ class _IndexManagePageState extends State { } Widget _buildKeyWidget(dynamic key) { - if (key == null) return const Text('-'); - if (key is! Map) return Text(key.toString()); + if (key == null) return Text('-', style: XFonts.caption); + if (key is! Map) return Text(key.toString(), style: XFonts.caption); final List tags = []; for (final entry in key.entries) { @@ -223,7 +225,7 @@ class _IndexManagePageState extends State { ), child: Text( '${entry.key}: $sortLabel', - style: const TextStyle(fontSize: 10, color: Colors.blue), + style: XFonts.labelSmall.copyWith(color: Colors.blue), ), )); } diff --git a/lib/pages/store/store_page.dart b/lib/pages/store/store_page.dart index d5e6c6ea6279378fb8a9256bf89e7bc836bc4101..a3879ad4c8e89f1165162ef43d3c56ade1905ceb 100644 --- a/lib/pages/store/store_page.dart +++ b/lib/pages/store/store_page.dart @@ -3,7 +3,8 @@ import 'package:orginone/components/XImage/components/icon.dart'; import 'package:orginone/components/TabContainerWidget/TabContainerWidget.dart'; import 'package:orginone/components/TabContainerWidget/types.dart'; import 'package:orginone/components/ListWidget/ListWidget.dart'; -import 'package:orginone/config/theme/unified_style.dart'; +import 'package:orginone/components/LoadingWidget/LoadingWidget.dart'; +import 'package:orginone/config/oip_tokens.dart'; import 'package:orginone/dart/base/schema.dart'; import 'package:orginone/dart/controller/data_freshness_tracker.dart'; import 'package:orginone/dart/core/public/entity.dart'; @@ -22,11 +23,10 @@ import 'package:orginone/dart/core/work/index.dart'; import 'package:orginone/main.dart'; import 'package:orginone/routers/app_route.dart'; import 'package:orginone/routers/pages.dart'; +import 'package:provider/provider.dart'; -import '../../components/CommandWidget/index.dart'; import '../../components/XConsumer/XConsumer.dart'; import '../../dart/base/common/commands.dart'; -import 'dba/collection_list_page.dart'; /// 数据页面 class StorePage extends StatefulWidget { @@ -42,61 +42,91 @@ class StorePage extends StatefulWidget { State createState() => _StorePageState(); } -class _StorePageState extends State { +class _StorePageState extends State + with AutomaticKeepAliveClientMixin { dynamic parentDataCurr; final ScrollController _scrollController = ScrollController(); late TabContainerModel? storeModel; late dynamic datas; late String parentDirtoryName; - // 跟踪上次加载路径,避免每次 Provider/Command 通知都重建 storeModel String? _lastLoadedPath; + final List _subscriptions = []; _StorePageState(); + @override + bool get wantKeepAlive => true; + @override void initState() { super.initState(); storeModel = null; datas = null; - // 进入发现页面时按需刷新存储与动态数据(数据过期才刷新,新鲜则直接从内存渲染) relationCtrl.provider.ensureDataFresh(DataType.storage); relationCtrl.provider.ensureDataFresh(DataType.activities); + // 订阅后台数据刷新事件:登录加速场景下 relationCtrl.user 在后台异步加载, + // 加载完成后 provider 会触发 'session' 事件,此处订阅以刷新首屏 + // target=false 避免订阅时立即触发(initState 阶段 context 尚未稳定) + _subscriptions.add(command.subscribeByFlag('session', ([args]) { + if (!mounted) return; + // 强制重新加载,让 getFirstLevelDirectories 拿到最新内存数据 + _lastLoadedPath = null; + _loadFromRoute(context); + }, false)); + // 存储页不跟随单位切换刷新数据,保持本地缓存优先策略, + // 加速页面渲染速度。单位切换只刷新门户页面数据。 + // 首次加载放到首帧后,避免在 build 中调度 setState 造成级联重建 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _loadFromRoute(context); + }); } - @override - void didUpdateWidget(StorePage oldWidget) { - super.didUpdateWidget(oldWidget); + /// 根据当前 AppRoute 上下文加载 storeModel。 + /// 由 initState 首帧回调和 switchSpace 事件触发,禁止在 build 中调用。 + void _loadFromRoute(BuildContext context) { + final ar = context.read(); + final currentPath = ar.currPageData.path ?? ''; + if (storeModel != null && _lastLoadedPath == currentPath) return; + load(); + _lastLoadedPath = currentPath; + setState(() {}); } @override void dispose() { + for (final id in _subscriptions) { + command.unsubscribeByFlag(id); + } _scrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { + super.build(context); return XConsumer( builder: (context, ar, child) { datas = ar.currPageData.data; - // 同步 parentDataCurr:导航返回时从路由数据恢复 if (ar.currPageData.entity != null) { parentDataCurr = ar.currPageData.entity; } else if (datas == null) { parentDataCurr = null; } - return CommandWidget( - type: 'store', - cmd: 'refresh', - builder: (context, args) { - final currentPath = ar.currPageData.path ?? ''; - // 仅在 storeModel 为空 或 路由路径变化时才重新 load,避免命令通知触发无谓重建 - if (storeModel == null || _lastLoadedPath != currentPath) { - load(); - _lastLoadedPath = currentPath; - } - return TabContainerWidget(storeModel!); - }); + final currentPath = ar.currPageData.path ?? ''; + // 路径变化时调度首帧后加载,避免在 build 中调用 setState + if (storeModel == null || _lastLoadedPath != currentPath) { + if (storeModel == null) { + return const SkeletonWidget(itemCount: 6); + } + // storeModel 已有数据但路径变化(如点击列表项进入二级页面), + // 由 _loadFromRoute 在首帧后处理;这里先返回旧 model 避免白屏 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _loadFromRoute(context); + }); + } + return TabContainerWidget(storeModel!); }, ); } @@ -151,7 +181,7 @@ class _StorePageState extends State { ].contains(parentDataCurr.name) && parentDataCurr is Directory) { parentDirtoryName = parentDataCurr.name; - return loadSystemDirectory2(parentDataCurr); + return loadSystemDirectory(parentDataCurr); } else if (parentDataCurr.typeName == SpaceEnum.applications.label && parentDataCurr is Application) { @@ -206,13 +236,12 @@ class _StorePageState extends State { SpaceEnum.code.label, DirectoryType.mirror.label, DirectoryType.model.label, - DirectoryType.form.label, DirectoryType.pageTemplate.label, ].contains(parentDataCurr.name)) { // dict species property work app dataStandard storage parentDirtoryName = parentDataCurr.name; //系统文件类型--子目录 - return loadFixedFiles2(parentDataCurr); + return loadFixedFiles(parentDataCurr); } else if (parentDataCurr.typeName == SpaceEnum.directory.label && parentDataCurr.name == DirectoryType.app.label && parentDataCurr is FixedDirectory) { @@ -317,7 +346,6 @@ class _StorePageState extends State { SpaceEnum.code.label, DirectoryType.mirror.label, DirectoryType.model.label, - DirectoryType.form.label, DirectoryType.pageTemplate.label, ].contains(parentData.name)) { // dict species property work app dataStandard storage @@ -356,7 +384,7 @@ class _StorePageState extends State { RoutePages.jumpStoreInfoPage(context, data: data); }, child: const IconWidget( - color: XColors.chatHintColors, + color: OipText.disabled, iconData: Icons.info_outlined, ), ); @@ -381,7 +409,7 @@ class _StorePageState extends State { radius: 0, onTap: () {}, child: const IconWidget( - color: XColors.chatHintColors, + color: OipText.disabled, iconData: Icons.info_outlined, ), ); @@ -444,13 +472,8 @@ class _StorePageState extends State { } if (storage != null && mounted) { - final s = storage; - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => CollectionListPage(storage: s), - ), - ); + // 走路由封装,符合 AGENTS.md §3 红线"不要绕开路由封装" + RoutePages.jumpCollectionListPage(context, storage); } } @@ -513,38 +536,15 @@ class _StorePageState extends State { return datas; } - /// 加载系统目录 - List loadSystemDirectory(T item) { + /// 加载系统目录(合并自 loadSystemDirectory / loadSystemDirectory2) + /// item 可以是 Directory / Application / StandardFileInfo 等具有 id/target/name 属性的实体 + List loadSystemDirectory(dynamic item) { List datas = []; XDirectory tmpDir; int id = 0; String type = item is Application ? item.typeName : item.name; if (null != relationCtrl.user) { DirectoryGroupType.getType(type)?.types.forEach((e) { - // LogUtil.i("loadSystemDirectory=id:${item.id}"); - tmpDir = XDirectory( - id: "${item.id}_$id", - directoryId: "${item.id}_$id", - isDeleted: false); - tmpDir.name = e.label; - tmpDir.typeName = SpaceEnum.directory.label; - datas.add(FixedDirectory(tmpDir, item.target, - standard: getCurrentCompany()?.standard)); - id++; - }); - } - return datas; - } - - /// 加载系统目录 - List loadSystemDirectory2(Directory item) { - List datas = []; - XDirectory tmpDir; - int id = 0; - String type = item is Application ? item.typeName : item.name; - if (null != relationCtrl.user) { - DirectoryGroupType.getType(type)?.types.forEach((e) { - // LogUtil.i("loadSystemDirectory=id:${item.id}"); tmpDir = XDirectory( id: "${item.id}_$id", directoryId: "${item.id}_$id", @@ -624,17 +624,10 @@ class _StorePageState extends State { ///加载表单 Future> loadForms(FixedDirectory item) async { - // List files = []; - // LogUtil.e('>>>>>>======loadForms ${item.id}'); - // LogUtil.e('>>>>>>======loadForms ${item.belongApplication?.id}'); - - // files = await item.standard - // .loadForms(reload: true, applicationId: item.belongApplication?.id); // - - // files = files.where((e) => !e.groupTags.contains("已删除")).toList(); - - // LogUtil.e('>>>>>>======loadForms 加载完成'); - // return files; + // belongApplication 为空时(如 loadStoragesDirectory 误入此分支)兜底返回空 + if (item.belongApplication == null) { + return []; + } List files = []; files = await item.belongApplication!.loadAllForms(); // @@ -658,6 +651,10 @@ class _StorePageState extends State { ///加载办事 Future> loadWorks(FixedDirectory item) async { + // belongApplication 为空时兜底返回空,避免强制解包抛异常 + if (item.belongApplication == null) { + return []; + } List works = await item.belongApplication!.loadWorks(); //过滤已删除目录 @@ -665,9 +662,9 @@ class _StorePageState extends State { return works; } - //加载应用子目录 - Future> loadFixedFiles2/*(T target)*/( - Directory target) async { + /// 加载应用子目录(合并自 loadFixedFiles / loadFixedFiles2) + /// 优先从内存缓存读取,缓存未命中才网络加载 + Future> loadFixedFiles(IDirectory target) async { List directorys = target.children; if (directorys.isEmpty) { await target.loadContent(); @@ -678,41 +675,13 @@ class _StorePageState extends State { //加载文件 List files = target.files; if (files.isEmpty) { - // await target.loadContent(); await target.loadFiles(); files = target.files; } - //过滤非文件类型数据 + //合并目录和文件 List entitys = []; entitys.addAll(directorys); entitys.addAll(files); - // directorys = _filterDeleteed(directorys); - - return entitys; - } - - //加载应用子目录 - Future> loadFixedFiles(T target) async { - List directorys = target.children; - if (directorys.isEmpty) { - await target.loadContent(); - directorys = target.children; - } - //过滤已删除目录 - directorys = directorys.where((e) => !e.groupTags.contains("已删除")).toList(); - //加载文件 - List files = target.files; - if (files.isEmpty) { - // await target.loadContent(); - await target.loadFiles(); - files = target.files; - } - //过滤非文件类型数据 - List entitys = []; - entitys.addAll(directorys); - entitys.addAll(files); - // directorys = _filterDeleteed(directorys); - return entitys; } diff --git a/lib/pages/work/work_page.dart b/lib/pages/work/work_page.dart index 880782276f1cd86cec386068839e2423606dfb3a..e7277874d982cc9b263f9147bc39e0919029d46e 100644 --- a/lib/pages/work/work_page.dart +++ b/lib/pages/work/work_page.dart @@ -7,146 +7,176 @@ import 'package:orginone/components/LoadingWidget/LoadingWidget.dart'; import 'package:orginone/components/TabContainerWidget/TabContainerWidget.dart'; import 'package:orginone/components/TabContainerWidget/types.dart'; import 'package:orginone/config/theme/unified_style.dart'; -import 'package:orginone/dart/base/schema.dart'; +import 'package:orginone/dart/base/common/commands.dart'; import 'package:orginone/dart/controller/data_freshness_tracker.dart'; -import 'package:orginone/dart/controller/index.dart'; import 'package:orginone/dart/core/public/consts.dart'; import 'package:orginone/dart/core/public/entity.dart'; -import 'package:orginone/dart/core/public/enums.dart'; -import 'package:orginone/dart/core/target/base/target.dart'; -import 'package:orginone/dart/core/target/outTeam/istorage.dart'; -import 'package:orginone/dart/core/thing/directory.dart'; import 'package:orginone/dart/core/work/index.dart'; import 'package:orginone/dart/core/work/task.dart'; import 'package:orginone/main.dart'; import 'package:orginone/routers/pages.dart'; import 'package:orginone/utils/date_util.dart'; -import '../../components/CommandWidget/index.dart'; -import '../../components/XStatefulWidget/XStatefulWidget.dart'; -import '../../dart/base/common/commands.dart'; -//办事页 -// ignore: must_be_immutable -class WorkPage extends XStatefulWidget { - late TabContainerModel? workModel; - late dynamic datas; - WorkPage({super.key, dynamic datas}) { - workModel = null; - this.datas = datas ?? RoutePages.getRouteParams(homeEnum: HomeEnum.work); - } +///办事页 +class WorkPage extends StatefulWidget { + const WorkPage({super.key}); @override State createState() => _WorkPageState(); } -class _WorkPageState extends State { - TabContainerModel? get workModel => widget.workModel; - final ScrollController scrollController = ScrollController(); +class _WorkPageState extends State + with AutomaticKeepAliveClientMixin { + TabContainerModel? _workModel; + final ScrollController _scrollController = ScrollController(); StreamSubscription? _todosSub; - // 上次构建时的待办数,用于判断是否需要重建 + final List _subscriptions = []; int? _lastTodosCount; - set workModel(TabContainerModel? value) { - widget.workModel = value; - } - dynamic get datas => widget.datas; - set datas(dynamic datas) => widget.datas; + /// 待办列表指纹(长度 + 每项 id:updateTime),用于检测内容变化 + String? _lastFingerprint; + bool _isRefreshing = false; + + @override + bool get wantKeepAlive => true; + RxList get todos => relationCtrl.work.todos; + @override void initState() { super.initState(); - workModel = null; - datas = RoutePages.getRouteParams(homeEnum: HomeEnum.work); + _workModel = null; _todosSub = todos.listen((values) { - if (mounted) { - // 仅当 todos 数量变化时才触发重建,避免无谓 setState - if (_lastTodosCount != values.length) { - setState(() { - widget.workModel = null; - }); - } + if (!mounted) return; + // 内容变化检测:长度或任意一项的 id+updateTime 变化都触发重建, + // 避免长度不变但内容变化时(标题修改、状态流转)UI 不刷新 + final newFingerprint = _computeTodosFingerprint(values); + if (_lastTodosCount != values.length || + _lastFingerprint != newFingerprint) { + setState(() { + _workModel = null; + _lastFingerprint = newFingerprint; + }); } + _lastTodosCount = values.length; }); - // 进入办事页面时按需刷新待办数据(数据过期才刷新,新鲜则直接从内存渲染) + // 订阅 'work' flag:后台 ensureDataFresh(DataType.work) 完成后会发射此事件 + // (provider/index.dart 第 673 行和 756 行),通知办事页重建 model。 + final workSub = command.subscribeByFlag('work', ([dynamic args]) { + if (!mounted) return; + // 事件回调可能在 build 阶段被同步派发,直接 setState 会触发 + // "setState during build" 异常,因此延迟到首帧后执行。 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + // 后台办事数据刷新完成,强制重建 model 拉取最新待办/已办/已发起等 + setState(() { + _workModel = null; + _lastFingerprint = null; + }); + }); + }); + _subscriptions.add(workSub); relationCtrl.provider.ensureDataFresh(DataType.work); + // 办事页不跟随单位切换刷新数据,保持本地缓存优先策略, + // 加速页面渲染速度。单位切换只刷新门户页面数据。 + } + + /// 计算待办列表指纹:长度 + 每项 id:updateTime + /// 用于检测长度不变但内容变化的情况(如标题修改、状态流转) + String? _computeTodosFingerprint(List values) { + if (values.isEmpty) return null; + final buf = StringBuffer('${values.length}'); + for (final t in values) { + buf.write('|${t.id}:${t.taskdata.updateTime}'); + } + return buf.toString(); } @override void dispose() { _todosSub?.cancel(); _todosSub = null; - scrollController.dispose(); + _scrollController.dispose(); + for (final id in _subscriptions) { + command.unsubscribeByFlag(id); + } super.dispose(); } @override Widget build(BuildContext context) { - return _buildMainView(); - } - - _buildMainView() { - return CommandWidget( - type: 'work', - cmd: 'refresh', - flag: 'session', - builder: (context, args) { - // 仅在 model 为空 或 todos 数量变化时才重建,避免命令通知触发无谓重建 - final currentCount = todos.length; - // 首次进入:workModel 为 null 且无数据时显示骨架屏占位 - // 避免直接访问 workModel! 导致崩溃 - if (workModel == null && currentCount == 0) { - return const SkeletonWidget(itemCount: 6); - } - if (workModel == null || _lastTodosCount != currentCount) { - load(context); - _lastTodosCount = currentCount; - } - return TabContainerWidget(workModel!); - }); + super.build(context); + final currentCount = todos.length; + final currentFingerprint = _computeTodosFingerprint(todos); + if (_workModel == null && currentCount == 0) { + return const SkeletonWidget(itemCount: 6); + } + // 检测长度变化或内容指纹变化,任一变化都重建 model + if (_workModel == null || + _lastTodosCount != currentCount || + _lastFingerprint != currentFingerprint) { + _workModel = _buildModel(); + _lastTodosCount = currentCount; + _lastFingerprint = currentFingerprint; + } + if (_isRefreshing) { + _isRefreshing = false; + return Column( + children: [ + XUi.refreshingBanner(), + Expanded(child: TabContainerWidget(_workModel!)), + ], + ); + } + // 统一下拉刷新交互:force=true 强制远端拉取待办/已办/已发起等 + return RefreshIndicator( + onRefresh: () async { + await relationCtrl.provider.ensureDataFresh(DataType.work, force: true); + if (mounted) { + setState(() { + _workModel = null; + _lastFingerprint = null; + }); + } + }, + child: TabContainerWidget(_workModel!), + ); } - TabContainerModel? load(BuildContext context) { - return workModel = TabContainerModel( - activeTabTitle: getActiveTabTitle() ?? "待办", - title: RoutePages.getRouteTitle() ?? "数据", + TabContainerModel _buildModel() { + return TabContainerModel( + activeTabTitle: _getActiveTabTitle() ?? "待办", + title: RoutePages.getRouteTitle() ?? "办事", tabItems: [ - createTabItemsModel(context, title: "常用"), - createTabItemsModel(context, title: "草稿"), - createTabItemsModel(context, title: "任务"), - createTabItemsModel(context, title: "待办"), - createTabItemsModel(context, title: "已办"), - createTabItemsModel(context, title: "抄送"), - createTabItemsModel(context, title: "已发起"), - createTabItemsModel(context, title: "已完结") + _createTabItem(title: "常用"), + _createTabItem(title: "草稿"), + _createTabItem(title: "任务"), + _createTabItem(title: "待办"), + _createTabItem(title: "已办"), + _createTabItem(title: "抄送"), + _createTabItem(title: "已发起"), + _createTabItem(title: "已完结"), ], ); } - /// 获得激活页签 - getActiveTabTitle() { + String? _getActiveTabTitle() { return RoutePages.getRouteDefaultActiveTab()?.firstOrNull; } - TabItemsModel createTabItemsModel( - BuildContext context, { - required String title, - }) { - return TabItemsModel( - title: title, content: _buildTabItemWidget(context, title)); + TabItemsModel _createTabItem({required String title}) { + return TabItemsModel(title: title, content: _buildTabContent(title)); } - _buildTabItemWidget(BuildContext context, String title) { - // List initCommons = []; + Widget _buildTabContent(String title) { return ListSearchWidget( - // initDatas: initCommons, onInitLoad: ([String? searchText]) async { - return await getFirstLevelDirectories(title, searchText ?? "", true); + return await _loadTabData(title, searchText ?? "", false); }, onLoad: ([String? searchText]) async { - command.emitter('work', 'refresh'); - return getFirstLevelDirectories(title, searchText ?? "", true); + return _loadTabData(title, searchText ?? "", true); }, - scrollController: scrollController, + scrollController: _scrollController, getAction: (dynamic data) { return Text( CustomDateUtil.getSessionTime(data.updateTime), @@ -155,7 +185,6 @@ class _WorkPageState extends State { ); }, onTap: (dynamic data, List children) { - // //XLogUtil.d('>>>>>>======点击了列表项 ${data.name} ${children.length}'); if (data is IWorkTask) { RoutePages.jumpWorkInfo(context: context, work: data); } else if (data is IWork) { @@ -165,99 +194,47 @@ class _WorkPageState extends State { ); } - // 获得一级目录 - Future> getFirstLevelDirectories( + Future> _loadTabData( String title, String searchText, bool reload) async { - List dataCommons = []; + List result = []; if (title == "常用") { - dataCommons = (await relationCtrl.loadCommonsOther(relationCtrl.user)) - .where((element) => - element.typeName == '办事' && element.search(searchText)) + result = (await relationCtrl.loadCommonsOther(relationCtrl.user)) + .where((e) => e.typeName == '办事' && e.search(searchText)) .toList(); } else if (title == "草稿") { - dataCommons = + result = (await relationCtrl.work.loadContent(TaskType.draft, reload: reload)) - .where((element) => element.search(searchText)) + .where((e) => e.search(searchText)) .toList(); } else if (title == "任务") { - dataCommons = + result = (await relationCtrl.work.loadContent(TaskType.task, reload: reload)) - .where((element) => element.search(searchText)) + .where((e) => e.search(searchText)) .toList(); } else if (title == "待办") { - dataCommons = relationCtrl.work.todos - .where((element) => element.search(searchText)) - .toList(); - // dataCommons = - // (await relationCtrl.work.loadContent(TaskType.wait, reload: reload)) - // .where((element) => element.search(searchText)) - // .toList(); + result = + relationCtrl.work.todos.where((e) => e.search(searchText)).toList(); } else if (title == "已办") { - dataCommons = + result = (await relationCtrl.work.loadContent(TaskType.done, reload: reload)) - .where((element) => element.search(searchText)) + .where((e) => e.search(searchText)) .toList(); } else if (title == "抄送") { - dataCommons = + result = (await relationCtrl.work.loadContent(TaskType.altMe, reload: reload)) - .where((element) => element.search(searchText)) + .where((e) => e.search(searchText)) .toList(); } else if (title == "已发起") { - dataCommons = + result = (await relationCtrl.work.loadContent(TaskType.create, reload: reload)) - .where((element) => element.search(searchText)) + .where((e) => e.search(searchText)) .toList(); } else if (title == "已完结") { - dataCommons = (await relationCtrl.work + result = (await relationCtrl.work .loadContent(TaskType.completed, reload: reload)) - .where((element) => element.search(searchText)) + .where((e) => e.search(searchText)) .toList(); } - return dataCommons; - } - - // 加载存储列表 - List loadStorages(ITarget target) { - TargetType? type = TargetType.getType(target.typeName); - if (type == TargetType.person) { - return relationCtrl.user?.storages ?? []; - } else if (type == TargetType.company) { - return relationCtrl.user?.findCompany(target.id)?.storages ?? []; - } - return []; - } - - /// 加载存储列表 - List loadStoragesDirectory(IStorage data) { - List datas = []; - XDirectory tmpDir; - int id = 0; - if (null != relationCtrl.user) { - DirectoryGroupType.getType(data.typeName)?.types.forEach((e) { - tmpDir = XDirectory( - id: id.toString(), directoryId: id.toString(), isDeleted: false); - tmpDir.name = e.label; - tmpDir.typeName = e.label; - datas.add(Directory(tmpDir, relationCtrl.user!)); - }); - } - return datas; - } - - /// 加载数据标准 - List loadDataStandards(Directory item) { - List datas = []; - XDirectory tmpDir; - int id = 0; - if (null != relationCtrl.user) { - DirectoryGroupType.getType(item.typeName)?.types.forEach((e) { - tmpDir = XDirectory( - id: id.toString(), directoryId: id.toString(), isDeleted: false); - tmpDir.name = e.label; - tmpDir.typeName = e.label; - datas.add(Directory(tmpDir, relationCtrl.user!)); - }); - } - return datas; + return result; } } diff --git a/lib/components/form/work_start_page.dart b/lib/pages/work/work_start_page.dart similarity index 50% rename from lib/components/form/work_start_page.dart rename to lib/pages/work/work_start_page.dart index d4673ca0fd5c62fc8b0bb8220901d223482a656d..b103175a6b8056f994d58de598645fa03d38cc86 100644 --- a/lib/components/form/work_start_page.dart +++ b/lib/pages/work/work_start_page.dart @@ -1,21 +1,27 @@ +import 'dart:convert'; + import 'package:flutter/material.dart' hide Form; import 'package:orginone/config/oip_tokens.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:hive/hive.dart'; import 'package:orginone/components/XScaffold/XScaffold.dart'; import 'package:orginone/components/XStatefulWidget/XStatefulWidget.dart'; import 'package:orginone/components/form/mapping_components.dart'; import 'package:orginone/components/form/form_widget/form_tool.dart'; +import 'package:orginone/components/Tip/ToastUtils.dart'; import 'package:orginone/config/theme/unified_style.dart'; import 'package:orginone/dart/base/model.dart'; import 'package:orginone/dart/core/thing/standard/form.dart'; import 'package:orginone/dart/core/work/index.dart'; import 'package:orginone/dart/core/work/apply.dart'; +import 'package:orginone/dart/core/work/rules/workFormRules.dart'; import 'package:orginone/main.dart'; import 'package:orginone/utils/log/log_util.dart'; /// 办事发起页面 - 参考React端TaskStart/DefaultWayStart流程 -/// 流程:work.createApply() → 显示表单填写 → 提交申请 +/// 流程:work.createApply() → 初始化规则引擎 → 显示表单填写 → 提交前规则校验 → 提交申请 +/// 支持草稿保存(暂存到本地 Hive Box),下次进入可继续编辑 class WorkStartPage extends XStatefulWidget { WorkStartPage({super.key, super.data}); @@ -30,6 +36,14 @@ class _WorkStartPageState extends XStatefulState { String? _errorMessage; final TextEditingController _remarkController = TextEditingController(); bool _isSubmitting = false; + bool _isSavingDraft = false; + + /// 表单规则引擎(参考 oiocns-react TaskStart) + WorkFormRules? _ruleService; + + /// 草稿缓存的 Hive Box 名 + static const _draftBoxName = 'work_start_drafts'; + static const _draftKeyPrefix = 'work_draft_'; @override void initState() { @@ -54,6 +68,8 @@ class _WorkStartPageState extends XStatefulState { if (apply != null && apply is WorkApply) { _apply = apply; await _initFormFields(); + _initRuleService(); + await _loadDraft(); if (!mounted) return; setState(() => _isLoading = false); } else { @@ -84,10 +100,143 @@ class _WorkStartPageState extends XStatefulState { } } - /// 提交申请 + /// 初始化表单规则引擎(对齐 oiocns-react WorkFormRules) + void _initRuleService() { + if (_apply == null || _work == null) return; + try { + _ruleService = WorkFormRules(); + // 收集主子表信息 + final primaryFormIds = + _work!.primaryForms.map((f) => f.id).toList(); + final detailFormIds = + _work!.detailForms.map((f) => f.id).toList(); + _ruleService!.collectData('formsType', { + 'primaryFormIds': primaryFormIds, + 'detailFormIds': detailFormIds, + }); + // 初始化规则 + _ruleService!.initRules(_apply!.forms, _apply!.belong); + XLogUtil.i('[WorkStartPage] 规则引擎初始化完成, isReady=${_ruleService!.isReady}'); + } catch (e) { + XLogUtil.w('[WorkStartPage] 规则引擎初始化失败, 跳过规则校验: $e'); + _ruleService = null; + } + } + + /// 加载草稿(如果存在) + Future _loadDraft() async { + if (_work == null || _apply == null) return; + try { + final box = await Hive.openBox(_draftBoxName); + final key = _draftKey(_work!.id); + final json = box.get(key); + if (json is String && json.isNotEmpty) { + final data = jsonDecode(json); + if (data is Map) { + _restoreDraftData(data); + XLogUtil.i('[WorkStartPage] 草稿已加载, workId=${_work!.id}'); + } + } + } catch (e) { + XLogUtil.w('[WorkStartPage] 加载草稿失败: $e'); + } + } + + /// 还原草稿数据到表单字段 + void _restoreDraftData(Map draftData) { + final remark = draftData['remark'] as String?; + if (remark != null) _remarkController.text = remark; + final fieldsData = draftData['fields'] as Map?; + if (fieldsData == null) return; + for (var form in _apply!.forms) { + for (var fieldModel in form.fields) { + final value = fieldsData[fieldModel.id]; + if (value != null) { + _setFieldValue(fieldModel, value); + } + } + } + } + + /// 保存草稿到本地 Hive Box + Future _saveDraft() async { + if (_work == null || _apply == null || _isSavingDraft) return; + setState(() => _isSavingDraft = true); + try { + final fieldsData = {}; + for (var form in _apply!.forms) { + for (var fieldModel in form.fields) { + if (fieldModel.options?.hideField != true) { + final value = _getFieldValue(fieldModel); + if (value != null) { + fieldsData[fieldModel.id ?? ''] = value; + } + } + } + } + final draftData = { + 'remark': _remarkController.text, + 'fields': fieldsData, + 'saveTime': DateTime.now().toIso8601String(), + }; + final box = await Hive.openBox(_draftBoxName); + await box.put(_draftKey(_work!.id), jsonEncode(draftData)); + if (!mounted) return; + ToastUtils.showMsg(msg: '草稿已保存'); + } catch (e) { + XLogUtil.e('[WorkStartPage] 保存草稿失败: $e'); + if (!mounted) return; + ToastUtils.showMsg(msg: '草稿保存失败'); + } finally { + if (mounted) setState(() => _isSavingDraft = false); + } + } + + /// 清除草稿 + Future _clearDraft() async { + if (_work == null) return; + try { + final box = await Hive.openBox(_draftBoxName); + await box.delete(_draftKey(_work!.id)); + } catch (e) { + XLogUtil.w('[WorkStartPage] 清除草稿失败: $e'); + } + } + + String _draftKey(String workId) => '$_draftKeyPrefix$workId'; + + /// 提交申请(提交前执行必填校验 + 规则校验 + 二次确认) + /// + /// 对齐 oiocns-react DefaultWayStart.handleSubmit: + /// 1. 必填项校验(React: validateAll + handlingResult) + /// 2. 规则校验(React: executeSubmitRule + validateAll) + /// 3. 二次确认(React: Confirm dialog) + /// 4. 提交(React: apply.createApply) Future _submitApply() async { if (_apply == null || _isSubmitting) return; + // 1. 必填项校验:遍历所有可见字段,检查 required 字段是否有值 + final missingFields = []; + for (var form in _apply!.forms) { + for (var fieldModel in form.fields) { + if (fieldModel.options?.hideField == true) continue; + final isRequired = fieldModel.options?.isRequired ?? false; + if (!isRequired) continue; + final value = _getFieldValue(fieldModel); + if (_isEmptyValue(value)) { + missingFields.add(fieldModel.name ?? fieldModel.code ?? '未知字段'); + } + } + } + if (missingFields.isNotEmpty) { + EasyLoading.showError('请填写必填项: ${missingFields.take(3).join('、')}'); + return; + } + + // 2. 二次确认(对齐 oiocns-react Confirm) + final confirmed = await _showConfirmDialog(); + if (!confirmed || !mounted) return; + setState(() => _isSubmitting = true); try { @@ -115,6 +264,20 @@ class _WorkStartPageState extends XStatefulState { ); } + // 3. 提交前执行规则校验(对齐 oiocns-react handleSubmit) + if (_ruleService != null && _ruleService!.isReady) { + _ruleService!.collectData('hotData', formData); + final result = + await _ruleService!.handleSubmit(formData) as Map?; + final success = result?['success'] ?? true; + if (success != true) { + if (!mounted) return; + EasyLoading.showError('规则校验未通过,请检查表单'); + return; + } + } + + // 4. 提交申请 final success = await _apply!.createApply( userId, _remarkController.text, @@ -124,6 +287,7 @@ class _WorkStartPageState extends XStatefulState { if (!mounted) return; if (success) { + await _clearDraft(); EasyLoading.showSuccess('提交成功'); Navigator.of(context).pop(true); } else { @@ -140,6 +304,44 @@ class _WorkStartPageState extends XStatefulState { } } + /// 判断字段值是否为空 + bool _isEmptyValue(dynamic value) { + if (value == null) return true; + if (value is String && value.trim().isEmpty) return true; + if (value is List && value.isEmpty) return true; + if (value is Map && value.isEmpty) return true; + return false; + } + + /// 提交二次确认对话框(对齐 oiocns-react Confirm) + Future _showConfirmDialog() async { + final result = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text('确认提交', style: XFonts.titleMedium), + content: Text( + '请确认表单信息填写无误,提交后将进入审批流程。', + style: XFonts.bodyMedium, + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: Text('取消', style: XFonts.labelLarge.copyWith(color: OipText.secondary)), + ), + ElevatedButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: OipBrand.primary, + foregroundColor: Colors.white, + ), + child: Text('确认提交', style: XFonts.labelLarge.copyWith(color: Colors.white)), + ), + ], + ), + ); + return result ?? false; + } + /// 获取字段值 dynamic _getFieldValue(FieldModel fieldModel) { final field = fieldModel.field; @@ -154,6 +356,19 @@ class _WorkStartPageState extends XStatefulState { return val.keys.first; } return null; + case 'multiSelect': + // 多选框值格式:List>,提交为 List(key 列表) + final val = field.defaultData.value; + if (val is List) { + final keys = []; + for (final item in val) { + if (item is Map && item.isNotEmpty) { + keys.add(item.keys.first.toString()); + } + } + return keys.isEmpty ? null : keys; + } + return null; case 'selectPerson': case 'selectDepartment': case 'selectGroup': @@ -177,6 +392,8 @@ class _WorkStartPageState extends XStatefulState { return field.defaultData.value; case 'reference': return field.defaultData.value; + // mapLocation/mapRegion/mapTrack/space/sensor 的值均为 JSON 字符串, + // 直接返回 defaultData.value 即可 default: final val = field.defaultData.value; if (val != null) return val; @@ -185,6 +402,49 @@ class _WorkStartPageState extends XStatefulState { } } + /// 还原字段值(从草稿恢复) + void _setFieldValue(FieldModel fieldModel, dynamic value) { + final field = fieldModel.field; + switch (field.type) { + case 'input': + field.controller?.text = value?.toString() ?? ''; + break; + case 'select': + field.defaultData.value = {value: value}; + break; + case 'multiSelect': + // 多选框值还原:List → List> + if (value is List) { + final items = >[]; + for (final v in value) { + final key = v.toString(); + final text = field.select?[key] ?? ''; + items.add({key: text}); + } + field.defaultData.value = items; + } else if (value is String && value.isNotEmpty) { + final text = field.select?[value] ?? ''; + field.defaultData.value = [ + {value: text} + ]; + } else { + field.defaultData.value = []; + } + break; + case 'selectPerson': + case 'selectDepartment': + case 'selectGroup': + case 'upload': + case 'reference': + case 'switch': + field.defaultData.value = value; + break; + default: + field.defaultData.value = value; + if (value is String) field.controller?.text = value; + } + } + @override Widget buildWidget(BuildContext context, dynamic data) { if (_work == null && data is IWork) { @@ -194,19 +454,33 @@ class _WorkStartPageState extends XStatefulState { return XScaffold( titleName: _work?.name ?? '发起办事', + actions: [ + if (_apply != null && !_isLoading && _errorMessage == null) + IconButton( + icon: _isSavingDraft + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + tooltip: '保存草稿', + onPressed: _isSavingDraft ? null : _saveDraft, + ), + ], body: _buildBody(), ); } Widget _buildBody() { if (_isLoading) { - return const Center( + return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ - CircularProgressIndicator(), - SizedBox(height: 16), - Text('正在加载办事配置...'), + const CircularProgressIndicator(color: OipBrand.primary), + SizedBox(height: 16.h), + Text('正在加载办事配置...', style: XFonts.bodyMedium), ], ), ); @@ -219,17 +493,21 @@ class _WorkStartPageState extends XStatefulState { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.error_outline, size: 64, color: Colors.grey.shade400), + const Icon(Icons.error_outline, size: 64, color: OipState.error), SizedBox(height: 16.h), Text( _errorMessage!, - style: TextStyle(fontSize: 16.sp, color: Colors.grey.shade600), + style: XFonts.bodyMedium.copyWith(color: OipText.secondary), textAlign: TextAlign.center, ), SizedBox(height: 24.h), ElevatedButton( onPressed: () => Navigator.of(context).pop(), - child: const Text('返回'), + style: ElevatedButton.styleFrom( + backgroundColor: OipBrand.primary, + foregroundColor: Colors.white, + ), + child: Text('返回', style: XFonts.labelLarge.copyWith(color: Colors.white)), ), ], ), @@ -238,7 +516,7 @@ class _WorkStartPageState extends XStatefulState { } if (_apply == null) { - return const Center(child: Text('无法创建申请单')); + return Center(child: Text('无法创建申请单', style: XFonts.bodyMedium)); } return Column( @@ -269,28 +547,24 @@ class _WorkStartPageState extends XStatefulState { return Container( padding: EdgeInsets.all(16.w), decoration: BoxDecoration( - gradient: LinearGradient( - colors: [Colors.orange.shade600, Colors.orange.shade400], + gradient: const LinearGradient( + colors: [OipBrand.primary, OipBrand.primaryLight], begin: Alignment.topLeft, end: Alignment.bottomRight, ), - borderRadius: BorderRadius.circular(12.r), + borderRadius: BorderRadius.circular(OipRadius.lg), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - Icon(Icons.work, color: Colors.white, size: 24.w), + Icon(Icons.work_outline, color: Colors.white, size: 24.w), SizedBox(width: 8.w), Expanded( child: Text( _work?.name ?? '办事', - style: TextStyle( - fontSize: 20.sp, - fontWeight: FontWeight.bold, - color: Colors.white, - ), + style: XFonts.titleMedium.copyWith(color: Colors.white), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -302,7 +576,7 @@ class _WorkStartPageState extends XStatefulState { SizedBox(height: 8.h), Text( _work!.metadata.remark!, - style: TextStyle(fontSize: 14.sp, color: Colors.white70), + style: XFonts.bodySmall.copyWith(color: Colors.white70), maxLines: 2, overflow: TextOverflow.ellipsis, ), @@ -337,7 +611,7 @@ class _WorkStartPageState extends XStatefulState { padding: EdgeInsets.symmetric(vertical: 32.h), child: Text( '该办事暂无表单', - style: TextStyle(fontSize: 14.sp, color: Colors.grey.shade500), + style: XFonts.bodyMedium.copyWith(color: OipText.tertiary), ), ), ), @@ -350,11 +624,7 @@ class _WorkStartPageState extends XStatefulState { padding: EdgeInsets.symmetric(vertical: 8.h), child: Text( title, - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - color: XColors.black3, - ), + style: XFonts.titleSmall, ), ); } @@ -371,15 +641,9 @@ class _WorkStartPageState extends XStatefulState { margin: EdgeInsets.only(bottom: 12.h), padding: EdgeInsets.all(16.w), decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], + color: OipSurface.card, + borderRadius: BorderRadius.circular(OipRadius.lg), + boxShadow: OipShadow.md, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -387,18 +651,14 @@ class _WorkStartPageState extends XStatefulState { Row( children: [ Icon( - isPrimary ? Icons.description : Icons.table_chart, - color: isPrimary ? Colors.orange : Colors.blue, + isPrimary ? Icons.description_outlined : Icons.table_chart_outlined, + color: isPrimary ? OipBrand.primary : OipAi.accent, size: 20, ), SizedBox(width: 8.w), Text( form.name, - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - color: XColors.black3, - ), + style: XFonts.titleSmall, ), ], ), @@ -424,15 +684,9 @@ class _WorkStartPageState extends XStatefulState { return Container( padding: EdgeInsets.all(16.w), decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12.r), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], + color: OipSurface.card, + borderRadius: BorderRadius.circular(OipRadius.lg), + boxShadow: OipShadow.md, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -443,11 +697,7 @@ class _WorkStartPageState extends XStatefulState { SizedBox(width: 8.w), Text( '备注', - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - color: XColors.black3, - ), + style: XFonts.titleSmall, ), ], ), @@ -455,19 +705,20 @@ class _WorkStartPageState extends XStatefulState { TextField( controller: _remarkController, maxLines: 3, + style: XFonts.bodyMedium, decoration: InputDecoration( hintText: '请输入备注信息(选填)', - hintStyle: TextStyle(color: Colors.grey.shade400), + hintStyle: XFonts.bodyMedium.copyWith(color: OipText.tertiary), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8.r), - borderSide: BorderSide(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(OipRadius.md), + borderSide: const BorderSide(color: OipBorder.divider), ), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8.r), - borderSide: BorderSide(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(OipRadius.md), + borderSide: const BorderSide(color: OipBorder.divider), ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8.r), + borderRadius: BorderRadius.circular(OipRadius.md), borderSide: const BorderSide(color: OipBrand.primary), ), contentPadding: EdgeInsets.symmetric( @@ -485,29 +736,32 @@ class _WorkStartPageState extends XStatefulState { Widget _buildSubmitButton() { return Container( padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h), - decoration: BoxDecoration( - color: Colors.white, + decoration: const BoxDecoration( + color: OipSurface.card, boxShadow: [ BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 10, - offset: const Offset(0, -2), + color: Color(0x14000000), + offset: Offset(0, -2), + blurRadius: 8, + spreadRadius: 0, ), ], ), child: SafeArea( child: SizedBox( width: double.infinity, - height: 48.h, + height: 52.h, child: ElevatedButton( onPressed: _isSubmitting ? null : _submitApply, style: ElevatedButton.styleFrom( - backgroundColor: Colors.orange.shade600, + backgroundColor: OipBrand.primary, foregroundColor: Colors.white, - disabledBackgroundColor: Colors.orange.shade200, + disabledBackgroundColor: OipBrand.primaryBorder, + disabledForegroundColor: Colors.white, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12.r), + borderRadius: BorderRadius.circular(OipRadius.md), ), + padding: EdgeInsets.symmetric(vertical: 14.h), ), child: _isSubmitting ? SizedBox( @@ -518,11 +772,10 @@ class _WorkStartPageState extends XStatefulState { valueColor: AlwaysStoppedAnimation(Colors.white), ), ) - : Text( - '提交申请', - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, + : Center( + child: Text( + '提交申请', + style: XFonts.labelLarge.copyWith(color: Colors.white), ), ), ), diff --git a/lib/routers/pages.dart b/lib/routers/pages.dart index 653844abc7174394944d2b9c54e9845ea3730bb8..97ae0aa97edcb604341c02f0b54de89abdf9dc20 100644 --- a/lib/routers/pages.dart +++ b/lib/routers/pages.dart @@ -24,7 +24,8 @@ import 'package:orginone/components/form/form_data_list_page/form_data_detail_pr import 'package:orginone/components/form/form_data_list_page/form_data_list_page.dart'; import 'package:orginone/components/form/form_page/view.dart'; import 'package:orginone/components/form/view_preview_page.dart'; -import 'package:orginone/components/form/work_start_page.dart'; +import 'package:orginone/pages/work/work_start_page.dart'; +import 'package:orginone/pages/store/dba/collection_list_page.dart'; import 'package:orginone/pages/portal/add_favorite_page.dart'; import 'package:orginone/components/form/form_widget/form_detail/form_detail_page.dart'; import 'package:orginone/components/form/form_widget/form_tool.dart'; @@ -191,6 +192,9 @@ class RoutePages { ///办事发起页面 Routers.workStartPage: (context) => WorkStartPage(), + ///数据库集合列表页面(数据库管理) + Routers.collectionListPage: (context) => const CollectionListPage(), + ///添加常用页面 Routers.addFavoritePage: (context) => const AddFavoritePage(), @@ -510,10 +514,12 @@ class RoutePages { return; } + // 三个 switch 命中后必须 return,避免"事项"等同时匹配 WorkType.thing + // 和 SpaceEnum.workStore 时双重导航(参见 store_page.dart 跳转流程) switch (workType) { case WorkType.thing: _jumpDetailOrInfoPage(context, Routers.standardEntityInfoPage, data); - break; + return; default: // _jumpHomeSub(home: HomeEnum.store, parentData: data, listDatas: data); } @@ -521,7 +527,7 @@ class RoutePages { case TargetType.person: case TargetType.company: _jumpDetailOrInfoPage(context, Routers.standardEntityInfoPage, data); - break; + return; default: // _jumpHomeSub(home: HomeEnum.store, parentData: data, listDatas: data); } @@ -542,10 +548,10 @@ class RoutePages { case SpaceEnum.workStore: case SpaceEnum.module: _jumpDetailOrInfoPage(context, Routers.standardEntityInfoPage, data); - break; + return; case SpaceEnum.form: _jumpDetailOrInfoPage(context, Routers.formInfoPage, data); - break; + return; default: // _jumpHomeSub(home: HomeEnum.store, parentData: data, listDatas: data); } @@ -571,6 +577,15 @@ class RoutePages { RoutePages.to(context: context, path: Routers.workStartPage, data: work); } + /// 跳转到数据库集合列表页面(数据库管理) + static void jumpCollectionListPage(BuildContext context, dynamic storage) { + RoutePages.to( + context: context, + path: Routers.collectionListPage, + data: storage, + title: '数据库'); + } + /// 跳转到关系二级页面 static void jumpRelation({dynamic parentData, List? listDatas}) { _jumpHomeSub( diff --git a/lib/routers/router_const.dart b/lib/routers/router_const.dart index ad90260d66e090a8946d420ed38216e10a860db4..443dd2b906370cffd85c4c428143ceffeeb23494 100644 --- a/lib/routers/router_const.dart +++ b/lib/routers/router_const.dart @@ -157,6 +157,9 @@ class Routers { ///办事发起页面 static const String workStartPage = "/workStartPage"; + ///数据库集合列表页面(数据库管理) + static const String collectionListPage = "/collectionListPage"; + ///添加常用页面 static const String addFavoritePage = "/addFavoritePage"; diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 3f9cec36db45c811aced42bf02d6a5e054fed697..f63d85c82965b23ea47e92514c4dfcdc176532f7 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -15,14 +15,12 @@ PODS: - FlutterMacOS - FlutterMacOS (1.0.0) - just_audio (0.0.1): + - Flutter - FlutterMacOS - package_info (0.0.1): - FlutterMacOS - package_info_plus (0.0.1): - FlutterMacOS - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - photo_manager (2.0.0): - Flutter - FlutterMacOS @@ -39,6 +37,9 @@ PODS: - FlutterMacOS - wakelock_plus (0.0.1): - FlutterMacOS + - webview_flutter_wkwebview (0.0.1): + - Flutter + - FlutterMacOS DEPENDENCIES: - audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`) @@ -49,16 +50,16 @@ DEPENDENCIES: - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) - flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - - just_audio (from `Flutter/ephemeral/.symlinks/plugins/just_audio/macos`) + - just_audio (from `Flutter/ephemeral/.symlinks/plugins/just_audio/darwin`) - package_info (from `Flutter/ephemeral/.symlinks/plugins/package_info/macos`) - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) - - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - photo_manager (from `Flutter/ephemeral/.symlinks/plugins/photo_manager/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite (from `Flutter/ephemeral/.symlinks/plugins/sqflite/darwin`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) - video_player_avfoundation (from `Flutter/ephemeral/.symlinks/plugins/video_player_avfoundation/darwin`) - wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`) + - webview_flutter_wkwebview (from `Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin`) EXTERNAL SOURCES: audio_session: @@ -78,13 +79,11 @@ EXTERNAL SOURCES: FlutterMacOS: :path: Flutter/ephemeral just_audio: - :path: Flutter/ephemeral/.symlinks/plugins/just_audio/macos + :path: Flutter/ephemeral/.symlinks/plugins/just_audio/darwin package_info: :path: Flutter/ephemeral/.symlinks/plugins/package_info/macos package_info_plus: :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos - path_provider_foundation: - :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin photo_manager: :path: Flutter/ephemeral/.symlinks/plugins/photo_manager/macos shared_preferences_foundation: @@ -97,6 +96,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/video_player_avfoundation/darwin wakelock_plus: :path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos + webview_flutter_wkwebview: + :path: Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin SPEC CHECKSUMS: audio_session: 728ae3823d914f809c485d390274861a24b0904e @@ -104,20 +105,20 @@ SPEC CHECKSUMS: device_info_plus: 5401765fde0b8d062a2f8eb65510fb17e77cf07f emoji_picker_flutter: 846d4afb3368bc864a3884f02654961198bd72da file_picker: e716a70a9fe5fd9e09ebc922d7541464289443af - file_selector_macos: 468fb6b81fac7c0e88d71317f3eec34c3b008ff9 - flutter_local_notifications: 3805ca215b2fb7f397d78b66db91f6a747af52e4 + file_selector_macos: 3e56eaea051180007b900eacb006686fd54da150 + flutter_local_notifications: 4b427ffabf278fc6ea9484c97505e231166927a5 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 - just_audio: 9b67ca7b97c61cfc9784ea23cd8cc55eb226d489 + just_audio: a42c63806f16995daf5b219ae1d679deb76e6a79 package_info: 6eba2fd8d3371dda2d85c8db6fe97488f24b74b2 package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b - path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c photo_manager: ff695c7a1dd5bc379974953a2b5c0a293f7c4c8a - shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec - url_launcher_macos: d2691c7dd33ed713bf3544850a623080ec693d95 - video_player_avfoundation: 7c6c11d8470e1675df7397027218274b6d2360b3 - wakelock_plus: 4783562c9a43d209c458cb9b30692134af456269 + url_launcher_macos: 175a54c831f4375a6cf895875f716ee5af3888ce + video_player_avfoundation: e54d03b3b19f64f2c557354f72ce538387541ba6 + wakelock_plus: 9d63063ffb7af1c215209769067c57103bde719d + webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4 PODFILE CHECKSUM: 0d3963a09fc94f580682bd88480486da345dc3f0 -COCOAPODS: 1.11.3 +COCOAPODS: 1.17.0 diff --git a/test/widget_test.dart b/test/widget_test.dart index e46a5d40d7dd020ac3803f6ac21a4987a80aec0d..d64a2ab4404b2c449679f7772e0c18ae9b588430 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -224,7 +224,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('演示视频'), findsOneWidget); - expect(find.text('查看视频类广场资源'), findsOneWidget); + expect(find.text('查看视频分类下的详细内容'), findsOneWidget); }); testWidgets('发现页在无数据且加载失败时展示错误态', (tester) async {