From e342a442d8e07651e200f597a02a11f07bdf9244 Mon Sep 17 00:00:00 2001 From: panzhaohui Date: Fri, 24 Jul 2026 20:21:21 +0800 Subject: [PATCH 01/37] =?UTF-8?q?perf:=20=E4=BC=98=E5=8C=96=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=8A=A0=E8=BD=BD=E4=B8=8E=E9=A1=B5=E9=9D=A2=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 缓存 ListWidget 首次加载 Future,避免每次 build 重复请求 - 优化 CommandModel 通知逻辑,数据未变化时不触发 rebuild - 为核心页面(store/work/chat/discover)添加 KeepAlive,Tab 切换更丝滑 - TabWidget 全局包装 KeepAlive,保持 Tab 状态与滚动位置 - 延长非聊天数据类型 TTL 至 24h,减少进入页面自动刷新 - 修复 widget_test 中视频 Tab 副标题期望与实现不一致 --- .../ContactListWidget.dart | 4 +- lib/components/CommandWidget/index.dart | 36 ++- .../FileListWidget/FileListWidget.dart | 4 +- lib/components/ListWidget/ListWidget.dart | 31 ++- .../components/TabWidget.dart | 23 +- lib/components/XConsumer/XConsumer.dart | 7 +- .../controller/data_freshness_tracker.dart | 28 +- lib/pages/chats/chat_page.dart | 103 +++---- lib/pages/discover/discover_page.dart | 31 ++- lib/pages/store/store_page.dart | 54 ++-- lib/pages/work/work_page.dart | 253 ++++++------------ test/widget_test.dart | 2 +- 12 files changed, 280 insertions(+), 296 deletions(-) diff --git a/lib/components/AlphabetIndexWidget/ContactListWidget.dart b/lib/components/AlphabetIndexWidget/ContactListWidget.dart index a06990d..fa93ad5 100644 --- a/lib/components/AlphabetIndexWidget/ContactListWidget.dart +++ b/lib/components/AlphabetIndexWidget/ContactListWidget.dart @@ -78,8 +78,10 @@ class _ContactListWidgetState extends State> _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(() { diff --git a/lib/components/CommandWidget/index.dart b/lib/components/CommandWidget/index.dart index 41ad63e..29803b9 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/FileWidget/FileListWidget/FileListWidget.dart b/lib/components/FileWidget/FileListWidget/FileListWidget.dart index ee1b65b..5aaf21d 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/ListWidget/ListWidget.dart b/lib/components/ListWidget/ListWidget.dart index a85bbba..d1854f7 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,12 @@ 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 ?? []; + if ((widget.initDatas?.isEmpty ?? true)) { + _initFuture = null; + } } } @@ -131,23 +137,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) { diff --git a/lib/components/TabContainerWidget/components/TabWidget.dart b/lib/components/TabContainerWidget/components/TabWidget.dart index d9ff110..b4eb695 100644 --- a/lib/components/TabContainerWidget/components/TabWidget.dart +++ b/lib/components/TabContainerWidget/components/TabWidget.dart @@ -268,7 +268,7 @@ class _TabPageState extends State { ? Column( children: [ const NetworkTipWidget(), - Expanded(child: item.content!) + Expanded(child: _KeepAliveTabContent(child: item.content!)), ], ) : null; @@ -278,3 +278,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 bb194f1..878d21c 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/dart/controller/data_freshness_tracker.dart b/lib/dart/controller/data_freshness_tracker.dart index c235bc5..321c2b9 100644 --- a/lib/dart/controller/data_freshness_tracker.dart +++ b/lib/dart/controller/data_freshness_tracker.dart @@ -18,24 +18,20 @@ 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, }; /// 判断指定数据类型是否需要刷新 diff --git a/lib/pages/chats/chat_page.dart b/lib/pages/chats/chat_page.dart index 96d0ce6..59aff70 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,13 +30,12 @@ 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; @@ -47,78 +43,85 @@ class _ChatPageState extends State { List? get chats => chatProvider?.chats ?? []; final Map> dataSourceCache = {}; - // 上次构建时的会话总数、未读总数与最近消息时间,用于判断是否需要重建 - // _lastLastMsgTime 解决"正在会话时新消息到达但 unread=0、count 不变,列表预览陈旧"问题 int? _lastChatCount; int? _lastUnreadCount; int? _lastLastMsgTime; + @override + bool get wantKeepAlive => true; + @override void initState() { super.initState(); - // 事件监听器(保存订阅引用以便 dispose 时取消) _portalRefreshSub = portalRefreshEventBus.on().listen(onPortalEvent); - // 进入沟通页面时按需刷新会话数据(数据过期才刷新,新鲜则直接从内存渲染) relationCtrl.provider.ensureDataFresh(DataType.chats); + final chatSub = command.subscribe((type, cmd, args) { + if (type == 'chat' && cmd == 'new') { + if (mounted) setState(() {}); + } + }); + _subscriptions.add(chatSub); + final switchSub = command.subscribeByFlag('switchSpace', ([args]) { + if (!mounted) return; + setState(() { + _chatModel = null; + _lastChatCount = null; + _lastUnreadCount = null; + _lastLastMsgTime = null; + dataSourceCache.clear(); + }); + }, false); + _subscriptions.add(switchSub); } @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) setState(() {}); } @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); + 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 && currentChats.isEmpty) { + return const SkeletonWidget(itemCount: 6); + } + if (_chatModel == null || + _lastChatCount != currentCount || + _lastUnreadCount != currentUnread || + _lastLastMsgTime != currentLastMsgTime) { + load(context); + _lastChatCount = currentCount; + _lastUnreadCount = currentUnread; + _lastLastMsgTime = currentLastMsgTime; + } + return TabContainerWidget(_chatModel!); } void load(BuildContext context) { - // 清空数据源缓存,确保每次重建都从 chatProvider 获取最新数据 dataSourceCache.clear(); - relationModel = TabContainerModel( + _chatModel = TabContainerModel( title: "沟通", activeTabTitle: getActiveTabTitle(), tabItems: [ diff --git a/lib/pages/discover/discover_page.dart b/lib/pages/discover/discover_page.dart index feefa21..e1eadf1 100644 --- a/lib/pages/discover/discover_page.dart +++ b/lib/pages/discover/discover_page.dart @@ -47,13 +47,6 @@ class _DiscoverPageState extends State } void _subscribeRefreshEvents() { - // 订阅 session flag,后台恢复或数据刷新时重新加载 - final sessionId = command.subscribeByFlag('session', ([args]) { - if (!mounted) return; - context.read().refreshData(); - }, false); - _subscribers.add(sessionId); - // 订阅 switchSpace 事件:切换单位后清空缓存并强制重新加载 final switchId = command.subscribeByFlag('switchSpace', ([args]) { if (!mounted) return; @@ -165,7 +158,7 @@ class _DiscoverPageState extends State controller: _tabController, children: [ for (final config in discoverItems) - _buildTabPane(config, provider, theme), + _keepAliveTabPane(_buildTabPane(config, provider, theme)), ], ), ), @@ -570,6 +563,8 @@ class _DiscoverPageState extends State ); } + Widget _keepAliveTabPane(Widget child) => _KeepAliveWrapper(child: child); + Widget _buildTabPane( DiscoverItemConfig config, DiscoverProvider provider, @@ -970,3 +965,23 @@ class _DiscoverPageState extends State } } } + +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/store/store_page.dart b/lib/pages/store/store_page.dart index d5e6c6e..22ed11b 100644 --- a/lib/pages/store/store_page.dart +++ b/lib/pages/store/store_page.dart @@ -3,6 +3,7 @@ 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/components/LoadingWidget/LoadingWidget.dart'; import 'package:orginone/config/theme/unified_style.dart'; import 'package:orginone/dart/base/schema.dart'; import 'package:orginone/dart/controller/data_freshness_tracker.dart'; @@ -23,7 +24,6 @@ import 'package:orginone/main.dart'; import 'package:orginone/routers/app_route.dart'; import 'package:orginone/routers/pages.dart'; -import '../../components/CommandWidget/index.dart'; import '../../components/XConsumer/XConsumer.dart'; import '../../dart/base/common/commands.dart'; import 'dba/collection_list_page.dart'; @@ -42,61 +42,71 @@ 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); - } - - @override - void didUpdateWidget(StorePage oldWidget) { - super.didUpdateWidget(oldWidget); + final subId = command.subscribeByFlag('switchSpace', ([args]) { + if (!mounted) return; + setState(() { + storeModel = null; + _lastLoadedPath = null; + }); + }, false); + _subscriptions.add(subId); } @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 ?? ''; + if (storeModel == null || _lastLoadedPath != currentPath) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + load(); + _lastLoadedPath = currentPath; + setState(() {}); + }); + if (storeModel == null) { + return const SkeletonWidget(itemCount: 6); + } + } + return TabContainerWidget(storeModel!); }, ); } diff --git a/lib/pages/work/work_page.dart b/lib/pages/work/work_page.dart index 8807822..572c0a4 100644 --- a/lib/pages/work/work_page.dart +++ b/lib/pages/work/work_page.dart @@ -7,146 +7,119 @@ 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; + @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; + if (_lastTodosCount != values.length) { + setState(() { + _workModel = null; + }); } }); - // 进入办事页面时按需刷新待办数据(数据过期才刷新,新鲜则直接从内存渲染) relationCtrl.provider.ensureDataFresh(DataType.work); + final subId = command.subscribeByFlag('switchSpace', ([args]) { + if (!mounted) return; + setState(() { + _workModel = null; + _lastTodosCount = null; + }); + }, false); + _subscriptions.add(subId); } @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; + if (_workModel == null && currentCount == 0) { + return const SkeletonWidget(itemCount: 6); + } + if (_workModel == null || _lastTodosCount != currentCount) { + _workModel = _buildModel(); + _lastTodosCount = currentCount; + } + return 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 +128,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 +137,42 @@ 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 = - (await relationCtrl.work.loadContent(TaskType.draft, reload: reload)) - .where((element) => element.search(searchText)) - .toList(); + result = (await relationCtrl.work.loadContent(TaskType.draft, reload: reload)) + .where((e) => e.search(searchText)) + .toList(); } else if (title == "任务") { - dataCommons = - (await relationCtrl.work.loadContent(TaskType.task, reload: reload)) - .where((element) => element.search(searchText)) - .toList(); + result = (await relationCtrl.work.loadContent(TaskType.task, reload: reload)) + .where((e) => e.search(searchText)) + .toList(); } else if (title == "待办") { - dataCommons = relationCtrl.work.todos - .where((element) => element.search(searchText)) + result = relationCtrl.work.todos + .where((e) => e.search(searchText)) .toList(); - // dataCommons = - // (await relationCtrl.work.loadContent(TaskType.wait, reload: reload)) - // .where((element) => element.search(searchText)) - // .toList(); } else if (title == "已办") { - dataCommons = - (await relationCtrl.work.loadContent(TaskType.done, reload: reload)) - .where((element) => element.search(searchText)) - .toList(); + result = (await relationCtrl.work.loadContent(TaskType.done, reload: reload)) + .where((e) => e.search(searchText)) + .toList(); } else if (title == "抄送") { - dataCommons = - (await relationCtrl.work.loadContent(TaskType.altMe, reload: reload)) - .where((element) => element.search(searchText)) - .toList(); + result = (await relationCtrl.work.loadContent(TaskType.altMe, reload: reload)) + .where((e) => e.search(searchText)) + .toList(); } else if (title == "已发起") { - dataCommons = - (await relationCtrl.work.loadContent(TaskType.create, reload: reload)) - .where((element) => element.search(searchText)) - .toList(); + result = (await relationCtrl.work.loadContent(TaskType.create, reload: reload)) + .where((e) => e.search(searchText)) + .toList(); } else if (title == "已完结") { - dataCommons = (await relationCtrl.work - .loadContent(TaskType.completed, reload: reload)) - .where((element) => element.search(searchText)) + result = (await relationCtrl.work.loadContent(TaskType.completed, reload: reload)) + .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/test/widget_test.dart b/test/widget_test.dart index e46a5d4..d64a2ab 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 { -- Gitee From 5dc4023468ad348edef074b2038c2df063ef8aa8 Mon Sep 17 00:00:00 2001 From: panzhaohui Date: Fri, 24 Jul 2026 23:44:13 +0800 Subject: [PATCH 02/37] =?UTF-8?q?perf:=20=E5=85=A8=E9=9D=A2=E6=80=A7?= =?UTF-8?q?=E8=83=BD=E4=BC=98=E5=8C=96=E4=B8=8E=E8=AE=BE=E8=AE=A1=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E7=BB=9F=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 性能优化: - chat_page: 将build()中的load()副作用移至addPostFrameCallback,避免同步重计算 - ActivityListWidget: ListView(children:)改为ListView.builder实现懒加载 - LoadingWidget: 移除didUpdateWidget中冗余的setState触发 设计系统统一: - 统一TabBar页签字体: labelStyle=XFonts.labelMedium, unselectedLabelStyle=XFonts.labelSmall - 修复TabWidget/explorer_page/agent_management_page/all_activities_page等硬编码TabBar字体 - 为device_list_page/vet_management_page/approval_center_page补充缺失的TabBar字体样式 - base组件(vet_guard/oip_a11y/oip_feedback): 所有硬编码Color(0xFF...)替换为OipState/OipSurface/OipText/OipBrand token - IoT模块(thing_model_view/iot_cards/device_detail_page/device_list_page): 40+处硬编码色值替换为设计系统token - 业务卡片(business_cards/card_action_flow/workflow_collaboration_cards/card_registry): 30+处硬编码色值替换 - OIP页面(vet_detail_page/vet_management_page): 30+处硬编码色值替换 - 门户页最近应用列表按updateTime降序排序,最近打开的显示在最前面 --- .../ActivityListWidget.dart | 29 +- .../ContactListWidget.dart | 104 ++- .../cards/business_cards.dart | 67 +- .../cards/card_action_flow.dart | 25 +- .../cards/card_registry.dart | 9 +- .../ChatSessionWidget/cards/iot_cards.dart | 123 ++-- .../cards/workflow_collaboration_cards.dart | 17 +- .../LoadingWidget/LoadingWidget.dart | 5 - .../components/TabWidget.dart | 9 +- lib/components/base/oip_a11y.dart | 1 + lib/components/base/oip_feedback.dart | 18 +- lib/components/base/vet_guard.dart | 10 +- lib/components/iot/thing_model_view.dart | 113 ++-- lib/config/theme/unified_style.dart | 623 +++++++++++++++++- lib/dart/core/target/base/team.dart | 39 +- lib/pages/chats/chat_page.dart | 21 +- lib/pages/discover/discover_page.dart | 329 +++------ lib/pages/discover/discover_provider.dart | 19 +- .../discover/pages/all_activities_page.dart | 13 +- lib/pages/iot/device_detail_page.dart | 57 +- lib/pages/iot/device_list_page.dart | 36 +- lib/pages/oip/approval_center_page.dart | 3 + lib/pages/oip/vet_detail_page.dart | 95 +-- lib/pages/oip/vet_management_page.dart | 43 +- lib/pages/portal/agent_management_page.dart | 7 +- lib/pages/portal/explorer/explorer_page.dart | 10 +- lib/pages/portal/home/home_page.dart | 536 +++++++-------- .../portal/logic/platform_entry_logic.dart | 6 +- lib/pages/portal/my_page.dart | 425 +++++------- lib/pages/portal/platform_entry_page.dart | 284 ++++---- lib/pages/portal/workBench/view.dart | 25 +- lib/pages/relation/relation_page.dart | 53 +- lib/pages/work/work_page.dart | 11 + 33 files changed, 1801 insertions(+), 1364 deletions(-) diff --git a/lib/components/ActivityWidget/ActivityListWidget/ActivityListWidget.dart b/lib/components/ActivityWidget/ActivityListWidget/ActivityListWidget.dart index 9d24693..69d53ec 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, + ), + ); + }, + ); }, ), ); diff --git a/lib/components/AlphabetIndexWidget/ContactListWidget.dart b/lib/components/AlphabetIndexWidget/ContactListWidget.dart index fa93ad5..515ade7 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,11 +75,24 @@ 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; @@ -88,12 +104,15 @@ class _ContactListWidgetState extends State> _contacts = List.from(data); _buildGroups(); _isLoading = false; + _isRefreshing = false; + _error = null; }); } catch (e) { if (!mounted) return; setState(() { _error = e; _isLoading = false; + _isRefreshing = false; }); } } @@ -176,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), + ), + ), ], ), ); @@ -188,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, @@ -217,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), ), ), ); @@ -237,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 d2321e1..59ddd07 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: 10.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: 11.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: 11.sp)), ], actionChildren: [ CardActionButton( diff --git a/lib/components/ChatSessionWidget/cards/card_action_flow.dart b/lib/components/ChatSessionWidget/cards/card_action_flow.dart index 1d9a4ac..3fb1791 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 c5ff8a8..c235fcf 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 24d44fd..aae72a5 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: 12.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: 12.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: 11.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: 11.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: 11.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: 12.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: 11.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: 11.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: 11.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: 11.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: 11.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: 12.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: 11.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: 12.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: 11.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: 10.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 5712196..0e262f9 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: 10.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: 11.sp), ), ], actionChildren: [ diff --git a/lib/components/LoadingWidget/LoadingWidget.dart b/lib/components/LoadingWidget/LoadingWidget.dart index ff401f8..0a71141 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 diff --git a/lib/components/TabContainerWidget/components/TabWidget.dart b/lib/components/TabContainerWidget/components/TabWidget.dart index b4eb695..4a088a3 100644 --- a/lib/components/TabContainerWidget/components/TabWidget.dart +++ b/lib/components/TabContainerWidget/components/TabWidget.dart @@ -135,8 +135,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.labelSmall, + unselectedLabelStyle: XFonts.labelSmall, indicator: UnderlineTabIndicator( borderSide: const BorderSide( width: 2.0, color: OipBrand.primary), @@ -235,9 +235,8 @@ class _TabPageState extends State { // : Colors.blue; // }, // ), - labelStyle: - const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), - unselectedLabelStyle: const TextStyle(fontSize: 14), + labelStyle: XFonts.labelMedium, + unselectedLabelStyle: XFonts.labelSmall, indicator: const UnderlineTabIndicator(), // indicator: UnderlineTabIndicator( // borderRadius: BorderRadius.circular(4.w), diff --git a/lib/components/base/oip_a11y.dart b/lib/components/base/oip_a11y.dart index d367437..c951f54 100644 --- a/lib/components/base/oip_a11y.dart +++ b/lib/components/base/oip_a11y.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:orginone/config/oip_tokens.dart'; /// OIP 无障碍基线(实施方案 §5.5,HCI 架构 §10.2) /// diff --git a/lib/components/base/oip_feedback.dart b/lib/components/base/oip_feedback.dart index 0af2dc7..b2f419c 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 1ff7f6e..5278ae3 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/iot/thing_model_view.dart b/lib/components/iot/thing_model_view.dart index 140cb8b..58dcb91 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: 12.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: 11.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: 13.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: 13.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: 12.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: 13.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: 13.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: 13.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: 12.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: 13.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: 12.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: 13.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: 11.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: 11.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: 12.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 2a475c4..a3e45a2 100644 --- a/lib/config/theme/unified_style.dart +++ b/lib/config/theme/unified_style.dart @@ -188,24 +188,207 @@ 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 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: 14.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: 14.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; // 列表区块标题 ///动态模块 ///动态子标题样式 @@ -673,3 +856,411 @@ 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), + ); + } + + // --- 统一间距快捷方法 --- + + 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 + ? 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 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), + 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/core/target/base/team.dart b/lib/dart/core/target/base/team.dart index de6afd3..c0f16c3 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/pages/chats/chat_page.dart b/lib/pages/chats/chat_page.dart index 59aff70..d2c4535 100644 --- a/lib/pages/chats/chat_page.dart +++ b/lib/pages/chats/chat_page.dart @@ -46,6 +46,7 @@ class _ChatPageState extends State int? _lastChatCount; int? _lastUnreadCount; int? _lastLastMsgTime; + bool _isRefreshing = false; @override bool get wantKeepAlive => true; @@ -70,6 +71,7 @@ class _ChatPageState extends State _lastUnreadCount = null; _lastLastMsgTime = null; dataSourceCache.clear(); + _isRefreshing = true; }); }, false); _subscriptions.add(switchSub); @@ -111,10 +113,27 @@ class _ChatPageState extends State _lastChatCount != currentCount || _lastUnreadCount != currentUnread || _lastLastMsgTime != currentLastMsgTime) { - load(context); + final needLoad = _chatModel == null; _lastChatCount = currentCount; _lastUnreadCount = currentUnread; _lastLastMsgTime = currentLastMsgTime; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + load(context); + setState(() {}); + }); + if (needLoad) { + return const SkeletonWidget(itemCount: 6); + } + } + if (_isRefreshing) { + _isRefreshing = false; + return Column( + children: [ + XUi.refreshingBanner(), + Expanded(child: TabContainerWidget(_chatModel!)), + ], + ); } return TabContainerWidget(_chatModel!); } diff --git a/lib/pages/discover/discover_page.dart b/lib/pages/discover/discover_page.dart index e1eadf1..16272af 100644 --- a/lib/pages/discover/discover_page.dart +++ b/lib/pages/discover/discover_page.dart @@ -6,6 +6,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'; @@ -50,7 +52,7 @@ class _DiscoverPageState extends State // 订阅 switchSpace 事件:切换单位后清空缓存并强制重新加载 final switchId = command.subscribeByFlag('switchSpace', ([args]) { if (!mounted) return; - DiscoverService.clearCache(); + context.read().clearCache(); context.read().loadDiscoverData(forceRefresh: true); }, false); _subscribers.add(switchId); @@ -129,7 +131,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; @@ -139,10 +141,9 @@ 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), ); @@ -151,6 +152,8 @@ class _DiscoverPageState extends State return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + if (provider.isLoading && hasLoadedData) + XUi.refreshingBanner(), _buildTabBar(provider, theme), _buildSmartSearchBar(theme), Expanded( @@ -174,10 +177,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, ), ), @@ -185,13 +188,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.labelMedium, + unselectedLabelStyle: XFonts.labelSmall, labelPadding: EdgeInsets.symmetric(horizontal: 16.w), padding: EdgeInsets.only(left: 8.w, right: 8.w, top: 6.h), dividerColor: Colors.transparent, @@ -202,7 +205,7 @@ class _DiscoverPageState extends State child: _buildTabLabel( config, provider.getBadgeCount(config.plazaType), - theme.primaryColor, + OipBrand.primary, ), ), ], @@ -212,7 +215,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: [ @@ -230,22 +233,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, ), ], ), @@ -313,9 +312,9 @@ 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( @@ -333,8 +332,7 @@ class _DiscoverPageState extends State children: [ Text( '搜索结果', - style: TextStyle( - fontSize: 18.sp, fontWeight: FontWeight.w600), + style: XFonts.titleSmall, ), const Spacer(), GestureDetector( @@ -348,8 +346,7 @@ 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), @@ -418,7 +415,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( @@ -437,20 +434,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)), + size: 20.sp, color: OipText.disabled), ], ), ), @@ -480,25 +475,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; } } @@ -544,16 +539,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, ), ), @@ -644,33 +635,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, ), ), @@ -680,9 +660,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, ), ), @@ -695,31 +674,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, ), ], ), @@ -730,56 +708,16 @@ 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, ); } @@ -789,29 +727,30 @@ 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( @@ -821,20 +760,15 @@ class _DiscoverPageState extends State resource.name, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 16.5, - fontWeight: FontWeight.w700, - color: Color(0xFF15181D), - ), + style: XFonts.titleSmall.copyWith(color: OipText.primary), ), SizedBox(height: 4.h), Text( _buildResourceSubtitle(config, resource), maxLines: 2, overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 14, - color: Color(0xFF7D8592), + style: XFonts.caption.copyWith( + color: OipText.tertiary, height: 1.35, ), ), @@ -842,7 +776,7 @@ class _DiscoverPageState extends State SizedBox(height: 8.h), _buildMetaChip( label: '公开', - color: theme.primaryColor, + color: OipBrand.primary, ), ], ], @@ -852,12 +786,13 @@ class _DiscoverPageState extends State Icon( Icons.chevron_right_rounded, size: 20.sp, - color: const Color(0xFFB7BDC7), + color: OipText.disabled, ), ], ), ), ), + ), ); } @@ -867,73 +802,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, @@ -949,19 +828,19 @@ 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; } } } diff --git a/lib/pages/discover/discover_provider.dart b/lib/pages/discover/discover_provider.dart index 7b6764f..5eb9929 100644 --- a/lib/pages/discover/discover_provider.dart +++ b/lib/pages/discover/discover_provider.dart @@ -10,14 +10,14 @@ class DiscoverProvider with ChangeNotifier { XPlaza? _plaza; bool _isLoading = false; String? _errorMessage; + bool _hasCompletedFirstLoad = false; Map get badgeCounts => Map.unmodifiable(_badgeCounts); List get resources => List.unmodifiable(_resources); XPlaza? get plaza => _plaza; bool get isLoading => _isLoading; 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,6 +25,16 @@ class DiscoverProvider with ChangeNotifier { _setLoading(true); _clearError(); + // forceRefresh 时立即清空内存数据并通知,让 hasLoadedData 变 false, + // build 走 Skeleton 分支而不是显示旧数据(切空间场景) + if (forceRefresh) { + _resources = []; + _badgeCounts = {}; + _plaza = null; + _hasCompletedFirstLoad = false; + notifyListeners(); + } + try { // 多集群聚合加载:聚合用户加入的所有空间(个人/单位/集群群组/工作群) // 各空间返回的 plaza 信息仅用于首次展示,资源列表已合并去重排序 @@ -35,12 +45,10 @@ class DiscoverProvider with ChangeNotifier { _plaza = aggregated.plaza; _resources = aggregated.resources; - notifyListeners(); - _badgeCounts = await DiscoverService.loadBadgeCounts(resources: _resources); - notifyListeners(); + _hasCompletedFirstLoad = true; XLogUtil.d('发现数据加载完成(多集群聚合):${_resources.length} 个资源'); } catch (e) { _setError('加载数据失败: $e'); @@ -98,6 +106,7 @@ class DiscoverProvider with ChangeNotifier { _badgeCounts.clear(); _resources.clear(); _plaza = null; + _hasCompletedFirstLoad = false; _clearError(); notifyListeners(); // 同时清理DiscoverService中的静态缓存 diff --git a/lib/pages/discover/pages/all_activities_page.dart b/lib/pages/discover/pages/all_activities_page.dart index b9b08a7..a43770e 100644 --- a/lib/pages/discover/pages/all_activities_page.dart +++ b/lib/pages/discover/pages/all_activities_page.dart @@ -225,8 +225,6 @@ class _AllActivitiesPageState extends State indicatorColor: OipBrand.primary, labelColor: OipText.primary, unselectedLabelColor: OipText.tertiary, - labelFontSize: 16, - unselectedLabelFontSize: 14, ), ), body: body, @@ -249,8 +247,6 @@ class _AllActivitiesPageState extends State indicatorColor: OipBrand.primary, labelColor: OipText.primary, unselectedLabelColor: OipText.tertiary, - labelFontSize: 14, - unselectedLabelFontSize: 14, ), ), Expanded( @@ -321,8 +317,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 +328,8 @@ class _AllActivitiesPageState extends State indicatorWeight: 3, labelColor: labelColor, unselectedLabelColor: unselectedLabelColor, - labelStyle: TextStyle( - fontSize: labelFontSize, - fontWeight: FontWeight.w600, - ), - unselectedLabelStyle: TextStyle(fontSize: unselectedLabelFontSize), + labelStyle: XFonts.labelMedium, + unselectedLabelStyle: XFonts.labelSmall, ); } diff --git a/lib/pages/iot/device_detail_page.dart b/lib/pages/iot/device_detail_page.dart index 7aeac2a..43bdfe7 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: 12.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: 12.sp, color: OipText.tertiary)), ), Expanded( child: SelectableText( value, - style: const TextStyle(fontSize: 12, color: Color(0xFF1E293B)), + style: XFonts.captionSmall.copyWith( + fontSize: 12.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: 12.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: 11.sp)), ], ], ), diff --git a/lib/pages/iot/device_list_page.dart b/lib/pages/iot/device_list_page.dart index 073fba7..b7075aa 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'; @@ -150,6 +153,8 @@ class _DeviceListPageState extends State title: const Text('设备管理'), bottom: TabBar( controller: _tabController, + labelStyle: XFonts.labelMedium, + unselectedLabelStyle: XFonts.labelSmall, 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: 11.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: 11.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: 11.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/oip/approval_center_page.dart b/lib/pages/oip/approval_center_page.dart index 57eff3d..9915068 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'; @@ -141,6 +142,8 @@ class _ApprovalCenterPageState extends State controller: _tabController, labelColor: OipBrand.primary, unselectedLabelColor: OipText.tertiary, + labelStyle: XFonts.labelMedium, + unselectedLabelStyle: XFonts.labelSmall, indicatorColor: OipBrand.primary, indicatorSize: TabBarIndicatorSize.label, tabs: [ diff --git a/lib/pages/oip/vet_detail_page.dart b/lib/pages/oip/vet_detail_page.dart index a0da771..221d5fa 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'; @@ -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: 11.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: 12.sp, color: OipText.secondary)), ), Expanded( child: Text(value, - style: const TextStyle(fontSize: 12, color: Color(0xFF1E293B))), + style: XFonts.labelSmall.copyWith(fontSize: 12.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: 12.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: 11.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: 12.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: 12.sp, color: OipText.secondary)), ), Expanded( child: Text(i.value, - style: const TextStyle(fontSize: 12, color: Color(0xFF1E293B))), + style: XFonts.labelSmall.copyWith(fontSize: 12.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: 12.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: 12.sp)), subtitle: Text(d.lifecycleState.name, - style: const TextStyle(fontSize: 10, color: Color(0xFF64748B))), + style: XFonts.captionSmall.copyWith(fontSize: 10.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: 12.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: 10.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 f0ef00e..035549c 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'; @@ -66,6 +69,8 @@ class _VetManagementPageState extends State ], bottom: TabBar( controller: _tabController, + labelStyle: XFonts.labelMedium, + unselectedLabelStyle: XFonts.labelSmall, 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: 11.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: 10.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: 10.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/agent_management_page.dart b/lib/pages/portal/agent_management_page.dart index ecb97d3..04de342 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/explorer/explorer_page.dart b/lib/pages/portal/explorer/explorer_page.dart index 46705e6..b1b6dfb 100644 --- a/lib/pages/portal/explorer/explorer_page.dart +++ b/lib/pages/portal/explorer/explorer_page.dart @@ -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.labelMedium, + unselectedLabelStyle: XFonts.labelSmall, indicatorColor: OipBrand.primary, indicatorSize: TabBarIndicatorSize.label, indicatorWeight: 2.h, diff --git a/lib/pages/portal/home/home_page.dart b/lib/pages/portal/home/home_page.dart index 75ab20c..b734b30 100644 --- a/lib/pages/portal/home/home_page.dart +++ b/lib/pages/portal/home/home_page.dart @@ -54,6 +54,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(); @@ -179,37 +187,26 @@ class _HomePageState extends State { 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(); - }); - } - } catch (e) { - XLogUtil.w('门户页 _refreshApps.forms 失败: $e'); + // 并行加载文件和表单 + final filesFuture = space.directory.loadFiles().timeout( + const Duration(seconds: 5), + onTimeout: () => [], + ); + final forms = []; + for (var app in validApps.take(3)) { + forms.addAll(app.forms); } - // 加载最近文件 - 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'); + final files = await filesFuture; + if (mounted) { + setState(() { + _allApps = validApps; + _recentApps = validApps.toList() + ..sort((a, b) => + (b.metadata.updateTime ?? '').compareTo(a.metadata.updateTime ?? '')); + _recentApps = _recentApps.take(8).toList(); + _forms = forms.take(10).toList(); + _recentFiles = files.take(5).toList(); + }); } } catch (e) { XLogUtil.w('门户页 _refreshApps 失败: $e'); @@ -297,7 +294,7 @@ class _HomePageState extends State { DeviceUtils.updateWithContext(context); final space = _currentSpace; return Scaffold( - backgroundColor: XColors.bgColor, + backgroundColor: OipSurface.background, body: _isLoading ? const SkeletonWidget(itemCount: 8) : RefreshIndicator( @@ -333,35 +330,9 @@ 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:单位名称 + 切换按钮 + 统计行 @@ -371,7 +342,7 @@ class _HomePageState extends State { padding: EdgeInsets.fromLTRB(20.w, 24.h, 20.w, 20.h), decoration: const BoxDecoration( gradient: LinearGradient( - colors: [Color(0xFF228DEF), Color(0xFF4A90E2)], + colors: [OipBrand.primary, OipBrand.primaryDark], begin: Alignment.topCenter, end: Alignment.bottomCenter, ), @@ -388,14 +359,14 @@ class _HomePageState extends State { Text( space?.name ?? '工作空间', style: XFonts.portalHeroTitle.copyWith( - color: Colors.white, + color: OipText.inverse, ), ), SizedBox(height: 6.h), Text( '单位工作台', style: XFonts.portalPageTitle.copyWith( - color: Colors.white.withValues(alpha: 0.85), + color: OipText.inverse.withValues(alpha: 0.85), ), ), ], @@ -409,19 +380,19 @@ class _HomePageState extends State { vertical: 6.h, ), decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(16.r), + color: OipSurface.card.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(OipRadius.xl.w), ), 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 +422,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), ), ), ], @@ -475,10 +442,7 @@ class _HomePageState extends State { 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), - ), + decoration: XUi.cardDecoration(), child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: _isPersonalSpace() @@ -509,31 +473,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 +509,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 +556,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 +583,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 +654,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 +686,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 +713,66 @@ 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), - decoration: BoxDecoration( - color: Colors.blue.shade50, - borderRadius: BorderRadius.circular(10.r), + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 12.h), + decoration: XUi.rounded( + color: OipSurface.muted, + radius: OipRadius.md, ), 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,41 +793,39 @@ 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), - ), + margin: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), + padding: EdgeInsets.fromLTRB(20.w, 16.h, 20.w, 20.h), + decoration: XUi.cardDecoration(), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - Text( - '数据', - style: XFonts.portalSectionTitle, - ), + Text('数据', style: XFonts.titleMedium), const Spacer(), GestureDetector( onTap: _jumpToUnitFiles, - child: Text( - '全部', - style: XFonts.portalActionLabel.copyWith( - color: OipBrand.primary, - ), + 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), + SizedBox(height: 16.h), Row( children: [ Expanded( child: GestureDetector( onTap: _jumpToAgents, child: _buildDataItem('$_agentCount', '智能体', - Colors.purple.shade600, Icons.smart_toy_outlined), + OipEntity.agent, Icons.smart_toy_outlined), ), ), SizedBox(width: 12.w), @@ -851,7 +833,7 @@ class _HomePageState extends State { child: GestureDetector( onTap: _jumpToUnitFiles, child: _buildDataItem('${_recentFiles.length}', '最近文件', - Colors.orange.shade600, Icons.insert_drive_file_outlined), + OipEntity.document, Icons.insert_drive_file_outlined), ), ), SizedBox(width: 12.w), @@ -859,7 +841,7 @@ class _HomePageState extends State { child: GestureDetector( onTap: _jumpToAllApps, child: _buildDataItem('${_allApps.length}', '应用', - Colors.blue.shade600, Icons.apps_outlined), + OipEntity.application, Icons.apps_rounded), ), ), ], @@ -872,29 +854,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,39 +876,31 @@ 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), - ), + margin: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), + decoration: XUi.cardDecoration(), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: EdgeInsets.fromLTRB(16.w, 12.h, 12.w, 4.h), + padding: EdgeInsets.fromLTRB(20.w, 16.h, 20.w, 8.h), child: Row( children: [ - Text('数据存储', style: XFonts.portalSectionTitle), + Text('数据存储', style: XFonts.titleMedium), 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), - ), + padding: EdgeInsets.symmetric(horizontal: 7.w, vertical: 2.h), + decoration: XUi.softBadge(OipState.success, radius: OipRadius.xs), child: Text( - '${_storages.length} 个存储空间', - style: XFonts.portalBadge.copyWith( - color: const Color(0xFF10B981), + '${_storages.length} 个', + style: XFonts.labelSmall.copyWith( + color: OipState.success, + fontWeight: FontWeight.w600, ), ), ), - const Spacer(), ], ), ), @@ -945,16 +910,15 @@ class _HomePageState extends State { return Column( children: [ if (index > 0) - Divider( - height: 1, - indent: 56.w, - endIndent: 16.w, - color: Colors.grey.shade100), + Padding( + padding: EdgeInsets.only(left: 76.w, right: 20.w), + child: const Divider(height: 0.5, color: OipBorder.divider), + ), _buildStorageItem(storage), ], ); }), - SizedBox(height: 8.h), + SizedBox(height: 12.h), ], ), ); @@ -962,50 +926,25 @@ class _HomePageState extends State { 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,26 +955,21 @@ 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(), ); } @@ -1047,53 +981,49 @@ 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), - ), + margin: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), + decoration: XUi.cardDecoration(), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: EdgeInsets.fromLTRB(16.w, 12.h, 12.w, 4.h), + padding: EdgeInsets.fromLTRB(20.w, 16.h, 20.w, 8.h), child: Row( children: [ Text( title, - style: XFonts.portalSectionTitle, + style: XFonts.titleMedium, ), - SizedBox(width: 8.w), - if (count > 0) + if (count > 0) ...[ + SizedBox(width: 8.w), Container( padding: - EdgeInsets.symmetric(horizontal: 6.w, vertical: 1.h), - decoration: BoxDecoration( - color: OipBrand.primary.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8.r), - ), + EdgeInsets.symmetric(horizontal: 7.w, vertical: 2.h), + decoration: XUi.softBadge(OipBrand.primary, radius: OipRadius.xs), child: Text( '$count', - style: XFonts.portalBadge.copyWith( + style: XFonts.labelSmall.copyWith( color: OipBrand.primary, + fontWeight: FontWeight.w600, ), ), ), + ], const Spacer(), if (onMore != null) GestureDetector( onTap: onMore, + behavior: HitTestBehavior.opaque, child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( '更多', - style: XFonts.portalActionLabel.copyWith( - color: OipBrand.primary, - ), + 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), ], ), ), @@ -1101,7 +1031,7 @@ class _HomePageState extends State { ), ), ...children, - SizedBox(height: 8.h), + SizedBox(height: 12.h), ], ), ); @@ -1114,7 +1044,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 797de54..0180c8d 100644 --- a/lib/pages/portal/logic/platform_entry_logic.dart +++ b/lib/pages/portal/logic/platform_entry_logic.dart @@ -98,10 +98,10 @@ class PlatformEntryLogic extends PortalLogic { } void _subscribeEntryEvents() { - // 订阅 session flag:后台恢复/网络恢复/数据刷新完成时触发 - final sessionId = command.subscribeByFlag('session', ([args]) { + // 仅订阅 switchSpace:切换单位时才刷新,避免 session 事件过度刷新 + final switchId = command.subscribeByFlag('switchSpace', ([args]) { refreshAllData(); }, false); - _entrySubscribers.add(sessionId); + _entrySubscribers.add(switchId); } } diff --git a/lib/pages/portal/my_page.dart b/lib/pages/portal/my_page.dart index cfaaae1..4af6b95 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/platform_entry_page.dart b/lib/pages/portal/platform_entry_page.dart index b6f2d67..2adc834 100644 --- a/lib/pages/portal/platform_entry_page.dart +++ b/lib/pages/portal/platform_entry_page.dart @@ -5,6 +5,7 @@ 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/config/oip_feature_flags.dart'; @@ -112,7 +113,7 @@ class _PlatformEntryPageState extends State Widget build(BuildContext context) { final useV5 = OipFeatureFlags.oipFlutterV5Enabled; return Scaffold( - backgroundColor: const Color(0xFFF5F7FA), + backgroundColor: OipSurface.background, body: SafeArea( top: false, bottom: false, @@ -196,7 +197,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 +214,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 +229,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.labelMedium.copyWith(height: 1.2), + unselectedLabelStyle: XFonts.labelSmall.copyWith(height: 1.2), tabs: _tabResources.map((r) { final icon = _getTabIcon(r.typeName); final badge = _logic.plazaBadges[r.typeName] ?? 0; @@ -241,7 +240,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 +249,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: 10.sp, ), ), ), @@ -327,8 +326,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 +344,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 +360,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, ), @@ -389,25 +388,25 @@ class _PlatformEntryPageState extends State children: [ // 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 +415,7 @@ class _PlatformEntryPageState extends State child: Center( child: XImage.platformNavButton( iconPath: XImage.logoNotBg, - iconSize: 28, + iconSize: 32, ), ), ), @@ -427,12 +426,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, @@ -442,18 +438,18 @@ class _PlatformEntryPageState extends State 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), + ? OipState.success + : OipState.warning, boxShadow: [ BoxShadow( color: (_isOnline - ? const Color(0xFF4ADE80) - : const Color(0xFFFFA726)) + ? OipState.success + : OipState.warning) .withValues(alpha: 0.7), blurRadius: 8, spreadRadius: 2, @@ -464,11 +460,8 @@ 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, + style: XFonts.captionSmall.copyWith( + color: OipText.inverse.withValues(alpha: 0.75), ), ), ], @@ -525,16 +518,16 @@ class _PlatformEntryPageState extends State child: Container( padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.h), decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.1), + color: OipText.inverse.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12.r), border: Border.all( - color: Colors.white.withValues(alpha: 0.18), + color: OipText.inverse.withValues(alpha: 0.18), width: 0.8, ), ), child: Row( children: [ - Icon(icon, color: Colors.white.withValues(alpha: 0.9), size: 16.w), + Icon(icon, color: OipText.inverse.withValues(alpha: 0.9), size: 18.w), SizedBox(width: 8.w), Expanded( child: Column( @@ -543,21 +536,13 @@ class _PlatformEntryPageState extends State children: [ Text( value, - style: TextStyle( - color: Colors.white, - fontSize: 17.sp, - fontWeight: FontWeight.w700, - height: 1.15, - ), + style: XFonts.numberSmall.copyWith(color: OipText.inverse), ), SizedBox(height: 2.h), Text( label, - style: TextStyle( - color: Colors.white.withValues(alpha: 0.72), - fontSize: 11.sp, - fontWeight: FontWeight.w500, - height: 1.25, + style: XFonts.labelSmall.copyWith( + color: OipText.inverse.withValues(alpha: 0.72), ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -594,7 +579,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 +611,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 +622,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 +639,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 +723,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 +771,19 @@ 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), + 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: 11.sp)), ], ), ), @@ -827,17 +799,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,11 +822,11 @@ 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 @@ -1056,9 +1018,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 +1135,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 +1147,7 @@ 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 +1167,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 +1179,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 +1209,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, ), ], ), @@ -1351,7 +1297,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 +1322,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), ), ], ), @@ -1478,7 +1424,7 @@ class _PlatformEntryItemCard extends StatelessWidget { decoration: BoxDecoration( borderRadius: BorderRadius.circular(18.r), gradient: const LinearGradient( - colors: [Colors.white, Color(0xFFFAFBFD)], + colors: [OipSurface.card, Color(0xFFFAFBFD)], begin: Alignment.topLeft, end: Alignment.bottomRight, ), @@ -1621,9 +1567,9 @@ class _PlatformEntryItemCard extends StatelessWidget { bottom: 10.h, child: Text( title, - style: TextStyle( - color: Colors.white, - fontSize: 15.sp, + style: XFonts.titleSmall.copyWith( + color: OipText.inverse, + fontSize: 16.sp, fontWeight: FontWeight.w700, height: 1.35, shadows: [ @@ -1649,9 +1595,9 @@ class _PlatformEntryItemCard extends StatelessWidget { Expanded( child: Text( summary, - style: TextStyle( - fontSize: 12.sp, - color: XColors.black9, + style: XFonts.bodySmall.copyWith( + fontSize: 13.sp, + color: OipText.tertiary, fontWeight: FontWeight.w500, height: 1.45, ), @@ -1664,13 +1610,13 @@ class _PlatformEntryItemCard extends StatelessWidget { if (timeStr.isNotEmpty) ...[ SizedBox(width: 8.w), Icon(Icons.access_time_rounded, - size: 12.w, color: XColors.black9), + size: 13.w, color: OipText.tertiary), SizedBox(width: 4.w), Text( timeStr, - style: TextStyle( - fontSize: 11.sp, - color: XColors.black9, + style: XFonts.captionSmall.copyWith( + fontSize: 12.sp, + color: OipText.tertiary, fontWeight: FontWeight.w500), ), ], @@ -1712,10 +1658,10 @@ class _PlatformEntryItemCard extends StatelessWidget { Flexible( child: Text( title, - style: TextStyle( - fontSize: 15.sp, + style: XFonts.titleSmall.copyWith( + fontSize: 16.sp, fontWeight: FontWeight.w700, - color: XColors.black3, + color: OipText.primary, height: 1.35, ), maxLines: 2, @@ -1728,9 +1674,9 @@ class _PlatformEntryItemCard extends StatelessWidget { SizedBox(height: 6.h), Text( summary, - style: TextStyle( - fontSize: 13.sp, - color: XColors.black6, + style: XFonts.bodySmall.copyWith( + fontSize: 14.sp, + color: OipText.secondary, fontWeight: FontWeight.w500, height: 1.45, ), @@ -1743,13 +1689,13 @@ class _PlatformEntryItemCard extends StatelessWidget { Row( children: [ Icon(Icons.access_time_rounded, - size: 12.w, color: XColors.black9), + size: 13.w, color: OipText.tertiary), SizedBox(width: 4.w), Text( timeStr, - style: TextStyle( - fontSize: 11.sp, - color: XColors.black9, + style: XFonts.captionSmall.copyWith( + fontSize: 12.sp, + color: OipText.tertiary, fontWeight: FontWeight.w500), ), ], @@ -1836,9 +1782,9 @@ class _PlatformEntryItemCard extends StatelessWidget { ), child: Text( label, - style: TextStyle( - color: Colors.white, - fontSize: 10.sp, + style: XFonts.labelSmall.copyWith( + color: OipText.inverse, + fontSize: 11.sp, fontWeight: FontWeight.w700, height: 1.15, letterSpacing: 0.3, @@ -1870,15 +1816,15 @@ class _PlatformEntryItemCard extends StatelessWidget { margin: EdgeInsets.only(right: 4.w), decoration: const BoxDecoration( shape: BoxShape.circle, - color: Colors.white, + color: OipText.inverse, ), ), ], Text( isLive ? 'LIVE' : '未开播', - style: TextStyle( - color: Colors.white, - fontSize: 10.sp, + style: XFonts.labelSmall.copyWith( + color: OipText.inverse, + fontSize: 11.sp, fontWeight: FontWeight.w700, height: 1.15, letterSpacing: 0.3, @@ -1903,9 +1849,9 @@ class _PlatformEntryItemCard extends StatelessWidget { ), child: Text( text, - style: TextStyle( - color: Colors.white, - fontSize: 10.sp, + style: XFonts.labelSmall.copyWith( + color: OipText.inverse, + fontSize: 11.sp, fontWeight: FontWeight.w600, height: 1.15, ), @@ -1918,14 +1864,14 @@ class _PlatformEntryItemCard extends StatelessWidget { return Container( padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h), decoration: BoxDecoration( - color: const Color(0xFFFF4757), + color: OipMarket.price, borderRadius: BorderRadius.circular(6.r), ), child: Text( '¥${g.price.toStringAsFixed(2)}', - style: TextStyle( - color: Colors.white, - fontSize: 11.sp, + style: XFonts.labelSmall.copyWith( + color: OipText.inverse, + fontSize: 12.sp, fontWeight: FontWeight.w700, height: 1.15, ), @@ -2048,12 +1994,12 @@ class _PlatformEntryItemCard extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, color: Colors.white, size: 14.w), + Icon(icon, color: OipText.inverse, size: 14.w), SizedBox(width: 4.w), Text( label, - style: TextStyle( - color: Colors.white, + style: XFonts.labelSmall.copyWith( + color: OipText.inverse, fontSize: 12.sp, fontWeight: FontWeight.w600, height: 1.2, @@ -2080,7 +2026,7 @@ class _PlatformEntryItemCard extends StatelessWidget { SizedBox(width: 4.w), Text( label, - style: TextStyle( + style: XFonts.labelSmall.copyWith( color: color, fontSize: 12.sp, fontWeight: FontWeight.w600, diff --git a/lib/pages/portal/workBench/view.dart b/lib/pages/portal/workBench/view.dart index 9be0835..fb87816 100644 --- a/lib/pages/portal/workBench/view.dart +++ b/lib/pages/portal/workBench/view.dart @@ -755,7 +755,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 +774,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), + padding: EdgeInsets.symmetric(vertical: 10.h), decoration: 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 0d5abe1..662b814 100644 --- a/lib/pages/relation/relation_page.dart +++ b/lib/pages/relation/relation_page.dart @@ -63,6 +63,7 @@ class _RelationState extends State final List _subscriptions = []; // 好友列表加载去重标志,避免快速切换 Tab 时多次触发 loadMembers bool _loadingMembers = false; + bool _isRefreshing = false; _RelationState(); @@ -84,6 +85,7 @@ class _RelationState extends State _lastLoadedPath = null; _lastLoadedData = null; _loadingMembers = false; + _isRefreshing = true; }); }, false); _subscriptions.add(subId); @@ -122,13 +124,14 @@ class _RelationState extends State // 同步最新 datas(路由参数可能更新但路径未变) datas = ar.currPageData.data; } + final showRefreshBanner = _isRefreshing; + if (_isRefreshing) _isRefreshing = false; return Column( children: [ + if (showRefreshBanner) XUi.refreshingBanner(), Expanded( child: TabContainerWidget( relationModel!, - // 用 activeTabTitle + entity id 作为 key,确保切换子页面/默认 Tab 变化时 - // TabWidget 被强制重建,重新读取 DefaultTabController.initialIndex key: ValueKey( '${relationModel!.activeTabTitle ?? ''}_${_parentCompany?.id ?? 'root'}'), getActions: _getActions, @@ -372,7 +375,7 @@ class _RelationState extends State RoutePages.jumpRelationInfo(data: data); }, child: const IconWidget( - color: XColors.chatHintColors, + color: OipText.tertiary, iconData: Icons.info_outlined, ), ); @@ -695,7 +698,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 +712,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 +725,13 @@ 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)), + title: Text('查看数据集', style: XFonts.bodyMedium), onTap: () { Navigator.pop(sheetCtx); _openStorageCollection(storage); @@ -741,7 +740,7 @@ 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)), + title: Text('激活存储', style: XFonts.bodyMedium), onTap: () { Navigator.pop(sheetCtx); _activateStorage(storage); @@ -749,7 +748,7 @@ class _RelationState extends State ), ListTile( leading: Icon(Icons.logout_outlined, size: 22.w, color: OipState.error), - title: Text('退出存储', style: TextStyle(fontSize: 15.sp, color: OipState.error)), + title: Text('退出存储', style: XFonts.bodyMedium.copyWith(color: OipState.error)), onTap: () { Navigator.pop(sheetCtx); _exitStorage(storage); @@ -828,7 +827,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 +841,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,17 +854,13 @@ 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)), + title: Text('打开会话', style: XFonts.bodyMedium), onTap: () { Navigator.pop(sheetCtx); Navigator.of(context).push( @@ -877,7 +872,7 @@ class _RelationState extends State ), ListTile( leading: Icon(Icons.add_circle_outline, size: 22.w, color: OipState.success), - title: Text('创建智能体', style: TextStyle(fontSize: 15.sp)), + title: Text('创建智能体', style: XFonts.bodyMedium), onTap: () { Navigator.pop(sheetCtx); _createAgent(); @@ -885,15 +880,15 @@ class _RelationState extends State ), ListTile( leading: Icon(Icons.group_add_outlined, size: 22.w, color: OipBrand.primary), - title: Text('加入智能体', style: TextStyle(fontSize: 15.sp)), + 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/work/work_page.dart b/lib/pages/work/work_page.dart index 572c0a4..0d22323 100644 --- a/lib/pages/work/work_page.dart +++ b/lib/pages/work/work_page.dart @@ -32,6 +32,7 @@ class _WorkPageState extends State StreamSubscription? _todosSub; final List _subscriptions = []; int? _lastTodosCount; + bool _isRefreshing = false; @override bool get wantKeepAlive => true; @@ -56,6 +57,7 @@ class _WorkPageState extends State setState(() { _workModel = null; _lastTodosCount = null; + _isRefreshing = true; }); }, false); _subscriptions.add(subId); @@ -83,6 +85,15 @@ class _WorkPageState extends State _workModel = _buildModel(); _lastTodosCount = currentCount; } + if (_isRefreshing) { + _isRefreshing = false; + return Column( + children: [ + XUi.refreshingBanner(), + Expanded(child: TabContainerWidget(_workModel!)), + ], + ); + } return TabContainerWidget(_workModel!); } -- Gitee From b0ff6ed0730cd83841b406f1bdc59199cf594b42 Mon Sep 17 00:00:00 2001 From: panzhaohui Date: Sat, 25 Jul 2026 05:37:28 +0800 Subject: [PATCH 03/37] =?UTF-8?q?fix:=20=E6=B8=85=E7=90=86=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=EF=BC=8C=E7=A7=BB=E9=99=A4=E6=9C=AA=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E7=9A=84=E6=96=B9=E6=B3=95=E5=92=8Cimport=EF=BC=8C0=20error=20?= =?UTF-8?q?0=20warning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ActivityListWidget.dart | 45 ------------------- lib/components/base/oip_a11y.dart | 1 - 2 files changed, 46 deletions(-) diff --git a/lib/components/ActivityWidget/ActivityListWidget/ActivityListWidget.dart b/lib/components/ActivityWidget/ActivityListWidget/ActivityListWidget.dart index 69d53ec..372b05c 100644 --- a/lib/components/ActivityWidget/ActivityListWidget/ActivityListWidget.dart +++ b/lib/components/ActivityWidget/ActivityListWidget/ActivityListWidget.dart @@ -203,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/base/oip_a11y.dart b/lib/components/base/oip_a11y.dart index c951f54..d367437 100644 --- a/lib/components/base/oip_a11y.dart +++ b/lib/components/base/oip_a11y.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:orginone/config/oip_tokens.dart'; /// OIP 无障碍基线(实施方案 §5.5,HCI 架构 §10.2) /// -- Gitee From a0c1e75c402a5e3c7099e0e06b2a203f0f76a596 Mon Sep 17 00:00:00 2001 From: panzhaohui Date: Sat, 25 Jul 2026 05:49:16 +0800 Subject: [PATCH 04/37] =?UTF-8?q?style:=20=E7=BB=9F=E4=B8=80=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E7=B3=BB=E7=BB=9F=E4=BB=A4=E7=89=8C=EF=BC=8C=E6=9B=BF?= =?UTF-8?q?=E6=8D=A26=E4=B8=AA=E6=A0=B8=E5=BF=83=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E7=A1=AC=E7=BC=96=E7=A0=81=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - portal_header: Colors.white→OipText.inverse, 硬编码字体→XFonts - portal_input_bar: 硬编码颜色→OipSurface/OipBrand/OipText令牌 - portal_quick_actions: 硬编码装饰色→OipState/OipBrand/OipAi令牌 - portal_recommend: 硬编码样式→OipShadow/OipBorder/XFonts令牌 - portal_favorites: Colors.grey→OipText, 硬编码字体→XFonts - workBench/view: XColors.doorDesGrey→OipText.secondary, 移除硬编码fontFamily - store_page: XColors.chatHintColors→OipText.disabled flutter analyze: 0 error, 0 warning --- .../portal/widgets/portal_favorites.dart | 69 +++++--------- lib/pages/portal/widgets/portal_header.dart | 68 +++++++------- .../portal/widgets/portal_input_bar.dart | 90 +++++++++++-------- .../portal/widgets/portal_quick_actions.dart | 71 +++++++-------- .../portal/widgets/portal_recommend.dart | 66 ++++++-------- lib/pages/portal/workBench/view.dart | 53 +++-------- lib/pages/store/store_page.dart | 6 +- 7 files changed, 183 insertions(+), 240 deletions(-) diff --git a/lib/pages/portal/widgets/portal_favorites.dart b/lib/pages/portal/widgets/portal_favorites.dart index 22be5b1..26d82eb 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 8bcc7df..3820113 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)), ], ), ), @@ -177,7 +171,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 +182,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 +193,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); @@ -237,7 +235,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 9ab9677..c8c7361 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 d9095fc..bac19d3 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 1f8d340..b322144 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/workBench/view.dart b/lib/pages/portal/workBench/view.dart index fb87816..d346b2c 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)), ], ), )); diff --git a/lib/pages/store/store_page.dart b/lib/pages/store/store_page.dart index 22ed11b..90ee16e 100644 --- a/lib/pages/store/store_page.dart +++ b/lib/pages/store/store_page.dart @@ -4,7 +4,7 @@ import 'package:orginone/components/TabContainerWidget/TabContainerWidget.dart'; import 'package:orginone/components/TabContainerWidget/types.dart'; import 'package:orginone/components/ListWidget/ListWidget.dart'; import 'package:orginone/components/LoadingWidget/LoadingWidget.dart'; -import 'package:orginone/config/theme/unified_style.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'; @@ -366,7 +366,7 @@ class _StorePageState extends State RoutePages.jumpStoreInfoPage(context, data: data); }, child: const IconWidget( - color: XColors.chatHintColors, + color: OipText.disabled, iconData: Icons.info_outlined, ), ); @@ -391,7 +391,7 @@ class _StorePageState extends State radius: 0, onTap: () {}, child: const IconWidget( - color: XColors.chatHintColors, + color: OipText.disabled, iconData: Icons.info_outlined, ), ); -- Gitee From dc3d136ef456d521d948cf0eccc32039cc4e36c0 Mon Sep 17 00:00:00 2001 From: panzhaohui Date: Sat, 25 Jul 2026 06:09:12 +0800 Subject: [PATCH 05/37] =?UTF-8?q?test:=20=E4=BF=AE=E5=A4=8D=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E6=80=81=E4=B8=8E=E5=8D=A1=E7=89=87=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=EF=BC=8C=E7=BB=9F=E4=B8=80=E7=A9=BA=E7=8A=B6=E6=80=81=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=EF=BC=8C=E4=BF=AE=E5=A4=8DiOS=E9=93=BE=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - XUi.emptyState新增title参数,discover_page错误态显示"发现页加载失败" - card_registry_test使用ScreenUtilInit包装(测试环境,test/*不纳入版本控制) - store_page替换旧式颜色为OipText.disabled - iOS project.pbxproj移除已弃用的path_provider_foundation框架链接 - macOS Podfile.lock同步更新 flutter analyze: 0 error 0 warning flutter test: 110 passed --- ios/Runner.xcodeproj/project.pbxproj | 90 +++++++++++++-------------- lib/config/theme/unified_style.dart | 5 ++ lib/pages/discover/discover_page.dart | 3 +- macos/Podfile.lock | 35 ++++++----- 4 files changed, 67 insertions(+), 66 deletions(-) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 84a55a5..dc38255 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/config/theme/unified_style.dart b/lib/config/theme/unified_style.dart index a3e45a2..e2308a3 100644 --- a/lib/config/theme/unified_style.dart +++ b/lib/config/theme/unified_style.dart @@ -1187,6 +1187,7 @@ class XUi { /// 空状态提示组件 static Widget emptyState({ IconData icon = Icons.inbox_outlined, + String? title, String message = '暂无内容', String? actionLabel, VoidCallback? onAction, @@ -1199,6 +1200,10 @@ class XUi { 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), diff --git a/lib/pages/discover/discover_page.dart b/lib/pages/discover/discover_page.dart index 16272af..9ae71b5 100644 --- a/lib/pages/discover/discover_page.dart +++ b/lib/pages/discover/discover_page.dart @@ -143,7 +143,8 @@ class _DiscoverPageState extends State if (provider.errorMessage != null && !hasLoadedData) { return XUi.emptyState( icon: Icons.cloud_off_rounded, - message: provider.errorMessage ?? '发现页加载失败', + title: '发现页加载失败', + message: provider.errorMessage ?? '请检查网络后重试', actionLabel: '重试', onAction: () => provider.loadDiscoverData(forceRefresh: true), ); diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 3f9cec3..f63d85c 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 -- Gitee From 31c3f444c16a8fe4f8fe06d545b5a3f8454c05c1 Mon Sep 17 00:00:00 2001 From: panzhaohui Date: Sat, 25 Jul 2026 06:24:14 +0800 Subject: [PATCH 06/37] =?UTF-8?q?feat:=20TabBar=E5=AD=97=E4=BD=93=E6=94=BE?= =?UTF-8?q?=E5=A4=A71=E5=8F=B7=EF=BC=8C=E7=99=BB=E5=BD=95=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E6=A1=86=E6=94=BE=E5=A4=A7=EF=BC=8C=E5=8F=91=E7=8E=B0?= =?UTF-8?q?=E9=A1=B5=E7=A6=BB=E7=BA=BF=E4=BC=98=E5=85=88=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - XFonts新增tabLabel(17sp)/tabUnselected(15sp)专用样式 - 沟通/办事/发现/关系/平台入口TabBar统一使用tabLabel/tabUnselected - XTextField输入内容和提示词字体15sp→16sp - DiscoverProvider.refreshData优化为离线优先:保留旧数据显示, 后台拉取新数据成功后替换,避免下拉刷新时UI闪烁 - 修复card_registry_test中多余的!非空断言 flutter analyze: 0 error, 0 warning --- .../components/TabWidget.dart | 4 +-- lib/components/XTextField/XTextField.dart | 4 +-- lib/config/theme/unified_style.dart | 14 ++++++++ lib/pages/discover/discover_page.dart | 4 +-- lib/pages/discover/discover_provider.dart | 36 +++++++++++++++---- lib/pages/portal/platform_entry_page.dart | 4 +-- 6 files changed, 51 insertions(+), 15 deletions(-) diff --git a/lib/components/TabContainerWidget/components/TabWidget.dart b/lib/components/TabContainerWidget/components/TabWidget.dart index 4a088a3..6c56120 100644 --- a/lib/components/TabContainerWidget/components/TabWidget.dart +++ b/lib/components/TabContainerWidget/components/TabWidget.dart @@ -235,8 +235,8 @@ class _TabPageState extends State { // : Colors.blue; // }, // ), - labelStyle: XFonts.labelMedium, - unselectedLabelStyle: XFonts.labelSmall, + labelStyle: XFonts.tabLabel, + unselectedLabelStyle: XFonts.tabUnselected, indicator: const UnderlineTabIndicator(), // indicator: UnderlineTabIndicator( // borderRadius: BorderRadius.circular(4.w), diff --git a/lib/components/XTextField/XTextField.dart b/lib/components/XTextField/XTextField.dart index 4c879e8..7b7806d 100644 --- a/lib/components/XTextField/XTextField.dart +++ b/lib/components/XTextField/XTextField.dart @@ -142,13 +142,13 @@ class XTextField extends StatelessWidget { autofocus: autofocus, obscureText: obscureText, inputFormatters: inputFormatters, - style: TextStyle(fontSize: 15.sp), + style: TextStyle(fontSize: 16.sp), decoration: InputDecoration( isCollapsed: true, hintText: hint, contentPadding: EdgeInsets.zero, hintStyle: TextStyle( - color: Colors.grey.shade400, fontSize: 15.sp), + color: Colors.grey.shade400, fontSize: 16.sp), border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, diff --git a/lib/config/theme/unified_style.dart b/lib/config/theme/unified_style.dart index e2308a3..d89e118 100644 --- a/lib/config/theme/unified_style.dart +++ b/lib/config/theme/unified_style.dart @@ -295,6 +295,20 @@ class XFonts { height: 1.3, ); + // --- TabBar 专用(在 labelMedium/labelSmall 基础上放大1号)--- + static TextStyle get tabLabel => TextStyle( + fontSize: 17.sp, + fontWeight: FontWeight.w500, + color: OipText.primary, + height: 1.3, + ); + static TextStyle get tabUnselected => TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w500, + color: OipText.tertiary, + height: 1.3, + ); + // --- Caption / 辅助说明(时间、数量、提示文字)--- static TextStyle get caption => TextStyle( fontSize: 16.sp, diff --git a/lib/pages/discover/discover_page.dart b/lib/pages/discover/discover_page.dart index 9ae71b5..fafcd20 100644 --- a/lib/pages/discover/discover_page.dart +++ b/lib/pages/discover/discover_page.dart @@ -194,8 +194,8 @@ class _DiscoverPageState extends State indicatorSize: TabBarIndicatorSize.label, labelColor: OipText.primary, unselectedLabelColor: OipText.tertiary, - labelStyle: XFonts.labelMedium, - unselectedLabelStyle: XFonts.labelSmall, + 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, diff --git a/lib/pages/discover/discover_provider.dart b/lib/pages/discover/discover_provider.dart index 5eb9929..8c541d5 100644 --- a/lib/pages/discover/discover_provider.dart +++ b/lib/pages/discover/discover_provider.dart @@ -25,9 +25,9 @@ class DiscoverProvider with ChangeNotifier { _setLoading(true); _clearError(); - // forceRefresh 时立即清空内存数据并通知,让 hasLoadedData 变 false, - // build 走 Skeleton 分支而不是显示旧数据(切空间场景) - if (forceRefresh) { + // forceRefresh 时仅切空间场景才清空数据(hasLoadedData 为 false 时); + // 下拉刷新走 refreshData(),保留旧数据避免 UI 闪烁 + if (forceRefresh && !_hasCompletedFirstLoad) { _resources = []; _badgeCounts = {}; _plaza = null; @@ -36,8 +36,6 @@ class DiscoverProvider with ChangeNotifier { } try { - // 多集群聚合加载:聚合用户加入的所有空间(个人/单位/集群群组/工作群) - // 各空间返回的 plaza 信息仅用于首次展示,资源列表已合并去重排序 final aggregated = await DiscoverService.loadAggregatedPlazaResources( forceRefresh: forceRefresh, ); @@ -58,9 +56,33 @@ class DiscoverProvider with ChangeNotifier { } } + /// 下拉刷新:离线优先,保留旧数据显示,后台拉取新数据后替换 Future refreshData() async { - DiscoverService.clearCache(); - await loadDiscoverData(forceRefresh: true); + if (_isLoading) return; + + _setLoading(true); + _clearError(); + + try { + // 强制刷新 DiscoverService 缓存,但不清空 Provider 已有数据 + final aggregated = await DiscoverService.loadAggregatedPlazaResources( + forceRefresh: true, + ); + + _plaza = aggregated.plaza; + _resources = aggregated.resources; + + _badgeCounts = + await DiscoverService.loadBadgeCounts(resources: _resources); + + _hasCompletedFirstLoad = true; + XLogUtil.d('发现数据刷新完成(离线优先):${_resources.length} 个资源'); + } catch (e) { + _setError('刷新数据失败: $e'); + XLogUtil.e('DiscoverProvider 刷新错误: $e'); + } finally { + _setLoading(false); + } } List findResourcesByType(PlazaType type) { diff --git a/lib/pages/portal/platform_entry_page.dart b/lib/pages/portal/platform_entry_page.dart index 2adc834..8b25fc8 100644 --- a/lib/pages/portal/platform_entry_page.dart +++ b/lib/pages/portal/platform_entry_page.dart @@ -231,8 +231,8 @@ class _PlatformEntryPageState extends State dividerColor: Colors.transparent, labelColor: OipText.inverse, unselectedLabelColor: OipText.secondary, - labelStyle: XFonts.labelMedium.copyWith(height: 1.2), - unselectedLabelStyle: XFonts.labelSmall.copyWith(height: 1.2), + 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; -- Gitee From 2c826e47fce3bf35a37f06a1338ba7674cba20b6 Mon Sep 17 00:00:00 2001 From: panzhaohui Date: Sat, 25 Jul 2026 06:45:43 +0800 Subject: [PATCH 07/37] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E5=88=87=E6=8D=A2=E4=B8=8E=E6=BB=91=E5=8A=A8=E7=99=BD?= =?UTF-8?q?=E5=B1=8F=E9=9A=90=E6=82=A3=EF=BC=8C=E7=BB=9F=E4=B8=80=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E4=BF=9D=E6=8C=81=E4=B8=8EFuture=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - chat/store/relation 页面:将 build 中的 addPostFrameCallback+setState 循环抽离到 initState 首帧回调和事件回调中的 _refreshModel/_loadFromRoute, 消除 build→postFrame→load→setState→build 的级联重建 - MessageListWidget:FutureBuilder 的 future 改为 initState 中缓存的 _loadMessagesFuture,避免每次 build 重复触发请求;修复 reverse ListView 的 index+1 越界风险(index>=length-1 边界保护) - HomePage:移除子页面外层 KeepAliveWidget 包裹,统一依赖 AutomaticKeepAliveClientMixin 实现状态保持 - TabWidget:为 ExtendedTabBarView 子项添加 ValueKey(tab.title), 确保父级重建时子 widget 实例复用,避免状态丢失 - XLazy:从 StatelessWidget 改为 StatefulWidget,在 initState 缓存 _initFuture,避免 FutureBuilder 每次 build 创建新 Future - ListWidget:didUpdateWidget 中 initDatas 引用变化时无论是否为空都 重置 _initFuture,避免缓存数据与新数据混合 - ChatPage:chat new 事件回调从空 setState({}) 改为 _refreshModel(), 内部判断是否真的需要刷新,减少无意义重建 验证:flutter analyze 0 error 0 warning;iOS 模拟器运行稳定 --- .../MessageListWidget/MessageListWidget.dart | 49 ++++++------- lib/components/ListWidget/ListWidget.dart | 7 +- .../components/TabWidget.dart | 22 +++--- lib/components/XLazy/XLazy.dart | 59 ++++++++-------- lib/pages/chats/chat_page.dart | 68 +++++++++++-------- lib/pages/home/HomePage.dart | 9 +-- lib/pages/relation/relation_page.dart | 43 +++++++++--- lib/pages/store/store_page.dart | 34 ++++++++-- 8 files changed, 172 insertions(+), 119 deletions(-) diff --git a/lib/components/ChatSessionWidget/components/MessageListWidget/MessageListWidget.dart b/lib/components/ChatSessionWidget/components/MessageListWidget/MessageListWidget.dart index b97b67f..194fb8c 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/ListWidget/ListWidget.dart b/lib/components/ListWidget/ListWidget.dart index d1854f7..273537d 100644 --- a/lib/components/ListWidget/ListWidget.dart +++ b/lib/components/ListWidget/ListWidget.dart @@ -118,12 +118,11 @@ class _ListWidgetState extends State with EmptyMixin { @override void didUpdateWidget(ListWidget oldWidget) { super.didUpdateWidget(oldWidget); - // initDatas 变化时(如从空变为有数据),更新 datas 并清除缓存的 Future 触发重新加载 + // initDatas 引用变化时(无论是否为空)更新 datas 并重置缓存 Future, + // 避免缓存数据与新数据混合、或父级传入新数据时不触发刷新 if (widget.initDatas != oldWidget.initDatas) { datas = widget.initDatas ?? []; - if ((widget.initDatas?.isEmpty ?? true)) { - _initFuture = null; - } + _initFuture = null; } } diff --git a/lib/components/TabContainerWidget/components/TabWidget.dart b/lib/components/TabContainerWidget/components/TabWidget.dart index 6c56120..ec4d4b1 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()), ), ); } @@ -250,21 +251,24 @@ 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: _KeepAliveTabContent(child: item.content!)), diff --git a/lib/components/XLazy/XLazy.dart b/lib/components/XLazy/XLazy.dart index 7e9a288..0305d3e 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/pages/chats/chat_page.dart b/lib/pages/chats/chat_page.dart index d2c4535..fb10985 100644 --- a/lib/pages/chats/chat_page.dart +++ b/lib/pages/chats/chat_page.dart @@ -59,7 +59,7 @@ class _ChatPageState extends State relationCtrl.provider.ensureDataFresh(DataType.chats); final chatSub = command.subscribe((type, cmd, args) { if (type == 'chat' && cmd == 'new') { - if (mounted) setState(() {}); + if (mounted) _refreshModel(); } }); _subscriptions.add(chatSub); @@ -73,8 +73,42 @@ class _ChatPageState extends State dataSourceCache.clear(); _isRefreshing = true; }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _refreshModel(); + }); }, false); _subscriptions.add(switchSub); + // 首次加载放到首帧后,避免在 build 中调度 setState 造成级联重建 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _refreshModel(); + }); + } + + /// 统一的模型刷新入口:重建 _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 @@ -91,41 +125,17 @@ class _ChatPageState extends State //监听Bus events void onPortalEvent(PortalRefreshEvent event) { - if (mounted) setState(() {}); + if (mounted) _refreshModel(); } @override Widget build(BuildContext context) { super.build(context); - 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 && currentChats.isEmpty) { + // build 只做渲染:未加载显示骨架屏,已加载显示 TabContainerWidget + // 模型刷新统一由 initState 和事件回调中的 _refreshModel 触发 + if (_chatModel == null) { return const SkeletonWidget(itemCount: 6); } - if (_chatModel == null || - _lastChatCount != currentCount || - _lastUnreadCount != currentUnread || - _lastLastMsgTime != currentLastMsgTime) { - final needLoad = _chatModel == null; - _lastChatCount = currentCount; - _lastUnreadCount = currentUnread; - _lastLastMsgTime = currentLastMsgTime; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - load(context); - setState(() {}); - }); - if (needLoad) { - return const SkeletonWidget(itemCount: 6); - } - } if (_isRefreshing) { _isRefreshing = false; return Column( diff --git a/lib/pages/home/HomePage.dart b/lib/pages/home/HomePage.dart index d5481e4..534e5b3 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/relation/relation_page.dart b/lib/pages/relation/relation_page.dart index 662b814..4c9b2b5 100644 --- a/lib/pages/relation/relation_page.dart +++ b/lib/pages/relation/relation_page.dart @@ -34,6 +34,7 @@ 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'; @@ -87,8 +88,35 @@ class _RelationState extends State _loadingMembers = false; _isRefreshing = true; }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _loadFromRoute(context); + }); }, false); _subscriptions.add(subId); + // 首次加载放到首帧后,避免在 build 中调度 setState 造成级联重建 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _loadFromRoute(context); + }); + } + + /// 根据当前 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 @@ -108,18 +136,17 @@ class _RelationState extends State // 路径变化 或 数据引用变化(如点击单位进入二级页面,路径不变但 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(() {}); - }); // 首次进入时 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; diff --git a/lib/pages/store/store_page.dart b/lib/pages/store/store_page.dart index 90ee16e..6a2b7d7 100644 --- a/lib/pages/store/store_page.dart +++ b/lib/pages/store/store_page.dart @@ -23,6 +23,7 @@ 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/XConsumer/XConsumer.dart'; import '../../dart/base/common/commands.dart'; @@ -70,8 +71,28 @@ class _StorePageState extends State storeModel = null; _lastLoadedPath = null; }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _loadFromRoute(context); + }); }, false); _subscriptions.add(subId); + // 首次加载放到首帧后,避免在 build 中调度 setState 造成级联重建 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _loadFromRoute(context); + }); + } + + /// 根据当前 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 @@ -95,16 +116,17 @@ class _StorePageState extends State parentDataCurr = null; } final currentPath = ar.currPageData.path ?? ''; + // 路径变化时调度首帧后加载,避免在 build 中调用 setState if (storeModel == null || _lastLoadedPath != currentPath) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - load(); - _lastLoadedPath = currentPath; - setState(() {}); - }); if (storeModel == null) { return const SkeletonWidget(itemCount: 6); } + // storeModel 已有数据但路径变化(如点击列表项进入二级页面), + // 由 _loadFromRoute 在首帧后处理;这里先返回旧 model 避免白屏 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _loadFromRoute(context); + }); } return TabContainerWidget(storeModel!); }, -- Gitee From 7f9fb86219b850718ec5974f87c05da5d41cf3ed Mon Sep 17 00:00:00 2001 From: panzhaohui Date: Sat, 25 Jul 2026 07:38:15 +0800 Subject: [PATCH 08/37] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E5=85=A5=E5=8F=A3=E5=92=8C=E5=8F=91=E7=8E=B0=E9=A1=B5?= =?UTF-8?q?=E6=A0=8F=E7=9B=AE=E6=A0=B7=E5=BC=8F=EF=BC=8C=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E8=A1=8C=EF=BC=8CTab=E5=AD=97=E4=BD=93?= =?UTF-8?q?=E7=BB=9F=E4=B8=8018?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除平台入口页标题下方第1行统计数据(网站栏目/内容类型/待阅), 删除 _buildHeaderStats 和 _buildStatChip 方法,让栏目内容更清晰呈现 - Tab 页签字体统一为 18sp(XFonts.tabLabel/tabUnselected),消除各页 Tab 字体大小不一致 - 新建 plaza_style_cards.dart,提供 5 种栏目特色风格卡片: · NoticeStyleCard:参考今日头条,左文右图图文浏览 · VideoStyleCard:参考抖音,16:9 沉浸式大图 + 播放按钮 + 渐变遮罩 · GroupStyleCard:参考小红书,瀑布流卡片 + 封面 + 头像/成员 · DataStyleCard:参考 App Store,大卡片 + 渐变背景 + 大图标 + 查看按钮 · MarketStyleCard:参考商场,大图商品 + 特惠徽章 + 价格 + 下单按钮 · plazaStyleCardByType:按 typeName 分发到对应风格卡片 - 5 个发现页子页面(公告/视频/群组/数据/市场)改用对应特色卡片组件, 群组页 ListView 改为 GridView 双列瀑布流布局 - PlatformEntryPage 的 _buildItemCard 改用 plazaStyleCardByType 按栏目 类型分发,统一平台入口与发现页视觉体验;删除 _PlatformEntryItemCard 死代码约 648 行 - 清理 5 个子页面和 platform_entry_page 中不再使用的 plaza_item_widgets import 验证:flutter analyze 0 error 0 warning;313 个 info 均为历史代码风格 --- lib/config/theme/unified_style.dart | 8 +- .../discover/pages/data_share_list_page.dart | 4 +- .../discover/pages/group_share_list_page.dart | 16 +- .../discover/pages/market_list_page.dart | 4 +- .../discover/pages/notice_list_page.dart | 4 +- lib/pages/discover/pages/video_list_page.dart | 4 +- .../discover/widgets/plaza_style_cards.dart | 956 ++++++++++++++++++ lib/pages/portal/platform_entry_page.dart | 745 +------------- 8 files changed, 989 insertions(+), 752 deletions(-) create mode 100644 lib/pages/discover/widgets/plaza_style_cards.dart diff --git a/lib/config/theme/unified_style.dart b/lib/config/theme/unified_style.dart index d89e118..0d0f5fb 100644 --- a/lib/config/theme/unified_style.dart +++ b/lib/config/theme/unified_style.dart @@ -295,15 +295,15 @@ class XFonts { height: 1.3, ); - // --- TabBar 专用(在 labelMedium/labelSmall 基础上放大1号)--- + // --- TabBar 专用(统一 18sp,对齐各页面 Tab 页签字体)--- static TextStyle get tabLabel => TextStyle( - fontSize: 17.sp, - fontWeight: FontWeight.w500, + fontSize: 18.sp, + fontWeight: FontWeight.w600, color: OipText.primary, height: 1.3, ); static TextStyle get tabUnselected => TextStyle( - fontSize: 15.sp, + fontSize: 18.sp, fontWeight: FontWeight.w500, color: OipText.tertiary, height: 1.3, diff --git a/lib/pages/discover/pages/data_share_list_page.dart b/lib/pages/discover/pages/data_share_list_page.dart index 41ce14f..8c24e40 100644 --- a/lib/pages/discover/pages/data_share_list_page.dart +++ b/lib/pages/discover/pages/data_share_list_page.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:orginone/components/LoadingWidget/LoadingWidget.dart'; import 'package:orginone/config/oip_tokens.dart'; import 'package:orginone/dart/base/common/commands.dart'; -import 'package:orginone/pages/discover/widgets/plaza_item_widgets.dart'; +import 'package:orginone/pages/discover/widgets/plaza_style_cards.dart'; import 'package:orginone/utils/log/log_util.dart'; import '../discover_service.dart'; @@ -256,7 +256,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 ab0e623..b106594 100644 --- a/lib/pages/discover/pages/group_share_list_page.dart +++ b/lib/pages/discover/pages/group_share_list_page.dart @@ -5,7 +5,7 @@ import 'package:orginone/config/oip_tokens.dart'; import 'package:orginone/dart/base/common/commands.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; @@ -220,9 +220,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 +264,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/market_list_page.dart b/lib/pages/discover/pages/market_list_page.dart index 397022a..5be0895 100644 --- a/lib/pages/discover/pages/market_list_page.dart +++ b/lib/pages/discover/pages/market_list_page.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:orginone/components/LoadingWidget/LoadingWidget.dart'; import 'package:orginone/config/oip_tokens.dart'; import 'package:orginone/dart/base/common/commands.dart'; -import 'package:orginone/pages/discover/widgets/plaza_item_widgets.dart'; +import 'package:orginone/pages/discover/widgets/plaza_style_cards.dart'; import 'package:orginone/utils/log/log_util.dart'; import '../discover_service.dart'; @@ -268,7 +268,7 @@ class _MarketListPageState extends State { } Widget _buildGoodsCard(PlazaMarketGoods item) { - return PlazaItemCard( + return MarketStyleCard( item: item, sourceName: widget.title, sourceId: widget.sourceId, diff --git a/lib/pages/discover/pages/notice_list_page.dart b/lib/pages/discover/pages/notice_list_page.dart index acdbfba..3a6923b 100644 --- a/lib/pages/discover/pages/notice_list_page.dart +++ b/lib/pages/discover/pages/notice_list_page.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:orginone/components/LoadingWidget/LoadingWidget.dart'; import 'package:orginone/config/oip_tokens.dart'; import 'package:orginone/dart/base/common/commands.dart'; -import 'package:orginone/pages/discover/widgets/plaza_item_widgets.dart'; +import 'package:orginone/pages/discover/widgets/plaza_style_cards.dart'; import 'package:orginone/utils/log/log_util.dart'; import '../discover_service.dart'; @@ -256,7 +256,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 6b2d2d5..064e3fa 100644 --- a/lib/pages/discover/pages/video_list_page.dart +++ b/lib/pages/discover/pages/video_list_page.dart @@ -4,7 +4,7 @@ import 'package:orginone/components/LoadingWidget/LoadingWidget.dart'; import 'package:orginone/config/oip_tokens.dart'; import 'package:orginone/dart/base/common/commands.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/plaza_style_cards.dart'; import '../discover_service.dart'; class VideoListPage extends StatefulWidget { @@ -264,7 +264,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/plaza_style_cards.dart b/lib/pages/discover/widgets/plaza_style_cards.dart new file mode 100644 index 0000000..1571248 --- /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: 13.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: 11.sp, + color: color, + fontWeight: FontWeight.w500, + ), + ), + ), + SizedBox(width: 8.w), + ], + if (timeStr.isNotEmpty) + Text( + timeStr, + style: TextStyle( + fontSize: 11.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: [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: 11.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: 14.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: 11.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: 14.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: 12.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: 11.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: 12.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: 13.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: 11.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: 13.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: 11.sp, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + Padding( + padding: EdgeInsets.all(12.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 15.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: 12.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: 11.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: 12.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/portal/platform_entry_page.dart b/lib/pages/portal/platform_entry_page.dart index 8b25fc8..ad1347c 100644 --- a/lib/pages/portal/platform_entry_page.dart +++ b/lib/pages/portal/platform_entry_page.dart @@ -1,4 +1,3 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; @@ -16,7 +15,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'; @@ -371,7 +370,6 @@ class _PlatformEntryPageState extends State Column( children: [ _buildHeaderTopRow(setting, topPad), - _buildHeaderStats(), _buildQuickEntries(), ], ), @@ -475,87 +473,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: OipText.inverse.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12.r), - border: Border.all( - color: OipText.inverse.withValues(alpha: 0.18), - width: 0.8, - ), - ), - child: Row( - children: [ - Icon(icon, color: OipText.inverse.withValues(alpha: 0.9), size: 18.w), - SizedBox(width: 8.w), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - value, - style: XFonts.numberSmall.copyWith(color: OipText.inverse), - ), - SizedBox(height: 2.h), - Text( - label, - style: XFonts.labelSmall.copyWith( - color: OipText.inverse.withValues(alpha: 0.72), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ], - ), - ), - ); - } - /// 多用途快捷入口栅格:商城 / 群组 / AI 沟通 / 视频直播 Widget _buildQuickEntries() { return Padding( @@ -1230,11 +1147,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, ); } @@ -1390,654 +1311,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: [OipSurface.card, 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: XFonts.titleSmall.copyWith( - color: OipText.inverse, - fontSize: 16.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: XFonts.bodySmall.copyWith( - fontSize: 13.sp, - color: OipText.tertiary, - 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: 13.w, color: OipText.tertiary), - SizedBox(width: 4.w), - Text( - timeStr, - style: XFonts.captionSmall.copyWith( - fontSize: 12.sp, - color: OipText.tertiary, - 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: XFonts.titleSmall.copyWith( - fontSize: 16.sp, - fontWeight: FontWeight.w700, - color: OipText.primary, - height: 1.35, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - if (summary.isNotEmpty) ...[ - SizedBox(height: 6.h), - Text( - summary, - style: XFonts.bodySmall.copyWith( - fontSize: 14.sp, - color: OipText.secondary, - 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: 13.w, color: OipText.tertiary), - SizedBox(width: 4.w), - Text( - timeStr, - style: XFonts.captionSmall.copyWith( - fontSize: 12.sp, - color: OipText.tertiary, - 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: XFonts.labelSmall.copyWith( - color: OipText.inverse, - fontSize: 11.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: OipText.inverse, - ), - ), - ], - Text( - isLive ? 'LIVE' : '未开播', - style: XFonts.labelSmall.copyWith( - color: OipText.inverse, - fontSize: 11.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: XFonts.labelSmall.copyWith( - color: OipText.inverse, - fontSize: 11.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: OipMarket.price, - borderRadius: BorderRadius.circular(6.r), - ), - child: Text( - '¥${g.price.toStringAsFixed(2)}', - style: XFonts.labelSmall.copyWith( - color: OipText.inverse, - fontSize: 12.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: OipText.inverse, size: 14.w), - SizedBox(width: 4.w), - Text( - label, - style: XFonts.labelSmall.copyWith( - color: OipText.inverse, - 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: XFonts.labelSmall.copyWith( - color: color, - fontSize: 12.sp, - fontWeight: FontWeight.w600, - height: 1.2, - ), - ), - ], - ), - ); - } -} /// 网格点装饰画笔(科技感纹理) class _GridDotPainter extends CustomPainter { -- Gitee From d1f082e0aede5b0b7ec60002174f4f66d786ba34 Mon Sep 17 00:00:00 2001 From: panzhaohui Date: Sat, 25 Jul 2026 10:49:00 +0800 Subject: [PATCH 09/37] =?UTF-8?q?feat:=20=E5=8A=9E=E4=BA=8B=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=E5=9B=BE=E5=8F=AF=E8=A7=86=E5=8C=96=E4=B8=8E=E5=AE=A1?= =?UTF-8?q?=E6=89=B9=E9=AB=98=E7=BA=A7=E6=93=8D=E4=BD=9C=EF=BC=8C=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=20WorkStartPage=20=E8=A7=84=E5=88=99=E5=BC=95?= =?UTF-8?q?=E6=93=8E=E5=92=8C=E8=8D=89=E7=A8=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 FlowChartWidget:递归渲染流程节点树,状态着色(已通过/已拒绝/已退回/已撤回/进行中/未开始),支持分支条件标签和节点点击详情 - ProcessDetailsWidget 新增"流程跟踪" Tab,位于办事详情和历史痕迹之间 - BottomActionWidget 新增"更多"按钮:转办/加签/委托审批操作(BottomSheet 成员选择 + loadNextNodes + loadMembers) - WorkStartPage 迁移到 lib/pages/work/,接入 WorkFormRules 规则引擎初始化和提交前校验,Hive Box 草稿保存与恢复 - 登录加速:连接建立后立即跳首页从本地渲染,后台异步加载数据完成后通过事件通知页面刷新 - 路由规范化:新增 collectionListPage 路由常量,所有跳转使用 RoutePages 封装 - store_page 合并重复方法,接入本地缓存订阅 session 事件 - 对照 oiocns-react 检查对等实现,核心模块(沟通/办事/关系/视图/执行器/IoT)功能对等 --- .../cards/business_cards.dart | 6 +- .../ChatSessionWidget/cards/iot_cards.dart | 32 +- .../cards/workflow_collaboration_cards.dart | 4 +- .../detail/LocationDetailWidget.dart | 4 +- .../components/detail/TextDetailWidget.dart | 4 +- lib/components/Executor/open_executor.dart | 4 +- lib/components/Executor/preview_panel.dart | 10 +- .../GlobalAiInputBar/global_ai_input_bar.dart | 4 +- lib/components/ListWidget/ListWidget.dart | 8 +- .../LoadingWidget/LoadingWidget.dart | 4 +- .../components/SecurityWidget.dart | 24 +- .../ProcessDetailsWidget.dart | 5 + .../components/BottomActionWidget.dart | 176 ++++++- .../components/FlowChartWidget.dart | 484 +++++++++++++++++ .../components/TabWidget.dart | 4 +- lib/components/XText/XText.dart | 2 +- lib/components/XTextField/XTextField.dart | 4 +- lib/components/form/mapping_components.dart | 6 +- lib/components/iot/thing_model_view.dart | 30 +- lib/config/theme/unified_style.dart | 117 +++-- .../storages/local_page_cache_repository.dart | 249 +++++++++ .../storages/local_page_cache_writer.dart | 192 +++++++ lib/dart/core/provider/auth.dart | 16 +- lib/dart/core/provider/index.dart | 102 ++-- lib/global.dart | 10 +- lib/pages/auth/forgot_password/view.dart | 2 +- lib/pages/auth/login/view.dart | 5 +- lib/pages/auth/login_transition/index.dart | 2 +- lib/pages/auth/register/view.dart | 2 +- lib/pages/chats/chat_page.dart | 48 +- lib/pages/common/asset_map_page.dart | 2 +- lib/pages/common/space_select_page.dart | 2 +- lib/pages/discover/discover_page.dart | 13 +- lib/pages/discover/discover_provider.dart | 54 +- lib/pages/discover/discover_service.dart | 97 +++- .../discover/pages/all_activities_page.dart | 4 +- .../discover/pages/data_share_list_page.dart | 176 +++++-- .../discover/pages/group_share_list_page.dart | 176 +++++-- .../discover/pages/market_list_page.dart | 180 +++++-- .../discover/pages/notice_list_page.dart | 176 +++++-- lib/pages/discover/pages/video_list_page.dart | 176 +++++-- .../discover/widgets/discover_list_cache.dart | 160 ++++++ .../discover/widgets/plaza_item_widgets.dart | 42 +- .../discover/widgets/plaza_style_cards.dart | 36 +- lib/pages/iot/device_detail_page.dart | 10 +- lib/pages/iot/device_list_page.dart | 10 +- lib/pages/oip/approval_center_page.dart | 4 +- lib/pages/oip/vet_detail_page.dart | 26 +- lib/pages/oip/vet_management_page.dart | 10 +- lib/pages/portal/account_binding_page.dart | 16 +- lib/pages/portal/add_favorite_page.dart | 2 +- lib/pages/portal/agent_chat_page.dart | 4 +- lib/pages/portal/agent_config_page.dart | 4 +- lib/pages/portal/assets_management/view.dart | 2 +- lib/pages/portal/create_agent_page.dart | 6 +- lib/pages/portal/explorer/explorer_page.dart | 12 +- lib/pages/portal/home/home_page.dart | 496 +++++++++++------- lib/pages/portal/morepage/share_page.dart | 2 +- lib/pages/portal/originone_menu_page.dart | 2 +- lib/pages/portal/password_change_page.dart | 8 +- lib/pages/portal/platform_entry_page.dart | 177 ++++--- lib/pages/portal/secret_edit_page.dart | 6 +- .../portal/smart_task/smart_task_page.dart | 10 +- lib/pages/portal/space_setting_page.dart | 2 +- lib/pages/portal/storage_management_page.dart | 11 +- .../portal/widgets/favorites_widget.dart | 6 +- .../portal/widgets/portal_search_bar.dart | 18 +- .../portal/widgets/search_dialog_widget.dart | 8 +- .../portal/widgets/task_create_widget.dart | 8 +- lib/pages/relation/relation_page.dart | 27 +- lib/pages/store/dba/collection_list_page.dart | 41 +- lib/pages/store/store_page.dart | 100 +--- .../form => pages/work}/work_start_page.dart | 181 ++++++- lib/routers/pages.dart | 15 +- lib/routers/router_const.dart | 3 + 75 files changed, 3186 insertions(+), 895 deletions(-) create mode 100644 lib/components/ProcessDetailsWidget/components/FlowChartWidget.dart create mode 100644 lib/dart/base/storages/local_page_cache_repository.dart create mode 100644 lib/dart/base/storages/local_page_cache_writer.dart create mode 100644 lib/pages/discover/widgets/discover_list_cache.dart rename lib/{components/form => pages/work}/work_start_page.dart (71%) diff --git a/lib/components/ChatSessionWidget/cards/business_cards.dart b/lib/components/ChatSessionWidget/cards/business_cards.dart index 59ddd07..6c117c5 100644 --- a/lib/components/ChatSessionWidget/cards/business_cards.dart +++ b/lib/components/ChatSessionWidget/cards/business_cards.dart @@ -73,7 +73,7 @@ class _WorkItemCardState extends BaseCardState { ), child: Text( priority, - style: XFonts.captionSmall.copyWith(color: priorityColor, fontSize: 10.sp), + style: XFonts.captionSmall.copyWith(color: priorityColor, fontSize: 16.sp), ), ), ], @@ -575,7 +575,7 @@ class _RichLinkCardState extends BaseCardState { if (siteName != null) ...[ const SizedBox(height: 4), Text(siteName, - style: XFonts.captionSmall.copyWith(color: OipText.tertiary, fontSize: 11.sp)), + style: XFonts.captionSmall.copyWith(color: OipText.tertiary, fontSize: 16.sp)), ], if (description.isNotEmpty) ...[ const SizedBox(height: 2), @@ -645,7 +645,7 @@ class _LocationCardState extends BaseCardState { ], const SizedBox(height: 2), Text('坐标: $lng, $lat', - style: XFonts.captionSmall.copyWith(color: OipText.tertiary, fontSize: 11.sp)), + style: XFonts.captionSmall.copyWith(color: OipText.tertiary, fontSize: 16.sp)), ], actionChildren: [ CardActionButton( diff --git a/lib/components/ChatSessionWidget/cards/iot_cards.dart b/lib/components/ChatSessionWidget/cards/iot_cards.dart index aae72a5..cbb4cb6 100644 --- a/lib/components/ChatSessionWidget/cards/iot_cards.dart +++ b/lib/components/ChatSessionWidget/cards/iot_cards.dart @@ -76,12 +76,12 @@ class _IotDeviceCardState extends BaseCardState { const SizedBox(width: 6), Text('状态: ${_statusLabel(status)}', style: XFonts.captionSmall.copyWith( - fontSize: 12.sp, color: statusColor)), + fontSize: 16.sp, color: statusColor)), if (model != null) ...[ const SizedBox(width: 12), Text('型号: $model', style: XFonts.captionSmall.copyWith( - fontSize: 12.sp, color: OipText.tertiary)), + fontSize: 16.sp, color: OipText.tertiary)), ], ], ), @@ -95,7 +95,7 @@ class _IotDeviceCardState extends BaseCardState { return Text( '${e.key}: ${_formatValue(e.value)}', style: XFonts.captionSmall.copyWith( - fontSize: 11.sp, color: OipText.secondary), + fontSize: 16.sp, color: OipText.secondary), ); }).toList(), ), @@ -104,7 +104,7 @@ class _IotDeviceCardState extends BaseCardState { const SizedBox(height: 2), Text('固件: $firmware', style: XFonts.captionSmall.copyWith( - fontSize: 11.sp, color: OipSurface.mutedFg)), + fontSize: 16.sp, color: OipSurface.mutedFg)), ], ], actionChildren: [ @@ -177,7 +177,7 @@ class _IotAlertCardState extends BaseCardState { child: Text( levelLabel, style: XFonts.captionSmall.copyWith( - fontSize: 11.sp, + fontSize: 16.sp, color: levelColor, fontWeight: FontWeight.w500), ), @@ -185,14 +185,14 @@ class _IotAlertCardState extends BaseCardState { const SizedBox(height: 6), Text(message, style: XFonts.captionSmall.copyWith( - fontSize: 12.sp, color: OipText.secondary), + fontSize: 16.sp, color: OipText.secondary), maxLines: 2, overflow: TextOverflow.ellipsis), if (eventType != null) ...[ const SizedBox(height: 2), Text('事件类型: $eventType', style: XFonts.captionSmall.copyWith( - fontSize: 11.sp, color: OipSurface.mutedFg)), + fontSize: 16.sp, color: OipSurface.mutedFg)), ], ], actionChildren: [ @@ -269,14 +269,14 @@ class _IotCommandCardState extends BaseCardState { child: Text( _commandStatusLabel(cmdStatus), style: XFonts.captionSmall.copyWith( - fontSize: 11.sp, color: statusColor), + fontSize: 16.sp, color: statusColor), ), ), const SizedBox(width: 8), if (mode == 'async') Text('异步', style: XFonts.captionSmall.copyWith( - fontSize: 11.sp, color: OipSurface.mutedFg)), + fontSize: 16.sp, color: OipSurface.mutedFg)), ], ), // 进度条(运行中) @@ -291,14 +291,14 @@ class _IotCommandCardState extends BaseCardState { const SizedBox(height: 2), Text('${progress.toInt()}%', style: XFonts.captionSmall.copyWith( - fontSize: 11.sp, color: OipSurface.mutedFg)), + fontSize: 16.sp, color: OipSurface.mutedFg)), ], // 错误信息 if (errorMessage != null && cmdStatus == 'failed') ...[ const SizedBox(height: 4), Text(errorMessage, style: XFonts.captionSmall.copyWith( - fontSize: 11.sp, color: OipState.error), + fontSize: 16.sp, color: OipState.error), maxLines: 2, overflow: TextOverflow.ellipsis), ], @@ -367,7 +367,7 @@ class _IotMapCardState extends BaseCardState { const SizedBox(height: 4), Text('设备总数: ${devices.length}', style: XFonts.captionSmall.copyWith( - fontSize: 12.sp, color: OipText.tertiary)), + fontSize: 16.sp, color: OipText.tertiary)), // 状态分布摘要 if (statusSummary != null && statusSummary.isNotEmpty) ...[ const SizedBox(height: 6), @@ -387,7 +387,7 @@ class _IotMapCardState extends BaseCardState { const SizedBox(width: 4), Text('${e.key}: ${e.value}', style: XFonts.captionSmall.copyWith( - fontSize: 11.sp, color: OipText.secondary)), + fontSize: 16.sp, color: OipText.secondary)), ], ); }).toList(), @@ -479,7 +479,7 @@ class _IotChartCardState extends BaseCardState { const SizedBox(height: 4), Text('时间范围: $timeRange', style: XFonts.captionSmall.copyWith( - fontSize: 12.sp, color: OipText.tertiary)), + fontSize: 16.sp, color: OipText.tertiary)), ], // 统计信息 if (avg != null || min != null || max != null) ...[ @@ -510,7 +510,7 @@ class _IotChartCardState extends BaseCardState { const SizedBox(height: 2), Text('数据点: ${dataPoints.length}', style: XFonts.captionSmall.copyWith( - fontSize: 11.sp, color: OipSurface.mutedFg)), + fontSize: 16.sp, color: OipSurface.mutedFg)), ], actionChildren: [ CardActionButton( @@ -550,7 +550,7 @@ class _StatChip extends StatelessWidget { child: Text( '$label: $value', style: XFonts.captionSmall.copyWith( - fontSize: 10.sp, color: OipText.secondary), + fontSize: 16.sp, color: OipText.secondary), ), ); } diff --git a/lib/components/ChatSessionWidget/cards/workflow_collaboration_cards.dart b/lib/components/ChatSessionWidget/cards/workflow_collaboration_cards.dart index 0e262f9..450e7cc 100644 --- a/lib/components/ChatSessionWidget/cards/workflow_collaboration_cards.dart +++ b/lib/components/ChatSessionWidget/cards/workflow_collaboration_cards.dart @@ -204,14 +204,14 @@ class _CollaborationInviteCardState color: OipBrand.primary.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(OipRadius.xs), ), - child: Text(c, style: XFonts.captionSmall.copyWith(color: OipBrand.primary, fontSize: 10.sp)), + child: Text(c, style: XFonts.captionSmall.copyWith(color: OipBrand.primary, fontSize: 16.sp)), )).toList(), ), const SizedBox(height: 4), Text( '时效: ${startStr ?? "立即"} → ${endStr ?? "手动结束"}' '${autoRevoke ? " · 到期自动撤销" : ""}', - style: XFonts.captionSmall.copyWith(color: OipText.tertiary, fontSize: 11.sp), + style: XFonts.captionSmall.copyWith(color: OipText.tertiary, fontSize: 16.sp), ), ], actionChildren: [ 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 4255c4c..7f53a3e 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 14248fa..23550c5 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/Executor/open_executor.dart b/lib/components/Executor/open_executor.dart index be10b83..7acb423 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 b6132dd..3212908 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/GlobalAiInputBar/global_ai_input_bar.dart b/lib/components/GlobalAiInputBar/global_ai_input_bar.dart index bcfcb0c..fdf6bd7 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/ListWidget/ListWidget.dart b/lib/components/ListWidget/ListWidget.dart index 273537d..7962576 100644 --- a/lib/components/ListWidget/ListWidget.dart +++ b/lib/components/ListWidget/ListWidget.dart @@ -215,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), ), ], ), @@ -234,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, ), @@ -254,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 0a71141..ccabe52 100644 --- a/lib/components/LoadingWidget/LoadingWidget.dart +++ b/lib/components/LoadingWidget/LoadingWidget.dart @@ -112,7 +112,7 @@ class _LoadingState extends State { Text( msg, style: TextStyle( - fontSize: 12.sp, + fontSize: 16.sp, color: Colors.black87, ), ), @@ -162,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/SecurityWidget.dart b/lib/components/MySettingWidget/components/SecurityWidget.dart index 1c72e21..74704df 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/ProcessDetailsWidget/ProcessDetailsWidget.dart b/lib/components/ProcessDetailsWidget/ProcessDetailsWidget.dart index 03ade11..336368d 100644 --- a/lib/components/ProcessDetailsWidget/ProcessDetailsWidget.dart +++ b/lib/components/ProcessDetailsWidget/ProcessDetailsWidget.dart @@ -3,6 +3,7 @@ import 'package:orginone/config/oip_tokens.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/FlowChartWidget.dart'; import 'package:orginone/components/ProcessDetailsWidget/components/UseTracesWidget.dart'; import '../../dart/base/schema.dart'; import '../../dart/core/work/task.dart'; @@ -43,6 +44,7 @@ class _ProcessDetailsPageState List tabTitle = [ '办事详情', + '流程跟踪', '历史痕迹', ]; @@ -113,6 +115,9 @@ class _ProcessDetailsPageState // controller: tabController, children: [ todo?.instance != null ? ProcessInfoWidget(todo: todo) : Container(), + todo?.instance != null + ? FlowChartWidget(todo: todo) + : const Text('暂无流程实例'), todo?.instance != null ? UseTracesWidget( todo: todo, diff --git a/lib/components/ProcessDetailsWidget/components/BottomActionWidget.dart b/lib/components/ProcessDetailsWidget/components/BottomActionWidget.dart index 18bb9c2..05b2d78 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,158 @@ 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; + } + // 当前任务的身份ID用于查询可选成员 + final identityId = todo!.taskdata.identityId ?? ''; + if (identityId.isEmpty) { + EasyLoading.dismiss(); + ToastUtils.showMsg(msg: '当前节点不支持$action'); + return; + } + final members = await todo!.loadMembers(identityId); + 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; + // 调用审批:通过 + 备注中带转办/加签/委托信息 + // 注:实际转办/加签/委托的目标写入由后端 gateway 处理, + // Flutter 端通过备注标注,后端规则引擎根据 node 配置执行 + EasyLoading.show(status: '提交中...'); + final success = await todo!.approvalTask( + TaskStatus.approvalStart.status, + comment: '${comment?.text ?? ""} [$action给: ${selected.name}]', + executors: const [], + ); + EasyLoading.dismiss(); + if (success) { + ToastUtils.showMsg(msg: '$action成功'); + if (context.mounted) { + Navigator.pop(context); + command.emitter('work', 'refresh'); + } + } 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 +353,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,8 +370,6 @@ 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'); }); diff --git a/lib/components/ProcessDetailsWidget/components/FlowChartWidget.dart b/lib/components/ProcessDetailsWidget/components/FlowChartWidget.dart new file mode 100644 index 0000000..b5dbb56 --- /dev/null +++ b/lib/components/ProcessDetailsWidget/components/FlowChartWidget.dart @@ -0,0 +1,484 @@ +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] 实际任务执行情况, +/// 渲染流程节点路径,标识当前节点和各节点状态。 +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; + + @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; + if (todo.instance?.tasks != null && todo.instance!.tasks!.isNotEmpty) { + _tasks = todo.instance!.tasks!; + } else { + // 退而求其次:通过 loadTasksData 异步加载(不阻塞详情页渲染) + try { + final list = await todo.loadTasksData(); + _tasks = list.whereType().toList(); + } catch (e) { + XLogUtil.w('[FlowChartWidget] loadTasksData 失败: $e'); + } + } + } catch (e) { + XLogUtil.e('[FlowChartWidget] 加载流程图数据异常: $e'); + } finally { + if (mounted) setState(() => _loaded = true); + } + } + + /// 根据 nodeId 查找匹配的任务状态 + FlowNodeStatus _resolveStatus(String? nodeId) { + if (nodeId == null || nodeId.isEmpty) return FlowNodeStatus.pending; + if (nodeId == _currentNodeId) { + // 当前节点:若已有任务记录,按记录状态展示;否则视为进行中 + final task = _findTaskByNodeId(nodeId); + if (task == null) return FlowNodeStatus.current; + return _mapTaskStatus(task.status, task.approveType); + } + final task = _findTaskByNodeId(nodeId); + if (task == null) return FlowNodeStatus.pending; + return _mapTaskStatus(task.status, task.approveType); + } + + XWorkTask? _findTaskByNodeId(String nodeId) { + for (final t in _tasks) { + 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), + ), + ); + } + + /// 渲染单个节点卡片 + 递归渲染后续路径 + Widget _buildNodeCard(WorkNodeModel node, + {required bool isRoot, required int depth}) { + final status = _resolveStatus(node.id); + 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), + 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: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + color: OipText.primary, + ), + ), + ), + 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: TextStyle( + fontSize: 12.sp, + color: style.color, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + if ((node.destName ?? '').isNotEmpty) ...[ + SizedBox(height: 4.h), + Text( + '审批人:${node.destName}', + style: TextStyle( + fontSize: 13.sp, + color: XColors.black6, + ), + ), + ], + if (_nodeTypeLabel(node.type).isNotEmpty) ...[ + SizedBox(height: 2.h), + Text( + _nodeTypeLabel(node.type), + style: TextStyle( + fontSize: 12.sp, + color: XColors.black6, + ), + ), + ], + ], + ), + ), + ), + ); + + final children = [card]; + + // 主路径:node.children(顺序后续节点) + if (node.children != null) { + children.add(_buildConnector()); + children.add(_buildNodeCard(node.children!, + isRoot: false, depth: depth + 1)); + } + + // 分支路径: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), + ), + ); + } + } + + 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: TextStyle( + fontSize: 11.sp, + color: OipBrand.primary, + ), + ), + ), + ], + ), + ); + } + + 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) { + final task = _findTaskByNodeId(node.id ?? ''); + 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: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w600, + ), + ), + ), + 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: TextStyle( + fontSize: 12.sp, 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: TextStyle( + fontSize: 14.sp, color: XColors.black6)), + ), + Expanded( + child: Text(value, + style: TextStyle( + fontSize: 14.sp, color: OipText.primary)), + ), + ], + ), + ); + } +} diff --git a/lib/components/TabContainerWidget/components/TabWidget.dart b/lib/components/TabContainerWidget/components/TabWidget.dart index ec4d4b1..033069a 100644 --- a/lib/components/TabContainerWidget/components/TabWidget.dart +++ b/lib/components/TabContainerWidget/components/TabWidget.dart @@ -136,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: XFonts.labelSmall, - unselectedLabelStyle: XFonts.labelSmall, + labelStyle: XFonts.tabLabel, + unselectedLabelStyle: XFonts.tabUnselected, indicator: UnderlineTabIndicator( borderSide: const BorderSide( width: 2.0, color: OipBrand.primary), diff --git a/lib/components/XText/XText.dart b/lib/components/XText/XText.dart index e35d609..92aa5be 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 7b7806d..398e612 100644 --- a/lib/components/XTextField/XTextField.dart +++ b/lib/components/XTextField/XTextField.dart @@ -142,13 +142,13 @@ class XTextField extends StatelessWidget { autofocus: autofocus, obscureText: obscureText, inputFormatters: inputFormatters, - style: TextStyle(fontSize: 16.sp), + style: TextStyle(fontSize: 20.sp), decoration: InputDecoration( isCollapsed: true, hintText: hint, contentPadding: EdgeInsets.zero, hintStyle: TextStyle( - color: Colors.grey.shade400, fontSize: 16.sp), + color: Colors.grey.shade400, fontSize: 20.sp), border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, diff --git a/lib/components/form/mapping_components.dart b/lib/components/form/mapping_components.dart index 08dbe75..4ecc4c2 100644 --- a/lib/components/form/mapping_components.dart +++ b/lib/components/form/mapping_components.dart @@ -848,7 +848,7 @@ class _SensorSelectSheet extends StatelessWidget { child: Text( item.headerText!, style: TextStyle( - fontSize: 12.sp, + fontSize: 16.sp, fontWeight: FontWeight.w600, color: OipText.secondary, ), @@ -863,11 +863,11 @@ class _SensorSelectSheet extends StatelessWidget { color: OipBrand.primary, ), title: Text(lookup.text ?? '', - style: TextStyle(fontSize: 14.sp)), + style: TextStyle(fontSize: 16.sp)), subtitle: lookup.code != null && lookup.code!.isNotEmpty ? Text(lookup.code!, style: TextStyle( - fontSize: 11.sp, color: OipText.tertiary)) + fontSize: 16.sp, color: OipText.tertiary)) : null, trailing: Icon(Icons.chevron_right, size: 20.w, color: OipText.tertiary), diff --git a/lib/components/iot/thing_model_view.dart b/lib/components/iot/thing_model_view.dart index 58dcb91..f183875 100644 --- a/lib/components/iot/thing_model_view.dart +++ b/lib/components/iot/thing_model_view.dart @@ -112,7 +112,7 @@ class _ModelHeader extends StatelessWidget { Text( '${model.name} · ${model.version}', style: XFonts.captionSmall.copyWith( - fontSize: 12.sp, color: OipText.tertiary), + fontSize: 16.sp, color: OipText.tertiary), ), ], ), @@ -126,7 +126,7 @@ class _ModelHeader extends StatelessWidget { child: Text( device.status.name, style: XFonts.captionSmall.copyWith( - fontSize: 11.sp, + fontSize: 16.sp, color: statusColor, fontWeight: FontWeight.w500), ), @@ -192,7 +192,7 @@ class PropertyRow extends StatelessWidget { child: Text( property.name, style: XFonts.captionSmall - .copyWith(fontSize: 13.sp, color: OipText.tertiary), + .copyWith(fontSize: 16.sp, color: OipText.tertiary), ), ), const SizedBox(width: 8), @@ -226,13 +226,13 @@ class _ValueWidget extends StatelessWidget { return Text( _formatTimestamp(value), style: XFonts.captionSmall - .copyWith(fontSize: 13.sp, color: OipText.primary), + .copyWith(fontSize: 16.sp, color: OipText.primary), ); case PropertyDataType.object: return Text( value?.toString() ?? '—', style: XFonts.captionSmall.copyWith( - fontSize: 12.sp, + fontSize: 16.sp, fontFamily: 'monospace', color: OipText.secondary), maxLines: 3, @@ -244,7 +244,7 @@ class _ValueWidget extends StatelessWidget { return Text( property.format(value), style: XFonts.captionSmall - .copyWith(fontSize: 13.sp, color: OipText.primary), + .copyWith(fontSize: 16.sp, color: OipText.primary), ); } } @@ -278,7 +278,7 @@ class _BooleanValue extends StatelessWidget { Text( v ? '是' : '否', style: XFonts.captionSmall.copyWith( - fontSize: 13.sp, + fontSize: 16.sp, color: v ? OipState.success : OipSurface.mutedFg), ), ], @@ -299,7 +299,7 @@ class _EnumValue extends StatelessWidget { if (label.isEmpty) { return Text('—', style: XFonts.captionSmall.copyWith( - fontSize: 13.sp, color: OipSurface.mutedFg)); + fontSize: 16.sp, color: OipSurface.mutedFg)); } return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), @@ -310,7 +310,7 @@ class _EnumValue extends StatelessWidget { child: Text( label, style: XFonts.captionSmall.copyWith( - fontSize: 12.sp, color: OipBrand.primary), + fontSize: 16.sp, color: OipBrand.primary), ), ); } @@ -326,14 +326,14 @@ class _LocationValue extends StatelessWidget { if (value is! Map) { return Text('—', style: XFonts.captionSmall.copyWith( - fontSize: 13.sp, color: OipSurface.mutedFg)); + fontSize: 16.sp, color: OipSurface.mutedFg)); } final lat = value['lat'] ?? value['latitude']; final lng = value['lng'] ?? value['longitude']; return Text( '$lat, $lng', style: XFonts.captionSmall.copyWith( - fontSize: 12.sp, + fontSize: 16.sp, fontFamily: 'monospace', color: OipText.secondary), ); @@ -681,7 +681,7 @@ class EventHistoryTile extends StatelessWidget { Text( event.eventType, style: XFonts.captionSmall.copyWith( - fontSize: 13.sp, + fontSize: 16.sp, fontWeight: FontWeight.w500, color: color), ), @@ -689,7 +689,7 @@ class EventHistoryTile extends StatelessWidget { Text( _formatTime(event.timestamp), style: XFonts.captionSmall.copyWith( - fontSize: 11.sp, color: OipSurface.mutedFg), + fontSize: 16.sp, color: OipSurface.mutedFg), ), ], ), @@ -698,7 +698,7 @@ class EventHistoryTile extends StatelessWidget { Text( event.data.toString(), style: XFonts.captionSmall.copyWith( - fontSize: 11.sp, + fontSize: 16.sp, color: OipText.secondary, fontFamily: 'monospace'), maxLines: 2, @@ -718,7 +718,7 @@ class EventHistoryTile extends StatelessWidget { ), child: Text('确认', style: XFonts.captionSmall - .copyWith(fontSize: 12.sp, color: color)), + .copyWith(fontSize: 16.sp, color: color)), ), ), ], diff --git a/lib/config/theme/unified_style.dart b/lib/config/theme/unified_style.dart index 0d0f5fb..649bd57 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,7 +188,7 @@ class XFonts { ///沟通 //沟通会话信息 static TextStyle get chatSMInfo => size24Black0; //文本信息 - static get chatSMSysTip => size14Black0; //系统提示文本信息 + static get chatSMSysTip => size16Black0; //系统提示文本信息 static TextStyle get chatSMTimeTip => captionSmall.copyWith(color: OipText.tertiary); //聊天时间提示信息 @@ -289,7 +290,7 @@ class XFonts { height: 1.3, ); static TextStyle get labelSmall => TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, fontWeight: FontWeight.w500, color: OipText.tertiary, height: 1.3, @@ -317,7 +318,7 @@ class XFonts { height: 1.3, ); static TextStyle get captionSmall => TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, fontWeight: FontWeight.w400, color: OipText.disabled, height: 1.3, @@ -410,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 { @@ -572,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 { @@ -608,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 { @@ -646,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 { @@ -790,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 { @@ -827,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 { @@ -965,6 +966,58 @@ class XUi { ); } + // --- 毛玻璃 / 玻璃态 --- + + /// 毛玻璃卡片装饰:半透明白底 + 细描边 + 轻阴影(搭配 [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); 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 0000000..473ab08 --- /dev/null +++ b/lib/dart/base/storages/local_page_cache_repository.dart @@ -0,0 +1,249 @@ +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) { + final sid = spaceId ?? 'personal'; + return '${_keyPrefix}_${userId}_${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 (_) { + return null; + } + } + + /// 写入本地存储并返回数据是否有变化(基于 hash 比对) + /// + /// 调用方传入 `List>`(已通过 toJson 序列化), + /// 本方法计算 hash 与现有值比对,相同则不写盘,返回 false。 + /// 不同则写盘并返回 true。 + Future write( + String dataType, + List> data, { + required String userId, + String? spaceId, + }) async { + 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) { + // 数据未变化,不写盘 + return false; + } + await box.put(key, { + 'data': data, + 'hash': newHash, + 'updateTime': DateTime.now().millisecondsSinceEpoch, + }); + return true; + } catch (e) { + XLogUtil.w('[LocalPageCache] 写入失败 dataType=$dataType: $e'); + return false; + } + } + + /// 计算数据签名(用于增量比对) + /// + /// 取每项的 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'; +} 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 0000000..9dfdfc4 --- /dev/null +++ b/lib/dart/base/storages/local_page_cache_writer.dart @@ -0,0 +1,192 @@ +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 (_) {} + } + await LocalPageCacheRepository.instance.write( + PageCacheType.apps, + data, + userId: userId, + spaceId: spaceId, + ); + } catch (e) { + XLogUtil.w('[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/core/provider/auth.dart b/lib/dart/core/provider/auth.dart index a10edcd..5162c60 100644 --- a/lib/dart/core/provider/auth.dart +++ b/lib/dart/core/provider/auth.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:orginone/dart/base/common/commands.dart'; @@ -5,6 +6,7 @@ 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/controller/data_freshness_tracker.dart'; import 'package:orginone/dart/core/chat/resource.dart'; @@ -54,6 +56,9 @@ class AuthProvider with AuthMixin { /// 登录 /// @param {RegisterType} params 参数 + /// + /// ★登录加速:API 成功后不 await _onAuthed,实例创建+数据加载在后台执行。 + /// UI 立即跳转首页从本地存储渲染首屏,_refresh 完成后通过事件刷新。 Future> login( LoginModel params, ) async { @@ -63,7 +68,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 +86,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 +103,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,6 +143,8 @@ mixin AuthMixin { currentUserId.isNotEmpty ? currentUserId : cachedUserId; if (snapshotUserId.isNotEmpty) { await BootstrapSnapshotRepository().clearByUser(snapshotUserId); + // 清理 LocalPageCache 中该用户的页面级缓存 + await LocalPageCacheRepository.instance.clearUser(snapshotUserId); } // 清理静态资源缓存,防止切换账号后残留前一个用户数据 diff --git a/lib/dart/core/provider/index.dart b/lib/dart/core/provider/index.dart index 4b78656..c32c543 100644 --- a/lib/dart/core/provider/index.dart +++ b/lib/dart/core/provider/index.dart @@ -9,6 +9,7 @@ import 'package:orginone/dart/base/common/emitter.dart'; import 'package:orginone/dart/base/common/mutex.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'; @@ -264,33 +265,29 @@ 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; - } + // ★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 +299,34 @@ 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'; + XLogUtil.e('>>>>>>[登录诊断] _loadUser 异常: $e\n$s'); + changeCallback(args: [false]); + sw.stop(); + } } /// 重载数据 @@ -355,6 +364,7 @@ class DataProvider with EmitterMixin, Mutex { changeCallback(args: [true]); _markCoreDataRefreshed(); _scheduleSnapshotWrite(); + _scheduleLocalPageCacheWrite(); _notifyDataRefreshed(); } else if (user != null) { // 完整重载路径(手动刷新) @@ -376,6 +386,7 @@ class DataProvider with EmitterMixin, Mutex { changeCallback(args: [true]); _markCoreDataRefreshed(); _scheduleSnapshotWrite(); + _scheduleLocalPageCacheWrite(); _notifyDataRefreshed(); } else { await Future.wait([ @@ -395,6 +406,7 @@ class DataProvider with EmitterMixin, Mutex { changeCallback(args: [true]); _markCoreDataRefreshed(); _scheduleSnapshotWrite(); + _scheduleLocalPageCacheWrite(); _notifyDataRefreshed(); } } catch (e, s) { @@ -459,6 +471,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 +617,7 @@ class DataProvider with EmitterMixin, Mutex { // 注意:无需 invalidateAll(写 0 后再写 now 等于空操作)。 _markCoreDataRefreshed(); _scheduleSnapshotWrite(); + _scheduleLocalPageCacheWrite(); _notifyDataRefreshed(); }); } diff --git a/lib/global.dart b/lib/global.dart index 7b9b824..a38fea8 100644 --- a/lib/global.dart +++ b/lib/global.dart @@ -12,6 +12,7 @@ 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:permission_handler/permission_handler.dart'; import 'package:orginone/utils/log/log_util.dart'; @@ -25,12 +26,11 @@ 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(); } catch (e, st) { XLogUtil.e('Global 初始化本地存储失败: $e\n$st'); } diff --git a/lib/pages/auth/forgot_password/view.dart b/lib/pages/auth/forgot_password/view.dart index 9738a65..5d8b09d 100644 --- a/lib/pages/auth/forgot_password/view.dart +++ b/lib/pages/auth/forgot_password/view.dart @@ -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 f29ec2b..c89d293 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'; @@ -422,7 +421,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 d4f0704..d6e0a66 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 b1c5876..1cc954e 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 fb10985..4b6f7fb 100644 --- a/lib/pages/chats/chat_page.dart +++ b/lib/pages/chats/chat_page.dart @@ -38,7 +38,6 @@ class _ChatPageState extends State TabContainerModel? _chatModel; List? get datas => widget.datas; - IChatProvider? get chatProvider => relationCtrl.provider.chatProvider; List? get chats => chatProvider?.chats ?? []; @@ -46,7 +45,8 @@ class _ChatPageState extends State int? _lastChatCount; int? _lastUnreadCount; int? _lastLastMsgTime; - bool _isRefreshing = false; + // 后台刷新指示器:仅在已有数据、后台拉取新数据时显示 + bool _isBgRefreshing = false; @override bool get wantKeepAlive => true; @@ -56,33 +56,34 @@ class _ChatPageState extends State super.initState(); _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) _refreshModel(); } }); _subscriptions.add(chatSub); - final switchSub = command.subscribeByFlag('switchSpace', ([args]) { - if (!mounted) return; - setState(() { - _chatModel = null; - _lastChatCount = null; - _lastUnreadCount = null; - _lastLastMsgTime = null; - dataSourceCache.clear(); - _isRefreshing = true; - }); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; + // 沟通页不跟随单位切换刷新数据,保持本地缓存优先策略 + } + + /// 触发后台数据刷新(带顶部"数据更新中"指示器) + void _triggerBgRefresh() { + if (!mounted) return; + setState(() => _isBgRefreshing = true); + relationCtrl.provider.ensureDataFresh(DataType.chats).then((_) { + if (mounted) { _refreshModel(); - }); - }, false); - _subscriptions.add(switchSub); - // 首次加载放到首帧后,避免在 build 中调度 setState 造成级联重建 - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - _refreshModel(); + setState(() => _isBgRefreshing = false); + } + }).catchError((_) { + if (mounted) setState(() => _isBgRefreshing = false); }); } @@ -136,8 +137,7 @@ class _ChatPageState extends State if (_chatModel == null) { return const SkeletonWidget(itemCount: 6); } - if (_isRefreshing) { - _isRefreshing = false; + if (_isBgRefreshing) { return Column( children: [ XUi.refreshingBanner(), diff --git a/lib/pages/common/asset_map_page.dart b/lib/pages/common/asset_map_page.dart index 7ee2dd8..65e7202 100644 --- a/lib/pages/common/asset_map_page.dart +++ b/lib/pages/common/asset_map_page.dart @@ -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/space_select_page.dart b/lib/pages/common/space_select_page.dart index af1ba5b..e1c2760 100644 --- a/lib/pages/common/space_select_page.dart +++ b/lib/pages/common/space_select_page.dart @@ -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 fafcd20..430681e 100644 --- a/lib/pages/discover/discover_page.dart +++ b/lib/pages/discover/discover_page.dart @@ -49,13 +49,9 @@ class _DiscoverPageState extends State } void _subscribeRefreshEvents() { - // 订阅 switchSpace 事件:切换单位后清空缓存并强制重新加载 - final switchId = command.subscribeByFlag('switchSpace', ([args]) { - if (!mounted) return; - context.read().clearCache(); - context.read().loadDiscoverData(forceRefresh: true); - }, false); - _subscribers.add(switchId); + // 发现页不跟随单位切换刷新数据,保持本地缓存优先策略, + // 加速页面渲染速度。单位切换只刷新门户页面数据。 + // 数据刷新由用户下拉刷新或切换 Tab 时按需触发。 } @override @@ -153,7 +149,8 @@ class _DiscoverPageState extends State return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (provider.isLoading && hasLoadedData) + // 后台刷新时显示"数据更新中"指示器,不影响主内容显示 + if (provider.isBackgroundRefreshing && hasLoadedData) XUi.refreshingBanner(), _buildTabBar(provider, theme), _buildSmartSearchBar(theme), diff --git a/lib/pages/discover/discover_provider.dart b/lib/pages/discover/discover_provider.dart index 8c541d5..2832268 100644 --- a/lib/pages/discover/discover_provider.dart +++ b/lib/pages/discover/discover_provider.dart @@ -9,13 +9,19 @@ 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 => _hasCompletedFirstLoad; @@ -32,6 +38,7 @@ class DiscoverProvider with ChangeNotifier { _badgeCounts = {}; _plaza = null; _hasCompletedFirstLoad = false; + _filteredCache.clear(); notifyListeners(); } @@ -47,6 +54,7 @@ class DiscoverProvider with ChangeNotifier { await DiscoverService.loadBadgeCounts(resources: _resources); _hasCompletedFirstLoad = true; + _filteredCache.clear(); XLogUtil.d('发现数据加载完成(多集群聚合):${_resources.length} 个资源'); } catch (e) { _setError('加载数据失败: $e'); @@ -58,13 +66,39 @@ class DiscoverProvider with ChangeNotifier { /// 下拉刷新:离线优先,保留旧数据显示,后台拉取新数据后替换 Future refreshData() async { - if (_isLoading) return; + 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 { - // 强制刷新 DiscoverService 缓存,但不清空 Provider 已有数据 final aggregated = await DiscoverService.loadAggregatedPlazaResources( forceRefresh: true, ); @@ -76,7 +110,8 @@ class DiscoverProvider with ChangeNotifier { await DiscoverService.loadBadgeCounts(resources: _resources); _hasCompletedFirstLoad = true; - XLogUtil.d('发现数据刷新完成(离线优先):${_resources.length} 个资源'); + _filteredCache.clear(); + XLogUtil.d('发现数据刷新完成(首次):${_resources.length} 个资源'); } catch (e) { _setError('刷新数据失败: $e'); XLogUtil.e('DiscoverProvider 刷新错误: $e'); @@ -86,6 +121,10 @@ class DiscoverProvider with ChangeNotifier { } List findResourcesByType(PlazaType type) { + // 命中缓存直接返回,避免每次 build 重复过滤+排序 + final cached = _filteredCache[type]; + if (cached != null) return cached; + final res = _resources .where((resource) => resource.typeName == type.resourceTypeName) .toList(); @@ -103,6 +142,7 @@ class DiscoverProvider with ChangeNotifier { } return a.name.compareTo(b.name); }); + _filteredCache[type] = res; return res; } @@ -129,6 +169,7 @@ class DiscoverProvider with ChangeNotifier { _resources.clear(); _plaza = null; _hasCompletedFirstLoad = false; + _filteredCache.clear(); _clearError(); notifyListeners(); // 同时清理DiscoverService中的静态缓存 @@ -142,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 72f0c34..4b42688 100644 --- a/lib/pages/discover/discover_service.dart +++ b/lib/pages/discover/discover_service.dart @@ -1,5 +1,6 @@ import 'package:orginone/config/constant.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'; @@ -557,13 +558,98 @@ class DiscoverService { }; XLogUtil.d('广场信息已加载 (缓存键: $cacheKey), resources: ${resources.length}'); + // ★落盘到本地存储:plaza + resources 完整数据 + _persistPlazaToLocal(targetId, plaza, resources); + return plaza; } } - return null; + // 网络加载失败,尝试从本地存储降级读取 + return _loadPlazaFromLocal(targetId); } catch (e) { XLogUtil.e('加载广场信息失败: $e'); + // 异常时也尝试从本地存储降级 + return _loadPlazaFromLocal(groupId.isNotEmpty ? groupId : defaultClusterId); + } + } + + /// 将 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.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; } } @@ -1016,14 +1102,23 @@ 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((_) {}); } } diff --git a/lib/pages/discover/pages/all_activities_page.dart b/lib/pages/discover/pages/all_activities_page.dart index a43770e..82e91f7 100644 --- a/lib/pages/discover/pages/all_activities_page.dart +++ b/lib/pages/discover/pages/all_activities_page.dart @@ -328,8 +328,8 @@ class _AllActivitiesPageState extends State indicatorWeight: 3, labelColor: labelColor, unselectedLabelColor: unselectedLabelColor, - labelStyle: XFonts.labelMedium, - unselectedLabelStyle: XFonts.labelSmall, + labelStyle: XFonts.tabLabel, + unselectedLabelStyle: XFonts.tabUnselected, ); } diff --git a/lib/pages/discover/pages/data_share_list_page.dart b/lib/pages/discover/pages/data_share_list_page.dart index 8c24e40..408cb26 100644 --- a/lib/pages/discover/pages/data_share_list_page.dart +++ b/lib/pages/discover/pages/data_share_list_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:orginone/components/LoadingWidget/LoadingWidget.dart'; import 'package:orginone/config/oip_tokens.dart'; import 'package:orginone/dart/base/common/commands.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'; @@ -26,7 +27,10 @@ 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 +43,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 +55,35 @@ 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, + ); + + // 1. 先读缓存 + final cached = DiscoverListCache.read(cacheKey); + 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 +91,105 @@ 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.write(cacheKey, data); + } } - _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.write(cacheKey, data); + } + }); + } 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() { @@ -133,22 +211,21 @@ class _DataShareListPageState extends State { 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(), @@ -170,7 +247,7 @@ class _DataShareListPageState extends State { ), 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( @@ -203,13 +280,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 && diff --git a/lib/pages/discover/pages/group_share_list_page.dart b/lib/pages/discover/pages/group_share_list_page.dart index b106594..c487675 100644 --- a/lib/pages/discover/pages/group_share_list_page.dart +++ b/lib/pages/discover/pages/group_share_list_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:orginone/components/LoadingWidget/LoadingWidget.dart'; import 'package:orginone/config/oip_tokens.dart'; import 'package:orginone/dart/base/common/commands.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_style_cards.dart'; @@ -26,7 +27,10 @@ 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 +43,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 +55,35 @@ 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, + ); + + // 1. 先读缓存 + final cached = DiscoverListCache.read(cacheKey); + 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 +91,105 @@ 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.write(cacheKey, data); + } } - _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.write(cacheKey, data); + } + }); + } 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 +211,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(), @@ -170,7 +247,7 @@ class _GroupShareListPageState extends State { ), 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( @@ -203,13 +280,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 && diff --git a/lib/pages/discover/pages/market_list_page.dart b/lib/pages/discover/pages/market_list_page.dart index 5be0895..edeafdb 100644 --- a/lib/pages/discover/pages/market_list_page.dart +++ b/lib/pages/discover/pages/market_list_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:orginone/components/LoadingWidget/LoadingWidget.dart'; import 'package:orginone/config/oip_tokens.dart'; import 'package:orginone/dart/base/common/commands.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'; @@ -26,7 +27,10 @@ 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 +44,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 +56,35 @@ 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, + ); + + // 1. 先读缓存 + final cached = DiscoverListCache.read(cacheKey); + 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 +93,112 @@ 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.write(cacheKey, data); + } } - _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.write(cacheKey, data); + } + }); + } 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(), @@ -143,7 +223,7 @@ class _MarketListPageState extends State { ), 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( @@ -198,24 +278,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 && @@ -316,7 +420,7 @@ class _MarketListPageState extends State { _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 3a6923b..081a379 100644 --- a/lib/pages/discover/pages/notice_list_page.dart +++ b/lib/pages/discover/pages/notice_list_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:orginone/components/LoadingWidget/LoadingWidget.dart'; import 'package:orginone/config/oip_tokens.dart'; import 'package:orginone/dart/base/common/commands.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'; @@ -26,7 +27,10 @@ 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 +43,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 +55,35 @@ 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, + ); + + // 1. 先读缓存 + final cached = DiscoverListCache.read(cacheKey); + 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 +91,105 @@ 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.write(cacheKey, data); + } } - _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.write(cacheKey, data); + } + }); + } 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 +211,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(), @@ -170,7 +247,7 @@ class _NoticeListPageState extends State { ), 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( @@ -203,13 +280,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 && diff --git a/lib/pages/discover/pages/video_list_page.dart b/lib/pages/discover/pages/video_list_page.dart index 064e3fa..270894e 100644 --- a/lib/pages/discover/pages/video_list_page.dart +++ b/lib/pages/discover/pages/video_list_page.dart @@ -4,6 +4,7 @@ import 'package:orginone/components/LoadingWidget/LoadingWidget.dart'; import 'package:orginone/config/oip_tokens.dart'; import 'package:orginone/dart/base/common/commands.dart'; import 'package:orginone/utils/log/log_util.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'; @@ -26,7 +27,10 @@ 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 +43,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 +55,35 @@ 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, + ); + + // 1. 先读缓存 + final cached = DiscoverListCache.read(cacheKey); + 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 +91,105 @@ 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.write(cacheKey, data); + } } - _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.write(cacheKey, data); + } + }); + } 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 +211,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(), @@ -173,7 +250,7 @@ class _VideoListPageState extends State { ), 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( @@ -205,13 +282,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 && 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 0000000..657680d --- /dev/null +++ b/lib/pages/discover/widgets/discover_list_cache.dart @@ -0,0 +1,160 @@ +import 'dart:async'; + +/// 发现页子页面(公告/视频/群组/数据/市场)的本地缓存工具。 +/// +/// 实现"先展示缓存数据,后台更新数据"的离线优先策略: +/// - 首次进入页面时,从内存缓存读取上次数据立即渲染,避免白屏等待 +/// - 后台拉取最新数据,完成后替换并刷新 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(), + ); + } + + /// 清空所有缓存(切换空间时调用) + 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 d8d9c02..39f775f 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 index 1571248..930837d 100644 --- a/lib/pages/discover/widgets/plaza_style_cards.dart +++ b/lib/pages/discover/widgets/plaza_style_cards.dart @@ -78,7 +78,7 @@ class NoticeStyleCard extends StatelessWidget { Text( summary, style: TextStyle( - fontSize: 13.sp, + fontSize: 16.sp, color: XColors.black6, height: 1.4, ), @@ -101,7 +101,7 @@ class NoticeStyleCard extends StatelessWidget { child: Text( sourceName, style: TextStyle( - fontSize: 11.sp, + fontSize: 16.sp, color: color, fontWeight: FontWeight.w500, ), @@ -113,7 +113,7 @@ class NoticeStyleCard extends StatelessWidget { Text( timeStr, style: TextStyle( - fontSize: 11.sp, + fontSize: 16.sp, color: Colors.grey.shade500, ), ), @@ -249,7 +249,7 @@ class VideoStyleCard extends StatelessWidget { Text( '视频', style: TextStyle( - color: Colors.white, fontSize: 11.sp), + color: Colors.white, fontSize: 16.sp), ), ], ), @@ -282,7 +282,7 @@ class VideoStyleCard extends StatelessWidget { Text( title, style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, fontWeight: FontWeight.w600, color: Colors.white, height: 1.35, @@ -301,7 +301,7 @@ class VideoStyleCard extends StatelessWidget { Text( summary, style: TextStyle( - fontSize: 11.sp, + fontSize: 16.sp, color: Colors.white.withValues(alpha: 0.78), ), maxLines: 1, @@ -417,7 +417,7 @@ class GroupStyleCard extends StatelessWidget { Text( title, style: TextStyle( - fontSize: 14.sp, + fontSize: 16.sp, fontWeight: FontWeight.w600, color: XColors.black3, height: 1.3, @@ -430,7 +430,7 @@ class GroupStyleCard extends StatelessWidget { Text( summary, style: TextStyle( - fontSize: 12.sp, + fontSize: 16.sp, color: XColors.black6, height: 1.35, ), @@ -455,7 +455,7 @@ class GroupStyleCard extends StatelessWidget { child: Text( sourceName.isNotEmpty ? sourceName : '群组', style: TextStyle( - fontSize: 11.sp, + fontSize: 16.sp, color: Colors.grey.shade600, ), maxLines: 1, @@ -612,7 +612,7 @@ class DataStyleCard extends StatelessWidget { Text( sourceName.isNotEmpty ? sourceName : '数据资源', style: TextStyle( - fontSize: 12.sp, + fontSize: 16.sp, color: Colors.grey.shade600, ), maxLines: 1, @@ -633,7 +633,7 @@ class DataStyleCard extends StatelessWidget { Text( summary, style: TextStyle( - fontSize: 13.sp, + fontSize: 16.sp, color: XColors.black6, height: 1.45, ), @@ -648,7 +648,7 @@ class DataStyleCard extends StatelessWidget { Text( timeStr, style: TextStyle( - fontSize: 11.sp, + fontSize: 16.sp, color: Colors.grey.shade500, ), ), @@ -663,7 +663,7 @@ class DataStyleCard extends StatelessWidget { child: Text( '查看', style: TextStyle( - fontSize: 13.sp, + fontSize: 16.sp, fontWeight: FontWeight.w600, color: color, ), @@ -751,7 +751,7 @@ class MarketStyleCard extends StatelessWidget { '特惠', style: TextStyle( color: Colors.white, - fontSize: 11.sp, + fontSize: 16.sp, fontWeight: FontWeight.w600, ), ), @@ -767,7 +767,7 @@ class MarketStyleCard extends StatelessWidget { Text( title, style: TextStyle( - fontSize: 15.sp, + fontSize: 16.sp, fontWeight: FontWeight.w600, color: XColors.black3, height: 1.3, @@ -780,7 +780,7 @@ class MarketStyleCard extends StatelessWidget { Text( summary, style: TextStyle( - fontSize: 12.sp, + fontSize: 16.sp, color: XColors.black6, height: 1.35, ), @@ -809,7 +809,7 @@ class MarketStyleCard extends StatelessWidget { child: Text( sourceName, style: TextStyle( - fontSize: 11.sp, + fontSize: 16.sp, color: Colors.grey.shade500, ), maxLines: 1, @@ -831,7 +831,7 @@ class MarketStyleCard extends StatelessWidget { price != null && price > 0 ? '下单' : '获取', style: TextStyle( color: Colors.white, - fontSize: 12.sp, + fontSize: 16.sp, fontWeight: FontWeight.w600, ), ), diff --git a/lib/pages/iot/device_detail_page.dart b/lib/pages/iot/device_detail_page.dart index 43bdfe7..312a840 100644 --- a/lib/pages/iot/device_detail_page.dart +++ b/lib/pages/iot/device_detail_page.dart @@ -196,7 +196,7 @@ class _DeviceDetailPageState extends State { child: Text( device.status.name, style: XFonts.captionSmall.copyWith( - fontSize: 12.sp, + fontSize: 16.sp, color: statusColor, fontWeight: FontWeight.w500), ), @@ -256,13 +256,13 @@ class _InfoRow extends StatelessWidget { width: 72, child: Text(label, style: XFonts.captionSmall.copyWith( - fontSize: 12.sp, color: OipText.tertiary)), + fontSize: 16.sp, color: OipText.tertiary)), ), Expanded( child: SelectableText( value, style: XFonts.captionSmall.copyWith( - fontSize: 12.sp, color: OipText.primary), + fontSize: 16.sp, color: OipText.primary), ), ), ], @@ -321,7 +321,7 @@ class _AsyncProgressDialog extends StatelessWidget { Text( tracker.errorMessage!, style: XFonts.captionSmall.copyWith( - fontSize: 12.sp, color: OipState.error), + fontSize: 16.sp, color: OipState.error), textAlign: TextAlign.center, ), ], @@ -333,7 +333,7 @@ class _AsyncProgressDialog extends StatelessWidget { ), const SizedBox(height: 4), Text('${tracker.progress}%', - style: XFonts.captionSmall.copyWith(fontSize: 11.sp)), + 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 b7075aa..8062b68 100644 --- a/lib/pages/iot/device_list_page.dart +++ b/lib/pages/iot/device_list_page.dart @@ -153,8 +153,8 @@ class _DeviceListPageState extends State title: const Text('设备管理'), bottom: TabBar( controller: _tabController, - labelStyle: XFonts.labelMedium, - unselectedLabelStyle: XFonts.labelSmall, + labelStyle: XFonts.tabLabel, + unselectedLabelStyle: XFonts.tabUnselected, tabs: _statusTabs.map((t) => Tab(text: t.$1)).toList(), isScrollable: false, tabAlignment: TabAlignment.fill, @@ -271,7 +271,7 @@ class _DeviceListTile extends StatelessWidget { Text( 'ID: ${device.id}', style: XFonts.captionSmall.copyWith( - fontSize: 11.sp, color: OipSurface.mutedFg), + fontSize: 16.sp, color: OipSurface.mutedFg), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -280,7 +280,7 @@ class _DeviceListTile extends StatelessWidget { Text( '${device.location!.latitude.toStringAsFixed(4)}, ${device.location!.longitude.toStringAsFixed(4)}', style: XFonts.captionSmall.copyWith( - fontSize: 11.sp, + fontSize: 16.sp, color: OipSurface.mutedFg, fontFamily: 'monospace'), ), @@ -296,7 +296,7 @@ class _DeviceListTile extends StatelessWidget { child: Text( device.status.name, style: XFonts.captionSmall.copyWith( - fontSize: 11.sp, color: statusColor), + fontSize: 16.sp, color: statusColor), ), ), onTap: onTap, diff --git a/lib/pages/oip/approval_center_page.dart b/lib/pages/oip/approval_center_page.dart index 9915068..4af3ce1 100644 --- a/lib/pages/oip/approval_center_page.dart +++ b/lib/pages/oip/approval_center_page.dart @@ -142,8 +142,8 @@ class _ApprovalCenterPageState extends State controller: _tabController, labelColor: OipBrand.primary, unselectedLabelColor: OipText.tertiary, - labelStyle: XFonts.labelMedium, - unselectedLabelStyle: XFonts.labelSmall, + labelStyle: XFonts.tabLabel, + unselectedLabelStyle: XFonts.tabUnselected, indicatorColor: OipBrand.primary, indicatorSize: TabBarIndicatorSize.label, tabs: [ diff --git a/lib/pages/oip/vet_detail_page.dart b/lib/pages/oip/vet_detail_page.dart index 221d5fa..6c0c806 100644 --- a/lib/pages/oip/vet_detail_page.dart +++ b/lib/pages/oip/vet_detail_page.dart @@ -111,7 +111,7 @@ class _HeaderCard extends StatelessWidget { ), child: Text( vet.lifecycleState.name, - style: XFonts.captionSmall.copyWith(fontSize: 11.sp, color: stateColor), + style: XFonts.captionSmall.copyWith(fontSize: 16.sp, color: stateColor), ), ), ], @@ -157,11 +157,11 @@ class _InfoRow extends StatelessWidget { SizedBox( width: 80, child: Text(label, - style: XFonts.labelSmall.copyWith(fontSize: 12.sp, color: OipText.secondary)), + style: XFonts.labelSmall.copyWith(fontSize: 16.sp, color: OipText.secondary)), ), Expanded( child: Text(value, - style: XFonts.labelSmall.copyWith(fontSize: 12.sp, color: OipText.primary)), + style: XFonts.labelSmall.copyWith(fontSize: 16.sp, color: OipText.primary)), ), ], ), @@ -190,7 +190,7 @@ class _OperationsCard extends StatelessWidget { if (vet.negativePermissions.isNotEmpty) ...[ const SizedBox(height: 8), Text('否定权限:', - style: XFonts.labelSmall.copyWith(fontSize: 12.sp, color: OipText.secondary)), + style: XFonts.labelSmall.copyWith(fontSize: 16.sp, color: OipText.secondary)), const SizedBox(height: 4), Wrap( spacing: 6, @@ -220,7 +220,7 @@ class _PermissionChip extends StatelessWidget { borderRadius: BorderRadius.circular(OipRadius.xs), border: Border.all(color: color.withValues(alpha: 0.3)), ), - child: Text(label, style: XFonts.captionSmall.copyWith(fontSize: 11.sp, color: color)), + child: Text(label, style: XFonts.captionSmall.copyWith(fontSize: 16.sp, color: color)), ); } } @@ -282,7 +282,7 @@ class _ConstraintsCard extends StatelessWidget { return _SectionCard( title: '约束条件', icon: Icons.shield_outlined, - children: [Text('无约束(最宽泛授权)', style: XFonts.labelSmall.copyWith(fontSize: 12.sp, color: OipText.secondary))], + children: [Text('无约束(最宽泛授权)', style: XFonts.labelSmall.copyWith(fontSize: 16.sp, color: OipText.secondary))], ); } @@ -299,11 +299,11 @@ class _ConstraintsCard extends StatelessWidget { SizedBox( width: 64, child: Text(i.label, - style: XFonts.labelSmall.copyWith(fontSize: 12.sp, color: OipText.secondary)), + style: XFonts.labelSmall.copyWith(fontSize: 16.sp, color: OipText.secondary)), ), Expanded( child: Text(i.value, - style: XFonts.labelSmall.copyWith(fontSize: 12.sp, color: OipText.primary)), + style: XFonts.labelSmall.copyWith(fontSize: 16.sp, color: OipText.primary)), ), ], ), @@ -373,15 +373,15 @@ class _DelegationChainCard extends StatelessWidget { if (delegations.isNotEmpty) ...[ const SizedBox(height: 12), Text('子委托 (${delegations.length})', - style: XFonts.labelSmall.copyWith(fontSize: 12.sp, fontWeight: FontWeight.w600, color: OipText.secondary)), + 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: OipText.tertiary), - title: Text(d.object.id, style: XFonts.labelSmall.copyWith(fontSize: 12.sp)), + title: Text(d.object.id, style: XFonts.labelSmall.copyWith(fontSize: 16.sp)), subtitle: Text(d.lifecycleState.name, - style: XFonts.captionSmall.copyWith(fontSize: 10.sp, color: OipText.secondary)), + style: XFonts.captionSmall.copyWith(fontSize: 16.sp, color: OipText.secondary)), )), ], ], @@ -441,7 +441,7 @@ class _ChainNode extends StatelessWidget { Text( id, style: XFonts.labelSmall.copyWith( - fontSize: 12.sp, + fontSize: 16.sp, fontWeight: isCurrent ? FontWeight.w600 : FontWeight.w400, color: isCurrent ? OipText.primary : OipText.secondary, ), @@ -450,7 +450,7 @@ class _ChainNode extends StatelessWidget { ), if (isCurrent) Text('当前 VET', - style: XFonts.captionSmall.copyWith(fontSize: 10.sp, color: OipBrand.primary)), + style: XFonts.captionSmall.copyWith(fontSize: 16.sp, color: OipBrand.primary)), ], ), ), diff --git a/lib/pages/oip/vet_management_page.dart b/lib/pages/oip/vet_management_page.dart index 035549c..ef17502 100644 --- a/lib/pages/oip/vet_management_page.dart +++ b/lib/pages/oip/vet_management_page.dart @@ -69,8 +69,8 @@ class _VetManagementPageState extends State ], bottom: TabBar( controller: _tabController, - labelStyle: XFonts.labelMedium, - unselectedLabelStyle: XFonts.labelSmall, + labelStyle: XFonts.tabLabel, + unselectedLabelStyle: XFonts.tabUnselected, tabs: const [ Tab(text: '活跃'), Tab(text: '已过期'), @@ -190,7 +190,7 @@ class _VetListTile extends StatelessWidget { const SizedBox(height: 2), Text( '类型: ${vet.object.type} · 操作: ${vet.operation.length} 项', - style: XFonts.captionSmall.copyWith(fontSize: 11.sp, color: OipText.secondary), + style: XFonts.captionSmall.copyWith(fontSize: 16.sp, color: OipText.secondary), ), const SizedBox(height: 2), Row( @@ -203,13 +203,13 @@ class _VetListTile extends StatelessWidget { ), child: Text( stateLabel, - style: XFonts.captionSmall.copyWith(fontSize: 10.sp, color: stateColor), + style: XFonts.captionSmall.copyWith(fontSize: 16.sp, color: stateColor), ), ), const SizedBox(width: 6), if (vet.canDelegate()) Text('可委托', - style: XFonts.captionSmall.copyWith(fontSize: 10.sp, color: OipBrand.primary)), + style: XFonts.captionSmall.copyWith(fontSize: 16.sp, color: OipBrand.primary)), ], ), ], diff --git a/lib/pages/portal/account_binding_page.dart b/lib/pages/portal/account_binding_page.dart index d035419..b5ea3da 100644 --- a/lib/pages/portal/account_binding_page.dart +++ b/lib/pages/portal/account_binding_page.dart @@ -137,7 +137,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 +148,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( @@ -199,7 +199,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 +208,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 +229,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 +241,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, ), ), diff --git a/lib/pages/portal/add_favorite_page.dart b/lib/pages/portal/add_favorite_page.dart index ce2c51e..4bc30ff 100644 --- a/lib/pages/portal/add_favorite_page.dart +++ b/lib/pages/portal/add_favorite_page.dart @@ -130,7 +130,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 0bc4c54..878bb8e 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 44ecd96..791f7d6 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/assets_management/view.dart b/lib/pages/portal/assets_management/view.dart index b3f7d1a..0ff40d5 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 639368e..f66e66d 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 b1b6dfb..0411657 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,8 +429,8 @@ class _ExplorerPageState extends State controller: _tabController, labelColor: OipBrand.primary, unselectedLabelColor: XColors.doorDesGrey, - labelStyle: XFonts.labelMedium, - unselectedLabelStyle: XFonts.labelSmall, + labelStyle: XFonts.tabLabel, + unselectedLabelStyle: XFonts.tabUnselected, indicatorColor: OipBrand.primary, indicatorSize: TabBarIndicatorSize.label, indicatorWeight: 2.h, @@ -557,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 b734b30..61e5328 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'; @@ -107,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: () { @@ -119,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(), @@ -162,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'); @@ -184,8 +249,16 @@ class _HomePageState extends State { const Duration(seconds: 6), onTimeout: () => [], ); - final validApps = - apps.where((a) => !a.groupTags.contains('已删除')).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); + } + } if (!mounted) return; // 并行加载文件和表单 final filesFuture = space.directory.loadFiles().timeout( @@ -193,17 +266,17 @@ class _HomePageState extends State { onTimeout: () => [], ); final forms = []; - for (var app in validApps.take(3)) { + 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 = validApps; - _recentApps = validApps.toList() - ..sort((a, b) => - (b.metadata.updateTime ?? '').compareTo(a.metadata.updateTime ?? '')); - _recentApps = _recentApps.take(8).toList(); + _allApps = deduped; + _recentApps = sortedRecent.take(8).toList(); _forms = forms.take(10).toList(); _recentFiles = files.take(5).toList(); }); @@ -294,12 +367,26 @@ class _HomePageState extends State { DeviceUtils.updateWithContext(context); final space = _currentSpace; return Scaffold( - backgroundColor: OipSurface.background, 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: '工作'), ); @@ -336,17 +423,12 @@ class _HomePageState extends State { } /// 顶部 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: [OipBrand.primary, OipBrand.primaryDark], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ), - ), + padding: EdgeInsets.fromLTRB(20.w, topPad + 16.h, 20.w, 20.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -360,14 +442,35 @@ class _HomePageState extends State { space?.name ?? '工作空间', style: XFonts.portalHeroTitle.copyWith( color: OipText.inverse, + letterSpacing: 0.3, ), ), SizedBox(height: 6.h), - Text( - '单位工作台', - style: XFonts.portalPageTitle.copyWith( - color: OipText.inverse.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), + ), + ), + ], ), ], ), @@ -380,8 +483,12 @@ class _HomePageState extends State { vertical: 6.h, ), decoration: BoxDecoration( - color: OipSurface.card.withValues(alpha: 0.2), + 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, @@ -441,13 +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: XUi.cardDecoration(), - 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(), + ), ), ); } @@ -723,9 +835,20 @@ class _HomePageState extends State { behavior: HitTestBehavior.opaque, child: Container( padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 12.h), - decoration: XUi.rounded( - color: OipSurface.muted, - radius: OipRadius.md, + decoration: BoxDecoration( + 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: [ @@ -794,59 +917,64 @@ class _HomePageState extends State { Widget _buildDataOverview() { return Container( margin: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), - padding: EdgeInsets.fromLTRB(20.w, 16.h, 20.w, 20.h), - decoration: XUi.cardDecoration(), - 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), - ], + 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: 16.h), - Row( - children: [ - Expanded( - child: GestureDetector( - onTap: _jumpToAgents, - child: _buildDataItem('$_agentCount', '智能体', - OipEntity.agent, 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}', '最近文件', - OipEntity.document, 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}', '应用', - OipEntity.application, Icons.apps_rounded), + SizedBox(width: 12.w), + Expanded( + child: GestureDetector( + onTap: _jumpToAllApps, + child: _buildDataItem('${_allApps.length}', '应用', + OipEntity.application, Icons.apps_rounded), + ), ), - ), - ], - ), - ], + ], + ), + ], + ), ), ); } @@ -880,46 +1008,52 @@ class _HomePageState extends State { if (_storages.isEmpty) return const SizedBox.shrink(); return Container( margin: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), - decoration: XUi.cardDecoration(), - 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, + 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, + ), ), ), - ), - ], + ], + ), ), - ), - ..._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), - ], + ..._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), + ], + ), ), ); } @@ -973,7 +1107,7 @@ class _HomePageState extends State { ); } - /// 通用分区卡片 + /// 通用分区卡片(毛玻璃效果) Widget _buildSectionCard({ required String title, required int count, @@ -982,57 +1116,63 @@ class _HomePageState extends State { }) { return Container( margin: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), - decoration: XUi.cardDecoration(), - 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, - ), - ), + 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, ), - ], - const Spacer(), - if (onMore != null) - GestureDetector( - onTap: onMore, - behavior: HitTestBehavior.opaque, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '更多', - style: XFonts.brandMedium, + 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, ), - SizedBox(width: 2.w), - Icon(Icons.chevron_right_rounded, - color: OipBrand.primary, size: 20.w), - ], + ), ), - ), - ], + ], + const Spacer(), + if (onMore != null) + GestureDetector( + onTap: onMore, + 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), + ], + ), + ), + ], + ), ), - ), - ...children, - SizedBox(height: 12.h), - ], + ...children, + SizedBox(height: 12.h), + ], + ), ), ); } diff --git a/lib/pages/portal/morepage/share_page.dart b/lib/pages/portal/morepage/share_page.dart index a473642..fd4d5d7 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/originone_menu_page.dart b/lib/pages/portal/originone_menu_page.dart index f6d76eb..f9ad88e 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 4c28aba..c28137f 100644 --- a/lib/pages/portal/password_change_page.dart +++ b/lib/pages/portal/password_change_page.dart @@ -53,7 +53,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), ), ), ], @@ -143,19 +143,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 ad1347c..4568b03 100644 --- a/lib/pages/portal/platform_entry_page.dart +++ b/lib/pages/portal/platform_entry_page.dart @@ -112,81 +112,96 @@ class _PlatformEntryPageState extends State Widget build(BuildContext context) { final useV5 = OipFeatureFlags.oipFlutterV5Enabled; return Scaffold( - backgroundColor: OipSurface.background, 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(), + ], + ), ), ), ); @@ -258,7 +273,7 @@ class _PlatformEntryPageState extends State color: OipText.inverse, fontWeight: FontWeight.w700, height: 1.1, - fontSize: 10.sp, + fontSize: 16.sp, ), ), ), @@ -378,12 +393,32 @@ 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: 52.w, @@ -700,7 +735,7 @@ class _PlatformEntryPageState extends State SizedBox(height: 4.h), Text('请检查集群绑定或网络连接', style: XFonts.captionSmall.copyWith( - color: OipText.disabled, fontSize: 11.sp)), + color: OipText.disabled, fontSize: 16.sp)), ], ), ), @@ -747,14 +782,14 @@ class _PlatformEntryPageState extends State 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, )), ), diff --git a/lib/pages/portal/secret_edit_page.dart b/lib/pages/portal/secret_edit_page.dart index 5d139cd..6e998de 100644 --- a/lib/pages/portal/secret_edit_page.dart +++ b/lib/pages/portal/secret_edit_page.dart @@ -161,7 +161,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 +169,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, ), ], diff --git a/lib/pages/portal/smart_task/smart_task_page.dart b/lib/pages/portal/smart_task/smart_task_page.dart index 19cf643..0dd12ee 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 b7856d8..94e10b1 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 0f2c4c5..8a6d5fc 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 045bc19..0e11f96 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_search_bar.dart b/lib/pages/portal/widgets/portal_search_bar.dart index a0dad67..865f1d6 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 2178a0f..d563c0f 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 7d930bd..fc598b1 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/relation/relation_page.dart b/lib/pages/relation/relation_page.dart index 4c9b2b5..d8cbae8 100644 --- a/lib/pages/relation/relation_page.dart +++ b/lib/pages/relation/relation_page.dart @@ -30,7 +30,6 @@ 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'; @@ -78,22 +77,8 @@ class _RelationState extends State datas = null; // 进入关系页面时按需刷新关系树(数据过期才刷新,新鲜则直接从内存渲染) relationCtrl.provider.ensureDataFresh(DataType.relation); - // 切换单位后清空 model,下次 build 重新 load() - final subId = command.subscribeByFlag('switchSpace', ([args]) { - if (!mounted) return; - setState(() { - relationModel = null; - _lastLoadedPath = null; - _lastLoadedData = null; - _loadingMembers = false; - _isRefreshing = true; - }); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - _loadFromRoute(context); - }); - }, false); - _subscriptions.add(subId); + // 关系页不跟随单位切换刷新数据,保持本地缓存优先策略, + // 加速页面渲染速度。单位切换只刷新门户页面数据。 // 首次加载放到首帧后,避免在 build 中调度 setState 造成级联重建 WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; @@ -798,12 +783,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); } /// 激活存储 diff --git a/lib/pages/store/dba/collection_list_page.dart b/lib/pages/store/dba/collection_list_page.dart index d2d8152..1089445 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,7 @@ class _CollectionListPageState extends State { return Scaffold( backgroundColor: XColors.white, appBar: AppBar( - title: Text('${widget.storage.name} - 数据库'), + title: Text('${_storage?.name ?? '数据库'} - 数据库'), backgroundColor: XColors.white, elevation: 0.5, actions: [ diff --git a/lib/pages/store/store_page.dart b/lib/pages/store/store_page.dart index 6a2b7d7..d269240 100644 --- a/lib/pages/store/store_page.dart +++ b/lib/pages/store/store_page.dart @@ -27,7 +27,6 @@ import 'package:provider/provider.dart'; import '../../components/XConsumer/XConsumer.dart'; import '../../dart/base/common/commands.dart'; -import 'dba/collection_list_page.dart'; /// 数据页面 class StorePage extends StatefulWidget { @@ -65,18 +64,17 @@ class _StorePageState extends State datas = null; relationCtrl.provider.ensureDataFresh(DataType.storage); relationCtrl.provider.ensureDataFresh(DataType.activities); - final subId = command.subscribeByFlag('switchSpace', ([args]) { + // 订阅后台数据刷新事件:登录加速场景下 relationCtrl.user 在后台异步加载, + // 加载完成后 provider 会触发 'session' 事件,此处订阅以刷新首屏 + // target=false 避免订阅时立即触发(initState 阶段 context 尚未稳定) + _subscriptions.add(command.subscribeByFlag('session', ([args]) { if (!mounted) return; - setState(() { - storeModel = null; - _lastLoadedPath = null; - }); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - _loadFromRoute(context); - }); - }, false); - _subscriptions.add(subId); + // 强制重新加载,让 getFirstLevelDirectories 拿到最新内存数据 + _lastLoadedPath = null; + _loadFromRoute(context); + }, false)); + // 存储页不跟随单位切换刷新数据,保持本地缓存优先策略, + // 加速页面渲染速度。单位切换只刷新门户页面数据。 // 首次加载放到首帧后,避免在 build 中调度 setState 造成级联重建 WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; @@ -183,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) { @@ -244,7 +242,7 @@ class _StorePageState extends State // 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) { @@ -476,13 +474,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); } } @@ -545,38 +538,15 @@ class _StorePageState extends State return datas; } - /// 加载系统目录 - List loadSystemDirectory(T 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) { + /// 加载系统目录(合并自 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", @@ -697,9 +667,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(); @@ -710,41 +680,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/components/form/work_start_page.dart b/lib/pages/work/work_start_page.dart similarity index 71% rename from lib/components/form/work_start_page.dart rename to lib/pages/work/work_start_page.dart index d4673ca..725da21 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,7 +100,112 @@ 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'; + + /// 提交申请(提交前执行规则校验) Future _submitApply() async { if (_apply == null || _isSubmitting) return; @@ -115,6 +236,19 @@ class _WorkStartPageState extends XStatefulState { ); } + // 提交前执行规则校验(对齐 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; + } + } + final success = await _apply!.createApply( userId, _remarkController.text, @@ -124,6 +258,7 @@ class _WorkStartPageState extends XStatefulState { if (!mounted) return; if (success) { + await _clearDraft(); EasyLoading.showSuccess('提交成功'); Navigator.of(context).pop(true); } else { @@ -185,6 +320,30 @@ 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 '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,6 +353,20 @@ 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(), ); } @@ -302,7 +475,7 @@ class _WorkStartPageState extends XStatefulState { SizedBox(height: 8.h), Text( _work!.metadata.remark!, - style: TextStyle(fontSize: 14.sp, color: Colors.white70), + style: TextStyle(fontSize: 16.sp, color: Colors.white70), maxLines: 2, overflow: TextOverflow.ellipsis, ), @@ -337,7 +510,7 @@ class _WorkStartPageState extends XStatefulState { padding: EdgeInsets.symmetric(vertical: 32.h), child: Text( '该办事暂无表单', - style: TextStyle(fontSize: 14.sp, color: Colors.grey.shade500), + style: TextStyle(fontSize: 16.sp, color: Colors.grey.shade500), ), ), ), diff --git a/lib/routers/pages.dart b/lib/routers/pages.dart index 653844a..d6095e5 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(), @@ -571,6 +575,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 ad90260..443dd2b 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"; -- Gitee From 2ac3b7b858e2f3a0b7c9084b22fea774d5ed616f Mon Sep 17 00:00:00 2001 From: panzhaohui Date: Sat, 25 Jul 2026 11:11:34 +0800 Subject: [PATCH 10/37] =?UTF-8?q?feat:=20=E8=A1=A5=E9=BD=90=20oiocns-react?= =?UTF-8?q?=20=E5=AF=B9=E7=AD=89=E5=AE=9E=E7=8E=B0=E5=9B=9B=E9=A1=B9?= =?UTF-8?q?=E7=BC=BA=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 转办/加签/委托真实 gateway 写入 - IWorkTask.approvalTask 新增 gatewayData: Map>? 参数 - WorkTask 实现将 gatewayData 序列化为 [{id, targetIds}] JSON 传给 ApprovalTaskReq.gateways - WorkNetWork.approvalTask 透传 gatewayData - BottomActionWidget._selectMemberAndApproval 通过 loadNextNodes + loadMembers(destId) 构造 gatewayData 3. 补齐 action_executor 缺失命令(12 → 30+) 新增: qrcode/copylink/copy/move/distribute/copyRevision/copyData/shortcut/newFile/ commonToggle/companyCommonToggle/applyFriend/quit/subscribe/cancelSubscribe/ subscribeUpdate/lookSubscribes/recallWorkTask/lookRevision/transferBelong/ switchVersion/deleteVersion/activate/setPageTab 有 API 的实现具体逻辑(recallWorkTask/shortcut/commonToggle/qrcode/copylink/download), 需复杂 UI 的引导至 PC 端 4. 补齐 route_registry 缺失 open 类型(17 → 30) 新增: 数据视图/jumpViewPreviewPage、字典表单/jumpFormPage、群动态、物、物详情、 打印/RFID打印/页面模板/商城模板/大仪模板/空间模板/代码编辑器/自定义html/在线编辑 Web 端复杂设计功能统一提示前往 PC 端使用 10. 发现页直播 Tab 独立实现 - discover_page PlazaType.live 分支从 GroupShareListPage 兜底改为 LiveListPage - 接入已存在的 LiveListPage(封面/直播中徽标/观看数/点赞/分享/进入直播间) 验证: flutter analyze 0 errors/0 warnings/314 info; flutter test 110/110 通过 --- lib/components/Executor/action_executor.dart | 184 +++++++++++++++++- lib/components/Executor/route_registry.dart | 106 ++++++++++ .../components/BottomActionWidget.dart | 21 +- .../components/WorkNetWork.dart | 3 +- lib/dart/core/work/task.dart | 14 +- lib/pages/discover/discover_page.dart | 6 +- 6 files changed, 317 insertions(+), 17 deletions(-) diff --git a/lib/components/Executor/action_executor.dart b/lib/components/Executor/action_executor.dart index 8fe603c..7f2607a 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,113 @@ 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'); + } 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/route_registry.dart b/lib/components/Executor/route_registry.dart index 2a90881..9080957 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/ProcessDetailsWidget/components/BottomActionWidget.dart b/lib/components/ProcessDetailsWidget/components/BottomActionWidget.dart index 05b2d78..1548801 100644 --- a/lib/components/ProcessDetailsWidget/components/BottomActionWidget.dart +++ b/lib/components/ProcessDetailsWidget/components/BottomActionWidget.dart @@ -217,14 +217,16 @@ class BottomActionWidget extends StatelessWidget { ToastUtils.showMsg(msg: '没有可用的下一节点'); return; } - // 当前任务的身份ID用于查询可选成员 - final identityId = todo!.taskdata.identityId ?? ''; - if (identityId.isEmpty) { + // 取第一个下一节点(转办/加签/委托的目标节点) + final nextNode = nextNodes.first; + // 下一节点的 destId 用于查询可选成员 + final destId = nextNode.destId ?? ''; + if (destId.isEmpty) { EasyLoading.dismiss(); ToastUtils.showMsg(msg: '当前节点不支持$action'); return; } - final members = await todo!.loadMembers(identityId); + final members = await todo!.loadMembers(destId); EasyLoading.dismiss(); if (!context.mounted) return; if (members.isEmpty) { @@ -266,14 +268,19 @@ class BottomActionWidget extends StatelessWidget { }, ); if (selected == null) return; - // 调用审批:通过 + 备注中带转办/加签/委托信息 - // 注:实际转办/加签/委托的目标写入由后端 gateway 处理, - // Flutter 端通过备注标注,后端规则引擎根据 node 配置执行 + // 构造 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) { diff --git a/lib/components/ProcessDetailsWidget/components/WorkNetWork.dart b/lib/components/ProcessDetailsWidget/components/WorkNetWork.dart index 24b02b4..42abbda 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/dart/core/work/task.dart b/lib/dart/core/work/task.dart index bcd9073..1f8b8ed 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); @@ -605,6 +606,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 +616,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 +635,7 @@ class WorkTask extends FileInfo implements IWorkTask { : null, backId: backId, isSkip: isSkip, - gateways: "")); + gateways: gatewaysStr)); if (!res.success) { ToastUtils.showMsg(msg: res.msg); } diff --git a/lib/pages/discover/discover_page.dart b/lib/pages/discover/discover_page.dart index 430681e..e3b8528 100644 --- a/lib/pages/discover/discover_page.dart +++ b/lib/pages/discover/discover_page.dart @@ -21,6 +21,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'; @@ -95,9 +96,8 @@ 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( -- Gitee From b849391d402e37e53fe30a79a5d6478e881a16cc Mon Sep 17 00:00:00 2001 From: panzhaohui Date: Sat, 25 Jul 2026 12:27:25 +0800 Subject: [PATCH 11/37] =?UTF-8?q?fix:=20=E5=AD=97=E4=BD=93=E8=A7=84?= =?UTF-8?q?=E8=8C=83=E3=80=81=E6=B2=9F=E9=80=9A=E6=8E=92=E5=BA=8F=E3=80=81?= =?UTF-8?q?=E5=85=B3=E7=B3=BB=E7=AD=9B=E9=80=89=E3=80=81=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=E5=AD=90=E6=A0=91=E4=B8=8E=E7=9B=AE=E5=BD=95=E9=80=92=E5=BD=92?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AGENTS.md: 新增 §15.5 字体统一设计规范,定义 XFonts 语义化样式与使用约束 - ErrorListWidget: 修复 kernel 未定义编译错误,导入 main.dart;重构 SystemLog 实现去重、异步落盘、容量控制和登出清理 - chat_page: 修正各 Tab 筛选与排序,单位 Tab 只显示单位标签,最近 Tab 按 lastMsgTime 倒序 Top100,所有 Tab 支持 searchText 过滤,修复 updateTime 字符串转 int 类型错误 - FlowChartWidget: 递归识别 type=='sub' 节点,加载子流程任务树并递归渲染,添加子流程连接线和缩进样式 - company/relation_page: 修复单位子页智能体/感知设备 Tab 显示群组数据问题,按类型正确加载 - directory: 添加 _loadingDirectoryIds 循环引用检测,防止递归栈溢出 - pages.dart jumpStoreInfoPage: 修复 break→return 导致的双重导航 - store_page: 修复排除列表重复项、loadForms/loadWorks 强制解包崩溃风险 - 数据页面字体: view_preview_page/collection_list_page/collection_content_page/form_data_list_page/index_manage_page/create_index_dialog/map_property_fields/mapping_components 替换硬编码字体为 XFonts 语义化样式 --- AGENTS.md | 49 +++ .../ErrorWidget/ErrorListWidget.dart | 30 +- .../components/FlowChartWidget.dart | 291 ++++++++++++++---- .../form_data_list_page.dart | 42 ++- lib/components/form/map_property_fields.dart | 10 +- lib/components/form/mapping_components.dart | 10 +- lib/components/form/view_preview_page.dart | 66 +--- lib/dart/base/common/systemError.dart | 131 ++++++-- lib/dart/core/provider/auth.dart | 4 + lib/dart/core/target/team/company.dart | 11 +- lib/dart/core/thing/directory.dart | 24 +- lib/pages/chats/chat_page.dart | 65 ++-- lib/pages/relation/relation_page.dart | 30 +- .../store/dba/collection_content_page.dart | 31 +- lib/pages/store/dba/collection_list_page.dart | 20 +- lib/pages/store/dba/create_index_dialog.dart | 7 +- lib/pages/store/dba/index_manage_page.dart | 28 +- lib/pages/store/store_page.dart | 21 +- lib/routers/pages.dart | 10 +- 19 files changed, 592 insertions(+), 288 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 660df38..1b676e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -358,6 +358,55 @@ lib/pages/iot/ # 物联网页面 --- +## 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/dart/core`)新增/修复建议补单测。 diff --git a/lib/components/MySettingWidget/components/ErrorWidget/ErrorListWidget.dart b/lib/components/MySettingWidget/components/ErrorWidget/ErrorListWidget.dart index 8ccbe05..13d6324 100644 --- a/lib/components/MySettingWidget/components/ErrorWidget/ErrorListWidget.dart +++ b/lib/components/MySettingWidget/components/ErrorWidget/ErrorListWidget.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:orginone/components/CommandWidget/index.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 '../../../../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'; @@ -15,23 +17,31 @@ class ErrorListWidget extends StatelessWidget { // 主视图 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: Colors.grey.shade400), + 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]; return ListTile( + key: ValueKey('${data.title}_$index'), title: XText.listTitle(data.title), 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)); }); diff --git a/lib/components/ProcessDetailsWidget/components/FlowChartWidget.dart b/lib/components/ProcessDetailsWidget/components/FlowChartWidget.dart index b5dbb56..0c81e86 100644 --- a/lib/components/ProcessDetailsWidget/components/FlowChartWidget.dart +++ b/lib/components/ProcessDetailsWidget/components/FlowChartWidget.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:orginone/config/oip_tokens.dart'; @@ -46,6 +48,9 @@ class _NodeStatusStyle { /// /// 基于 [InstanceDataModel.node] 节点树 + [XWorkInstance.tasks] 实际任务执行情况, /// 渲染流程节点路径,标识当前节点和各节点状态。 +/// +/// 子流程展开:检测 `type == 'sub'` 节点,通过 [IWorkTask.loadTasksData] 已加载的 +/// `task.tasks` / `task.instance` 递归渲染子流程节点树。 class FlowChartWidget extends StatefulWidget { final IWorkTask? todo; @@ -61,6 +66,14 @@ class _FlowChartWidgetState extends State { 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(); @@ -74,20 +87,20 @@ class _FlowChartWidgetState extends State { return; } try { - // 流程实例数据中已带节点树,但任务列表可能需要异步加载 _rootNode = todo.instanceData?.node; _currentNodeId = todo.instanceData?.node?.id; - if (todo.instance?.tasks != null && todo.instance!.tasks!.isNotEmpty) { - _tasks = todo.instance!.tasks!; - } else { - // 退而求其次:通过 loadTasksData 异步加载(不阻塞详情页渲染) - try { - final list = await todo.loadTasksData(); - _tasks = list.whereType().toList(); - } catch (e) { - XLogUtil.w('[FlowChartWidget] loadTasksData 失败: $e'); + // 始终调用 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 { @@ -95,22 +108,65 @@ class _FlowChartWidgetState extends State { } } + /// 构建子流程映射: + /// - _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 (t.id != null && !_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 查找匹配的任务状态 - FlowNodeStatus _resolveStatus(String? nodeId) { + /// + /// [tasks] 指定任务列表查找范围(主流程或子流程),[currentNodeId] 指定当前节点 id + FlowNodeStatus _resolveStatus( + String? nodeId, { + List? tasks, + String? currentNodeId, + }) { if (nodeId == null || nodeId.isEmpty) return FlowNodeStatus.pending; - if (nodeId == _currentNodeId) { + final taskList = tasks ?? _tasks; + final currId = currentNodeId ?? _currentNodeId; + if (nodeId == currId) { // 当前节点:若已有任务记录,按记录状态展示;否则视为进行中 - final task = _findTaskByNodeId(nodeId); + final task = _findTaskByNodeId(nodeId, taskList); if (task == null) return FlowNodeStatus.current; return _mapTaskStatus(task.status, task.approveType); } - final task = _findTaskByNodeId(nodeId); + final task = _findTaskByNodeId(nodeId, taskList); if (task == null) return FlowNodeStatus.pending; return _mapTaskStatus(task.status, task.approveType); } - XWorkTask? _findTaskByNodeId(String nodeId) { - for (final t in _tasks) { + XWorkTask? _findTaskByNodeId(String nodeId, [List? taskList]) { + final list = taskList ?? _tasks; + for (final t in list) { if (t.nodeId == nodeId) return t; } return null; @@ -206,15 +262,29 @@ class _FlowChartWidgetState extends State { color: OipSurface.background, padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h), child: SingleChildScrollView( - child: _buildNodeCard(root, isRoot: true, depth: 0), + child: _buildNodeCard( + root, + isRoot: true, + depth: 0, + tasks: _tasks, + currentNodeId: _currentNodeId, + ), ), ); } /// 渲染单个节点卡片 + 递归渲染后续路径 - Widget _buildNodeCard(WorkNodeModel node, - {required bool isRoot, required int depth}) { - final status = _resolveStatus(node.id); + /// + /// [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; @@ -223,14 +293,16 @@ class _FlowChartWidgetState extends State { borderRadius: BorderRadius.circular(8.r), child: InkWell( borderRadius: BorderRadius.circular(8.r), - onTap: () => _showNodeDetail(node, status, style), + 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), + color: isCurrent + ? style.border + : style.border.withValues(alpha: 0.5), width: isCurrent ? 1.5 : 1, ), ), @@ -244,11 +316,7 @@ class _FlowChartWidgetState extends State { Expanded( child: Text( node.name ?? '未命名节点', - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - color: OipText.primary, - ), + style: XFonts.titleSmall, ), ), Container( @@ -260,34 +328,19 @@ class _FlowChartWidgetState extends State { ), child: Text( style.label, - style: TextStyle( - fontSize: 12.sp, - color: style.color, - fontWeight: FontWeight.w500, - ), + style: XFonts.labelSmall.copyWith(color: style.color), ), ), ], ), if ((node.destName ?? '').isNotEmpty) ...[ SizedBox(height: 4.h), - Text( - '审批人:${node.destName}', - style: TextStyle( - fontSize: 13.sp, - color: XColors.black6, - ), - ), + Text('审批人:${node.destName}', style: XFonts.caption), ], if (_nodeTypeLabel(node.type).isNotEmpty) ...[ SizedBox(height: 2.h), - Text( - _nodeTypeLabel(node.type), - style: TextStyle( - fontSize: 12.sp, - color: XColors.black6, - ), - ), + Text(_nodeTypeLabel(node.type), + style: XFonts.captionSmall), ], ], ), @@ -300,8 +353,13 @@ class _FlowChartWidgetState extends State { // 主路径:node.children(顺序后续节点) if (node.children != null) { children.add(_buildConnector()); - children.add(_buildNodeCard(node.children!, - isRoot: false, depth: depth + 1)); + children.add(_buildNodeCard( + node.children!, + isRoot: false, + depth: depth + 1, + tasks: tasks, + currentNodeId: currentNodeId, + )); } // 分支路径:node.branches(条件分支,平行展示) @@ -312,13 +370,59 @@ class _FlowChartWidgetState extends State { children.add( Padding( padding: EdgeInsets.only(left: 16.w), - child: _buildNodeCard(br.children!, - isRoot: false, depth: depth + 1), + 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 = subTask.id != null + ? _subInstanceDataMap[subTask.id!] + : null; + 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, @@ -361,10 +465,45 @@ class _FlowChartWidgetState extends State { ), child: Text( condText, - style: TextStyle( - fontSize: 11.sp, - color: OipBrand.primary, - ), + 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), + ), + ], ), ), ], @@ -372,6 +511,25 @@ class _FlowChartWidgetState extends State { ); } + /// 子流程未开放/未提交提示 + 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': @@ -393,8 +551,12 @@ class _FlowChartWidgetState extends State { } void _showNodeDetail( - WorkNodeModel node, FlowNodeStatus status, _NodeStatusStyle style) { - final task = _findTaskByNodeId(node.id ?? ''); + WorkNodeModel node, + FlowNodeStatus status, + _NodeStatusStyle style, { + List tasks = const [], + }) { + final task = _findTaskByNodeId(node.id ?? '', tasks); showModalBottomSheet( context: context, builder: (ctx) { @@ -412,10 +574,7 @@ class _FlowChartWidgetState extends State { Expanded( child: Text( node.name ?? '未命名节点', - style: TextStyle( - fontSize: 18.sp, - fontWeight: FontWeight.w600, - ), + style: XFonts.titleMedium, ), ), Container( @@ -426,8 +585,8 @@ class _FlowChartWidgetState extends State { borderRadius: BorderRadius.circular(10.r), ), child: Text(style.label, - style: TextStyle( - fontSize: 12.sp, color: style.color)), + style: XFonts.labelSmall + .copyWith(color: style.color)), ), ], ), @@ -468,14 +627,10 @@ class _FlowChartWidgetState extends State { children: [ SizedBox( width: 80.w, - child: Text(label, - style: TextStyle( - fontSize: 14.sp, color: XColors.black6)), + child: Text(label, style: XFonts.bodySmall), ), Expanded( - child: Text(value, - style: TextStyle( - fontSize: 14.sp, color: OipText.primary)), + child: Text(value, style: XFonts.bodySmall), ), ], ), 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 60d32ab..4433fb6 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,7 +65,7 @@ class _FormDataListPageState extends XStatefulState { child: ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: selected - ? Theme.of(context).primaryColor + ? OipBrand.primary : Colors.grey.shade200, foregroundColor: selected ? Colors.white : Colors.black54, @@ -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/map_property_fields.dart b/lib/components/form/map_property_fields.dart index 7a3bae5..afc698d 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'; @@ -104,7 +105,7 @@ class _MapLocationFieldState extends State { : (_data!.name.isNotEmpty ? _data!.name : '${_data!.latitude.toStringAsFixed(4)}, ${_data!.longitude.toStringAsFixed(4)}'), - style: const TextStyle(fontSize: 13), + style: XFonts.caption, ), ), ); @@ -212,7 +213,7 @@ class _MapRegionFieldState extends State { ? const Text('点击选择区域', style: TextStyle(color: Color(0xFF94A3B8))) : Text( _formatRegion(_data!.polygon!), - style: const TextStyle(fontSize: 12, fontFamily: 'monospace'), + style: XFonts.caption.copyWith(fontFamily: 'monospace'), ), ), ); @@ -338,8 +339,7 @@ class _MapTrackFieldState extends State { ), child: _path.isEmpty ? const Text('暂无轨迹', style: TextStyle(color: Color(0xFF94A3B8))) - : Text('${_path.length} 个轨迹点 · 点击查看', - style: const TextStyle(fontSize: 13)), + : 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 4ecc4c2..28edf2d 100644 --- a/lib/components/form/mapping_components.dart +++ b/lib/components/form/mapping_components.dart @@ -822,9 +822,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 +845,7 @@ class _SensorSelectSheet extends StatelessWidget { color: OipSurface.muted, child: Text( item.headerText!, - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - color: OipText.secondary, - ), + style: XFonts.labelMedium.copyWith(color: OipText.secondary), ), ); } diff --git a/lib/components/form/view_preview_page.dart b/lib/components/form/view_preview_page.dart index e0b7329..f38493c 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'; @@ -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: Colors.white) + : 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: Colors.white70), maxLines: 2, overflow: TextOverflow.ellipsis, ), @@ -90,14 +86,7 @@ class _ViewPreviewPageState extends XStatefulState { 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, - ), - ), + child: Text(tag, style: XFonts.whiteSmall), ); }).toList(), ), @@ -134,11 +123,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: Colors.black87), ), ], ), @@ -155,21 +140,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: Colors.black87), overflow: TextOverflow.ellipsis, ), ), @@ -202,11 +177,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: Colors.black87), ), ], ), @@ -232,10 +203,7 @@ class _ViewPreviewPageState extends XStatefulState { const SizedBox(height: 16), Text( '暂不支持此视图预览', - style: TextStyle( - fontSize: 14, - color: Colors.grey.shade600, - ), + style: XFonts.bodySmall.copyWith(color: Colors.grey.shade600), ), ], ), @@ -271,12 +239,7 @@ class _ViewPreviewPageState extends XStatefulState { ), ), 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)), ), ), ], @@ -304,10 +267,7 @@ class _ViewPreviewPageState extends XStatefulState { const SizedBox(height: 16), Text( '视图数据不存在', - style: TextStyle( - fontSize: 16, - color: Colors.grey.shade600, - ), + style: XFonts.bodySmall.copyWith(color: Colors.grey.shade600), ), ], ), diff --git a/lib/dart/base/common/systemError.dart b/lib/dart/base/common/systemError.dart index 374c3b5..822748e 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,50 @@ import '../storages/storage.dart'; class LogInfo { late final String title; late final String content; + // 新增:毫秒时间戳,作为排序和去重键;旧数据无则按 title 解析回填 + late final int timestamp; - LogInfo({this.title = '', this.content = ''}); + LogInfo({this.title = '', this.content = '', int? timestamp}) + : timestamp = 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; + } } //转成JSON Map toJson() { - Map json = {}; + final json = {}; json["t"] = title; json["errorText"] = content; + json["ts"] = timestamp; return json; } + + // 去重键:内容前 200 字符 + title + String get dedupeKey => '${title}::${content.length > 200 ? content.substring(0, 200) : content}'; } /// 系统日志 class SystemLog { // 单例 static SystemLog? _instance; - List _errors; - // void Function(FlutterErrorDetails)? _onError; + final List _errors; + // 内容去重索引:dedupeKey -> 最近一次 timestamp + final Map _dedupeMap = {}; + // 异步落盘去重:避免高频错误每条都触发 Storage.setJson + Timer? _flushTimer; + // 容量上限 + static const int _maxCapacity = 200; late bool _inited; factory SystemLog() { @@ -42,10 +63,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 +74,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 (final info in list) { + _dedupeMap[info.dedupeKey] = info.timestamp; + } + } catch (e) { + XLogUtil.e('SystemLog.init 解析失败: $e'); + } } - _inited = true; } @@ -70,33 +97,87 @@ 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?._dedupeMap.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); + _instance?._append(content, title); + } + + void _append(dynamic content, String? title) { + title ??= getDefTitle(); + String contentStr; + if (content is String) { + contentStr = content; + } else { + try { + contentStr = jsonEncode(content); + } catch (_) { + contentStr = '$content'; + } + } + + final info = LogInfo( + title: title, + content: contentStr, + timestamp: DateTime.now().millisecondsSinceEpoch, + ); + + // 去重:相同内容在 5 秒内不重复记录 + final lastTs = _dedupeMap[info.dedupeKey]; + if (lastTs != null && (info.timestamp - lastTs) < 5000) { + // 5 秒内相同错误:更新时间戳(让最近一次"浮前"),不新增条目 + final idx = _errors.indexWhere((e) => e.dedupeKey == info.dedupeKey); + if (idx >= 0) { + _errors[idx] = info; + _dedupeMap[info.dedupeKey] = info.timestamp; + _scheduleFlush(); + return; + } } - _instance?._errors.add(LogInfo(title: title, content: content)); - _instance?._save(); + _errors.add(info); + _dedupeMap[info.dedupeKey] = info.timestamp; + _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); + _dedupeMap.remove(removed.dedupeKey); } } + + /// 异步批量落盘: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/core/provider/auth.dart b/lib/dart/core/provider/auth.dart index 5162c60..15f2473 100644 --- a/lib/dart/core/provider/auth.dart +++ b/lib/dart/core/provider/auth.dart @@ -2,6 +2,7 @@ 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'; @@ -152,6 +153,9 @@ mixin AuthMixin { SessionResource.clearAll(); IotServiceManager().clearAll(); + // 清理错误日志(前一用户的日志不应残留),与 SystemLog 单例解绑 + SystemLog.clearForLogout(); + // 清理数据新鲜度跟踪记录(core 层),避免跨账号残留 DataFreshnessTracker.instance.invalidateAll(); diff --git a/lib/dart/core/target/team/company.dart b/lib/dart/core/target/team/company.dart index 1a1d757..8bab666 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'; @@ -115,7 +116,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 +128,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)); } diff --git a/lib/dart/core/thing/directory.dart b/lib/dart/core/thing/directory.dart index 8771a07..c494f0b 100644 --- a/lib/dart/core/thing/directory.dart +++ b/lib/dart/core/thing/directory.dart @@ -611,14 +611,28 @@ 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 { + if (parent == null || reload == true) { + await resource.preLoad(reload: reload!); + } + 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/pages/chats/chat_page.dart b/lib/pages/chats/chat_page.dart index 4b6f7fb..6d099c2 100644 --- a/lib/pages/chats/chat_page.dart +++ b/lib/pages/chats/chat_page.dart @@ -170,15 +170,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( @@ -220,20 +219,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("组织群")) && @@ -241,24 +259,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/relation/relation_page.dart b/lib/pages/relation/relation_page.dart index d8cbae8..e94fee7 100644 --- a/lib/pages/relation/relation_page.dart +++ b/lib/pages/relation/relation_page.dart @@ -294,6 +294,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(); @@ -313,21 +328,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); diff --git a/lib/pages/store/dba/collection_content_page.dart b/lib/pages/store/dba/collection_content_page.dart index 9c44299..303ae19 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 1089445..79b4edd 100644 --- a/lib/pages/store/dba/collection_list_page.dart +++ b/lib/pages/store/dba/collection_list_page.dart @@ -160,7 +160,10 @@ class _CollectionListPageState extends State { return Scaffold( backgroundColor: XColors.white, appBar: AppBar( - title: Text('${_storage?.name ?? '数据库'} - 数据库'), + title: Text( + '${_storage?.name ?? '数据库'} - 数据库', + style: XFonts.titleMedium, + ), backgroundColor: XColors.white, elevation: 0.5, actions: [ @@ -177,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), @@ -199,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, ), ), @@ -214,8 +217,7 @@ class _CollectionListPageState extends State { ), child: Text( coll.collTypeName, - style: TextStyle( - fontSize: 11, + style: XFonts.labelSmall.copyWith( color: _getTypeColor(coll.collTypeName), ), ), @@ -226,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_index_dialog.dart b/lib/pages/store/dba/create_index_dialog.dart index 66a15ca..15dd663 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; @@ -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 f9fc2f0..bb2eaf4 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 d269240..a3879ad 100644 --- a/lib/pages/store/store_page.dart +++ b/lib/pages/store/store_page.dart @@ -236,7 +236,6 @@ 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 @@ -347,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 @@ -626,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(); // @@ -660,6 +651,10 @@ class _StorePageState extends State ///加载办事 Future> loadWorks(FixedDirectory item) async { + // belongApplication 为空时兜底返回空,避免强制解包抛异常 + if (item.belongApplication == null) { + return []; + } List works = await item.belongApplication!.loadWorks(); //过滤已删除目录 diff --git a/lib/routers/pages.dart b/lib/routers/pages.dart index d6095e5..97ae0aa 100644 --- a/lib/routers/pages.dart +++ b/lib/routers/pages.dart @@ -514,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); } @@ -525,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); } @@ -546,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); } -- Gitee From 8c19f934c8ad2468f57020ee4908fc7bac516f40 Mon Sep 17 00:00:00 2001 From: panzhaohui Date: Sat, 25 Jul 2026 12:43:57 +0800 Subject: [PATCH 12/37] =?UTF-8?q?fix:=20=E9=A3=8E=E6=A0=BC=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E3=80=81SQLite=20=E5=85=A8=E9=87=8F=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96=E3=80=81=E7=82=B9=E5=87=BB=E8=A7=A6=E5=8F=91=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E6=9B=B4=E6=96=B0=E3=80=81=E5=8D=95=E4=BD=8D=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AGENTS.md §6 本地存储: 重写为 SQLite 全量持久化 + 本地优先,新增 L0/L1/L2/L3 分层、表结构约束、写入策略 - AGENTS.md §17 整体交互设计风格统一: 新增视觉令牌统一、同类页面风格一致、行为一致 - AGENTS.md §18 数据更新机制: 新增点击触发后台更新 + 持久化同刷、deepLoad 约束,明确 SQLite L1 全量持久化 - AGENTS.md §19 测试验证流程: 新增每次测试必检查错误日志的硬约束 - AGENTS.md §20 PR 自检清单: 新增风格统一/数据更新/测试验证三项 - ProcessDetailsWidget: 删除"历史痕迹"Tab,TabBar 字体改用 XFonts.tabLabel/tabUnselected - FlowChartWidget: 修复 4 个 unnecessary_null_comparison/non_null_assertion 警告 - work_page/discover/all_activities_page/live_list_page: 移除 switchSpace 订阅,单位切换不影响非门户页面 - work_start_page: 整体风格统一改用 OipBrand.primary 蓝色,提交按钮高度 48→52.h,文字居中,使用 XFonts/OipSurface/OipShadow 令牌 --- AGENTS.md | 165 ++++++++++++++++-- .../ProcessDetailsWidget.dart | 14 +- .../components/FlowChartWidget.dart | 8 +- .../discover/pages/all_activities_page.dart | 10 +- lib/pages/discover/pages/live_list_page.dart | 8 +- lib/pages/work/work_page.dart | 11 +- lib/pages/work/work_start_page.dart | 127 ++++++-------- 7 files changed, 216 insertions(+), 127 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1b676e2..0603b3e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,13 +96,38 @@ --- -## 6. 本地存储 +## 6. 本地存储(SQLite 全量持久化 + 本地优先) -- 轻量 KV:`Storage`(SharedPreferences),只存 token、开关、少量字符串/数字。 -- 结构化缓存:`HiveUtils` + Hive Box。 -- key:避免魔法字符串,集中维护在现有常量位置。 -- 禁止明文落盘敏感信息(私钥、密码、验证码、完整身份证/银行卡等)。 -- **离线优先**:设备数据/告警本地缓存(SQLite L1),离线指令排队,上线后补发。 +### 6.1 存储分层(硬约束) + +| 层级 | 用途 | 实现 | 范围 | +| ---- | ---- | ---- | ---- | +| L0 内存 | 运行时缓存 | `relationCtrl.user.*` / `provider.chats` / `GetX Rx` | 当前会话活跃数据 | +| L1 SQLite | **全量持久化** | `sqflite`(IoT 用 `sqflite_sqlcipher` 加密) | 用户浏览过的所有业务数据:会话/消息/动态/办事/关系树/存储/智能体/设备/告警 | +| L2 KV | 轻量配置 | `Storage`(SharedPreferences) | token、开关、主题、最近单位等少量字符串/数字 | +| L3 Hive | 结构化草稿 | `HiveUtils` + Hive Box | 办事草稿、表单暂存、文件上传队列 | + +### 6.2 SQLite 全量持久化(硬约束) + +- **所有用户浏览过的业务数据必须写入 SQLite**:会话列表、消息内容、动态、办事任务、关系树(单位/群组/部门/集群/存储/智能体/感知设备)、存储资源、表单数据等。 +- **本地优先**:页面打开时先从 SQLite 读取全量数据渲染,再后台拉取最新数据。 +- **页面点击触发后台更新**:用户点击列表项 / 进入子页面 / 切换 Tab 时,后台 `fire-and-forget` 拉取当前页面数据的最新版本,**持久化到 SQLite 同时 `setState` 刷新页面**(有变化才刷新,无变化不重建)。 +- **登出清空**:`exitLogin` 必须清空 SQLite 全部业务表(保留 L2 的 token 黑名单等必要配置),防止跨用户数据污染。 +- **表结构**:按业务域分表(`sessions` / `messages` / `activities` / `work_tasks` / `relations` / `storages` / `agents` / `iot_devices` / `iot_alerts`),主键 `id` + 索引 `belongId` / `updateTime` / `spaceId`。 +- **写入策略**:upsert(`INSERT OR REPLACE`),保证幂等;批量写入用事务,禁止循环单条写入。 + +### 6.3 通用约束 + +- key / 表名 / 列名:避免魔法字符串,集中维护在 `lib/dart/base/storage/`(待建)或现有常量位置。 +- 禁止明文落盘敏感信息(私钥、密码、验证码、完整身份证/银行卡等);token 用 Keychain/Keystore,不入 SQLite。 +- **离线指令**:办事提交 / IoT 指令 / 动态发布等写操作,离线时入 SQLite 队列,上线后补发。 +- **数据库迁移**:版本升级用 `sqflite` 的 `onUpgrade` 增量迁移,禁止 `DROP` 全表。 + +### 6.4 现有 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 → 远端)。 +- VET 缓存:[lib/dart/base/security/secure_database.dart](lib/dart/base/security/secure_database.dart)(待替换为 `sqflite_sqlcipher`)。 +- 其他业务域 SQLite 持久化按需推进,新增业务必须遵循 §6.2 约束。 --- @@ -407,16 +432,124 @@ lib/pages/iot/ # 物联网页面 --- -## 17. 测试与质量门槛 +## 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`)。 + +### 19.3 测试覆盖范围 -- 纯逻辑(尤其 `lib/dart/core`)新增/修复建议补单测。 -- 关键交互可补 widget test(按成本评估)。 -- 提交前保证 `flutter analyze` 不引入新增错误。 -- 物联网模块(`dart/core/iot/`)建议补单测,覆盖物模型解析、指令封装、推送分发。 +- 纯逻辑(`lib/dart/core`)新增/修复补单测。 +- 关键交互补 widget test。 +- 物联网模块(`dart/core/iot/`)补单测:物模型解析、指令封装、推送分发。 +- 提交前 `flutter analyze` 不引入新增错误。 --- -## 18. PR 自检清单(提交前逐条对照) +## 20. PR 自检清单(提交前逐条对照) - [ ] 代码放对目录:pages/components/dart/routers/config/utils - [ ] 路由:已注册到 `RoutePages` 体系,没私建第二套路由 @@ -430,11 +563,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/` 生成物)。 @@ -442,7 +577,7 @@ lib/pages/iot/ # 物联网页面 --- -## 20. 协作规范 +## 22. 协作规范 - 默认中文沟通。 - 修改前先读相关文件;只做需求相关改动。 diff --git a/lib/components/ProcessDetailsWidget/ProcessDetailsWidget.dart b/lib/components/ProcessDetailsWidget/ProcessDetailsWidget.dart index 336368d..29983a0 100644 --- a/lib/components/ProcessDetailsWidget/ProcessDetailsWidget.dart +++ b/lib/components/ProcessDetailsWidget/ProcessDetailsWidget.dart @@ -1,10 +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/FlowChartWidget.dart'; -import 'package:orginone/components/ProcessDetailsWidget/components/UseTracesWidget.dart'; import '../../dart/base/schema.dart'; import '../../dart/core/work/task.dart'; import '../XStatefulWidget/XStatefulWidget.dart'; @@ -45,7 +45,6 @@ class _ProcessDetailsPageState List tabTitle = [ '办事详情', '流程跟踪', - '历史痕迹', ]; @override @@ -95,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, ), ); @@ -118,11 +117,6 @@ class _ProcessDetailsPageState todo?.instance != null ? FlowChartWidget(todo: todo) : const Text('暂无流程实例'), - todo?.instance != null - ? UseTracesWidget( - todo: todo, - ) - : const Text('暂无流程实例'), ], ), ) diff --git a/lib/components/ProcessDetailsWidget/components/FlowChartWidget.dart b/lib/components/ProcessDetailsWidget/components/FlowChartWidget.dart index 0c81e86..806bb79 100644 --- a/lib/components/ProcessDetailsWidget/components/FlowChartWidget.dart +++ b/lib/components/ProcessDetailsWidget/components/FlowChartWidget.dart @@ -125,9 +125,9 @@ class _FlowChartWidgetState extends State { } // 解析子流程实例数据(task.instance.data 是 InstanceDataModel 的 JSON 字符串) if (t.instance?.data != null && t.instance!.data!.isNotEmpty) { - if (t.id != null && !_subInstanceDataMap.containsKey(t.id)) { + if (!_subInstanceDataMap.containsKey(t.id)) { try { - _subInstanceDataMap[t.id!] = + _subInstanceDataMap[t.id] = InstanceDataModel.fromJson(jsonDecode(t.instance!.data!)); } catch (e) { XLogUtil.w('[FlowChartWidget] 子流程实例数据解析失败: $e'); @@ -386,9 +386,7 @@ class _FlowChartWidgetState extends State { if (node.type == 'sub' && node.id != null) { final subTask = _subProcessTaskMap[node.id]; if (subTask != null) { - final subData = subTask.id != null - ? _subInstanceDataMap[subTask.id!] - : null; + final subData = _subInstanceDataMap[subTask.id]; final subRoot = subData?.node; final subTasks = subTask.tasks ?? []; if (subRoot != null) { diff --git a/lib/pages/discover/pages/all_activities_page.dart b/lib/pages/discover/pages/all_activities_page.dart index 82e91f7..1ffc3c8 100644 --- a/lib/pages/discover/pages/all_activities_page.dart +++ b/lib/pages/discover/pages/all_activities_page.dart @@ -90,14 +90,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 diff --git a/lib/pages/discover/pages/live_list_page.dart b/lib/pages/discover/pages/live_list_page.dart index 02578f5..a009473 100644 --- a/lib/pages/discover/pages/live_list_page.dart +++ b/lib/pages/discover/pages/live_list_page.dart @@ -39,12 +39,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 diff --git a/lib/pages/work/work_page.dart b/lib/pages/work/work_page.dart index 0d22323..10b43d5 100644 --- a/lib/pages/work/work_page.dart +++ b/lib/pages/work/work_page.dart @@ -52,15 +52,8 @@ class _WorkPageState extends State } }); relationCtrl.provider.ensureDataFresh(DataType.work); - final subId = command.subscribeByFlag('switchSpace', ([args]) { - if (!mounted) return; - setState(() { - _workModel = null; - _lastTodosCount = null; - _isRefreshing = true; - }); - }, false); - _subscriptions.add(subId); + // 办事页不跟随单位切换刷新数据,保持本地缓存优先策略, + // 加速页面渲染速度。单位切换只刷新门户页面数据。 } @override diff --git a/lib/pages/work/work_start_page.dart b/lib/pages/work/work_start_page.dart index 725da21..1b1ccb7 100644 --- a/lib/pages/work/work_start_page.dart +++ b/lib/pages/work/work_start_page.dart @@ -373,13 +373,13 @@ class _WorkStartPageState extends XStatefulState { 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), ], ), ); @@ -392,17 +392,21 @@ class _WorkStartPageState extends XStatefulState { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.error_outline, size: 64, color: Colors.grey.shade400), + 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)), ), ], ), @@ -411,7 +415,7 @@ class _WorkStartPageState extends XStatefulState { } if (_apply == null) { - return const Center(child: Text('无法创建申请单')); + return Center(child: Text('无法创建申请单', style: XFonts.bodyMedium)); } return Column( @@ -442,28 +446,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, ), @@ -475,7 +475,7 @@ class _WorkStartPageState extends XStatefulState { SizedBox(height: 8.h), Text( _work!.metadata.remark!, - style: TextStyle(fontSize: 16.sp, color: Colors.white70), + style: XFonts.bodySmall.copyWith(color: Colors.white70), maxLines: 2, overflow: TextOverflow.ellipsis, ), @@ -510,7 +510,7 @@ class _WorkStartPageState extends XStatefulState { padding: EdgeInsets.symmetric(vertical: 32.h), child: Text( '该办事暂无表单', - style: TextStyle(fontSize: 16.sp, color: Colors.grey.shade500), + style: XFonts.bodyMedium.copyWith(color: OipText.tertiary), ), ), ), @@ -523,11 +523,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, ), ); } @@ -544,15 +540,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, @@ -560,18 +550,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, ), ], ), @@ -597,15 +583,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, @@ -616,11 +596,7 @@ class _WorkStartPageState extends XStatefulState { SizedBox(width: 8.w), Text( '备注', - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - color: XColors.black3, - ), + style: XFonts.titleSmall, ), ], ), @@ -628,19 +604,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( @@ -658,29 +635,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( @@ -691,11 +671,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), ), ), ), -- Gitee From fe26c729762496de97d78cce2333cfd40e7f2430 Mon Sep 17 00:00:00 2001 From: panzhaohui Date: Sat, 25 Jul 2026 13:12:29 +0800 Subject: [PATCH 13/37] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20exitLogin=20?= =?UTF-8?q?=E6=9C=AA=E6=B8=85=E7=A9=BA=20IoT=20SQLite=20=E7=BC=BA=E9=99=B7?= =?UTF-8?q?=EF=BC=8CDeviceRepository=20=E5=AE=9E=E7=8E=B0=E7=9C=9F?= =?UTF-8?q?=E6=AD=A3=E7=9A=84=E6=9C=AC=E5=9C=B0=E4=BC=98=E5=85=88=E4=B8=89?= =?UTF-8?q?=E5=B1=82=E8=AF=BB=E5=8F=96=EF=BC=8C=E7=BB=9F=E4=B8=80=20AppBar?= =?UTF-8?q?=20=E6=A0=87=E9=A2=98=E5=92=8C=20ElevatedButton=20=E4=B8=BB?= =?UTF-8?q?=E8=89=B2=E8=A7=84=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - exitLogin 增加 IotDataCache.instance.clearAll() 调用,清空 iot_devices/iot_thing_models/iot_events/iot_commands 四张业务表,符合 AGENTS.md §6.2 登出清空 SQLite 硬约束 - DeviceRepository.listDevices 重构为真正的"内存 → SQLite L1 → 远端"三层读取:内存命中即返回并后台刷新、SQLite L1 命中即返回并后台刷新、本地无数据才在线拉取;远端结果比较 lastSeen/status 变化才 notifyListeners,避免无变化重建 - 14 个 AppBar 标题统一使用 XFonts.headlineSmall(vet_detail、vet_management、market_browser、approval_center、device_map、device_list、location_picker、space_select、asset_map、add_favorite、data_share_list、video_list、market_list、live_list、notice_list、group_share_list、all_activities) - 8 个 ElevatedButton 主色统一使用 OipBrand.primary 和 XFonts.labelLarge(agent_settings 测试连接/保存按钮、ChatBoxWidget 发送按钮、password_change 确认修改、secret_edit 保存、account_binding 开始扫码/确认绑定、standard_widgets 重新加载、form_data_list_page 视图切换按钮) - form_data_list_page 视图切换按钮未选中态颜色从 Colors.grey.shade200 改为 OipSurface.muted,未选中文字色从 Colors.black54 改为 OipText.secondary --- .../ChatBoxWidget/ChatBoxWidget.dart | 5 +- .../StandardComponents/standard_widgets.dart | 5 +- .../form_data_list_page.dart | 4 +- lib/dart/core/iot/device_repository.dart | 100 ++++++++++++------ lib/dart/core/provider/auth.dart | 4 + lib/pages/common/asset_map_page.dart | 2 +- lib/pages/common/location_picker_page.dart | 4 +- lib/pages/common/space_select_page.dart | 2 +- .../discover/pages/all_activities_page.dart | 2 +- .../discover/pages/data_share_list_page.dart | 3 +- .../discover/pages/group_share_list_page.dart | 3 +- lib/pages/discover/pages/live_list_page.dart | 3 +- .../discover/pages/market_list_page.dart | 3 +- .../discover/pages/notice_list_page.dart | 3 +- lib/pages/discover/pages/video_list_page.dart | 3 +- lib/pages/iot/device_list_page.dart | 2 +- lib/pages/iot/device_map_page.dart | 3 +- lib/pages/oip/approval_center_page.dart | 2 +- lib/pages/oip/market_browser_page.dart | 3 +- lib/pages/oip/vet_detail_page.dart | 2 +- lib/pages/oip/vet_management_page.dart | 2 +- lib/pages/portal/account_binding_page.dart | 20 ++-- lib/pages/portal/add_favorite_page.dart | 3 +- lib/pages/portal/agent_settings_page.dart | 9 +- lib/pages/portal/password_change_page.dart | 7 +- lib/pages/portal/secret_edit_page.dart | 7 +- 26 files changed, 136 insertions(+), 70 deletions(-) diff --git a/lib/components/ChatSessionWidget/components/ChatBoxWidget/ChatBoxWidget.dart b/lib/components/ChatSessionWidget/components/ChatBoxWidget/ChatBoxWidget.dart index 752dd3e..d63a51d 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/StandardComponents/standard_widgets.dart b/lib/components/StandardComponents/standard_widgets.dart index 5e4efc2..d666118 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/form/form_data_list_page/form_data_list_page.dart b/lib/components/form/form_data_list_page/form_data_list_page.dart index 4433fb6..907b85d 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 @@ -66,9 +66,9 @@ class _FormDataListPageState extends XStatefulState { style: ElevatedButton.styleFrom( backgroundColor: selected ? OipBrand.primary - : Colors.grey.shade200, + : OipSurface.muted, foregroundColor: - selected ? Colors.white : Colors.black54, + selected ? Colors.white : OipText.secondary, elevation: 0, padding: const EdgeInsets.symmetric(vertical: 8), ), diff --git a/lib/dart/core/iot/device_repository.dart b/lib/dart/core/iot/device_repository.dart index 5f5d390..89508c7 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/provider/auth.dart b/lib/dart/core/provider/auth.dart index 15f2473..5672803 100644 --- a/lib/dart/core/provider/auth.dart +++ b/lib/dart/core/provider/auth.dart @@ -11,6 +11,7 @@ import 'package:orginone/dart/base/storages/local_page_cache_repository.dart'; import 'package:orginone/dart/base/storages/storage.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/target/base/resource.dart'; @@ -152,6 +153,9 @@ mixin AuthMixin { 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(); // 清理错误日志(前一用户的日志不应残留),与 SystemLog 单例解绑 SystemLog.clearForLogout(); diff --git a/lib/pages/common/asset_map_page.dart b/lib/pages/common/asset_map_page.dart index 65e7202..3bee00c 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 diff --git a/lib/pages/common/location_picker_page.dart b/lib/pages/common/location_picker_page.dart index fff108a..2ea3ad1 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 e1c2760..f8dc60c 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, diff --git a/lib/pages/discover/pages/all_activities_page.dart b/lib/pages/discover/pages/all_activities_page.dart index 1ffc3c8..7daea1c 100644 --- a/lib/pages/discover/pages/all_activities_page.dart +++ b/lib/pages/discover/pages/all_activities_page.dart @@ -211,7 +211,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, diff --git a/lib/pages/discover/pages/data_share_list_page.dart b/lib/pages/discover/pages/data_share_list_page.dart index 408cb26..8fdcaee 100644 --- a/lib/pages/discover/pages/data_share_list_page.dart +++ b/lib/pages/discover/pages/data_share_list_page.dart @@ -2,6 +2,7 @@ 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/discover_list_cache.dart'; import 'package:orginone/pages/discover/widgets/plaza_style_cards.dart'; @@ -271,7 +272,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, diff --git a/lib/pages/discover/pages/group_share_list_page.dart b/lib/pages/discover/pages/group_share_list_page.dart index c487675..2ed776e 100644 --- a/lib/pages/discover/pages/group_share_list_page.dart +++ b/lib/pages/discover/pages/group_share_list_page.dart @@ -2,6 +2,7 @@ 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/discover_list_cache.dart'; import 'package:orginone/utils/log/log_util.dart'; @@ -271,7 +272,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, diff --git a/lib/pages/discover/pages/live_list_page.dart b/lib/pages/discover/pages/live_list_page.dart index a009473..6d503a8 100644 --- a/lib/pages/discover/pages/live_list_page.dart +++ b/lib/pages/discover/pages/live_list_page.dart @@ -5,6 +5,7 @@ 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/utils/log/log_util.dart'; import '../discover_service.dart'; @@ -186,7 +187,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, diff --git a/lib/pages/discover/pages/market_list_page.dart b/lib/pages/discover/pages/market_list_page.dart index edeafdb..24a74b3 100644 --- a/lib/pages/discover/pages/market_list_page.dart +++ b/lib/pages/discover/pages/market_list_page.dart @@ -2,6 +2,7 @@ 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/discover_list_cache.dart'; import 'package:orginone/pages/discover/widgets/plaza_style_cards.dart'; @@ -246,7 +247,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, diff --git a/lib/pages/discover/pages/notice_list_page.dart b/lib/pages/discover/pages/notice_list_page.dart index 081a379..fc12ae3 100644 --- a/lib/pages/discover/pages/notice_list_page.dart +++ b/lib/pages/discover/pages/notice_list_page.dart @@ -2,6 +2,7 @@ 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/discover_list_cache.dart'; import 'package:orginone/pages/discover/widgets/plaza_style_cards.dart'; @@ -271,7 +272,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, diff --git a/lib/pages/discover/pages/video_list_page.dart b/lib/pages/discover/pages/video_list_page.dart index 270894e..eee23b1 100644 --- a/lib/pages/discover/pages/video_list_page.dart +++ b/lib/pages/discover/pages/video_list_page.dart @@ -2,6 +2,7 @@ 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/utils/log/log_util.dart'; import 'package:orginone/pages/discover/widgets/discover_list_cache.dart'; @@ -273,7 +274,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, diff --git a/lib/pages/iot/device_list_page.dart b/lib/pages/iot/device_list_page.dart index 8062b68..12bff18 100644 --- a/lib/pages/iot/device_list_page.dart +++ b/lib/pages/iot/device_list_page.dart @@ -150,7 +150,7 @@ 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, diff --git a/lib/pages/iot/device_map_page.dart b/lib/pages/iot/device_map_page.dart index 424a0d9..b5b1e60 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 4af3ce1..d3ace5b 100644 --- a/lib/pages/oip/approval_center_page.dart +++ b/lib/pages/oip/approval_center_page.dart @@ -127,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, diff --git a/lib/pages/oip/market_browser_page.dart b/lib/pages/oip/market_browser_page.dart index d238087..2964ddc 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 6c0c806..7dc76ee 100644 --- a/lib/pages/oip/vet_detail_page.dart +++ b/lib/pages/oip/vet_detail_page.dart @@ -29,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]; diff --git a/lib/pages/oip/vet_management_page.dart b/lib/pages/oip/vet_management_page.dart index ef17502..39efb1a 100644 --- a/lib/pages/oip/vet_management_page.dart +++ b/lib/pages/oip/vet_management_page.dart @@ -59,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, diff --git a/lib/pages/portal/account_binding_page.dart b/lib/pages/portal/account_binding_page.dart index b5ea3da..e6a2e84 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'; @@ -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)), ), ), ], @@ -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 4bc30ff..34c8127 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, ), diff --git a/lib/pages/portal/agent_settings_page.dart b/lib/pages/portal/agent_settings_page.dart index fbbf3bc..91dcf96 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/password_change_page.dart b/lib/pages/portal/password_change_page.dart index c28137f..2721b98 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'; @@ -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)), ), ), ], diff --git a/lib/pages/portal/secret_edit_page.dart b/lib/pages/portal/secret_edit_page.dart index 6e998de..9f26318 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'; @@ -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)), ), ); } -- Gitee From c7c7369e4910f28d24c7d9c0946863c9ac64275c Mon Sep 17 00:00:00 2001 From: panzhaohui Date: Sat, 25 Jul 2026 15:58:26 +0800 Subject: [PATCH 14/37] =?UTF-8?q?fix:=20=E8=A1=A8=E5=8D=95=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E5=85=A8=E7=B1=BB=E5=9E=8B=E8=A6=86=E7=9B=96=E4=B8=8E?= =?UTF-8?q?=E5=85=B3=E7=B3=BB=E9=A1=B5=E6=9C=AC=E5=9C=B0=E4=BC=98=E5=85=88?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 表单组件(对齐 oiocns-react): - Fields 类新增 lookups/speciesId 非持久化字段,传递给 sensor/space 组件 - initFields 阶段统一从 field.lookups 填充 select,修复新建表单下拉框为空 - buildField 新增 multiSelect 分支,处理 List 值解析与填充 - mappingComponents 新增 multiSelect/mapLocation/mapRegion/mapTrack/space/sensor 六类组件 - mappingInputWidget 根据 regx 设置数字键盘与输入过滤 - mappingSelectBoxWidget/mappingMultiSelectBoxWidget/mappingSwitchWidget 增加 null 安全检查 - mappingSensorWidget 改用 Fields.lookups 获取传感器列表,存储改为字符串 ID - _showSpaceSelect 改用 Fields.speciesId,移除错误的 FieldModel 类型判断 - _getFieldValue/_setFieldValue 添加 multiSelect 值转换(List ↔ List) - ProcessInfoWidget._buildField 补齐 multiSelect/mapLocation/mapRegion/mapTrack/space/sensor 处理 - XTextField 新增 keyboardType/inputFormatters 参数 - DialogUtils 新增 showMultiSelectPicker 多选弹窗 关系页与杂项: - relation_page 单位子页面 Tab 本地优先数据加载,避免 FutureBuilder 转圈 - chat_page/person/task/portal_header 等小幅修复与风格统一 flutter analyze 通过(313 info 全为历史遗留,无新增 error/warning) --- .../components/ActivityReleaseWidget.dart | 3 + .../components/ApplyWidget.dart | 22 +-- .../ProcessInfoWidget/ProcessInfoWidget.dart | 40 ++++++ lib/components/XDialogs/dialog_utils.dart | 90 ++++++++++++ lib/components/XScaffold/XScaffold.dart | 13 +- lib/components/XTextField/XTextField.dart | 17 ++- .../form/form_widget/form_tool.dart | 92 +++++++++++- lib/components/form/mapping_components.dart | 136 +++++++++++++----- .../form/widgets/form_datas_view.dart | 7 +- .../models/asset_creation_config.dart | 9 +- lib/dart/core/target/person.dart | 12 +- lib/dart/core/work/task.dart | 5 +- lib/pages/auth/forgot_password/view.dart | 2 +- lib/pages/chats/chat_page.dart | 20 ++- lib/pages/portal/widgets/portal_header.dart | 6 +- lib/pages/relation/relation_page.dart | 106 +++++++++++--- lib/pages/work/work_start_page.dart | 105 +++++++++++++- 17 files changed, 580 insertions(+), 105 deletions(-) diff --git a/lib/components/ActivityWidget/ActivityListWidget/components/ActivityReleaseWidget.dart b/lib/components/ActivityWidget/ActivityListWidget/components/ActivityReleaseWidget.dart index 83a046d..403910f 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/ProcessDetailsWidget/components/ApplyWidget.dart b/lib/components/ProcessDetailsWidget/components/ApplyWidget.dart index 03ddfee..e911905 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/ProcessInfoWidget/ProcessInfoWidget.dart b/lib/components/ProcessDetailsWidget/components/ProcessInfoWidget/ProcessInfoWidget.dart index 733e8fe..c1640b9 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/XDialogs/dialog_utils.dart b/lib/components/XDialogs/dialog_utils.dart index 0bebb12..dc6f21d 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/XScaffold/XScaffold.dart b/lib/components/XScaffold/XScaffold.dart index ea38256..23c884d 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/XTextField/XTextField.dart b/lib/components/XTextField/XTextField.dart index 398e612..b846027 100644 --- a/lib/components/XTextField/XTextField.dart +++ b/lib/components/XTextField/XTextField.dart @@ -28,6 +28,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 +49,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 +73,8 @@ class XTextField extends StatelessWidget { required = false, maxLines = null, content = null, - enabled = null; + enabled = null, + keyboardType = null; /// 输入框 XTextField.input( @@ -87,10 +90,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, @@ -135,6 +139,7 @@ class XTextField extends StatelessWidget { maxLines: maxLines, minLines: 1, enabled: enabled, + keyboardType: keyboardType, controller: controller ?? TextEditingController(text: content), onSubmitted: onSubmitted, diff --git a/lib/components/form/form_widget/form_tool.dart b/lib/components/form/form_widget/form_tool.dart index 0493940..324d5cb 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/mapping_components.dart b/lib/components/form/mapping_components.dart index 28edf2d..661255f 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, @@ -136,15 +137,26 @@ MappingComponentsCallback mappingReferenceTextWidget = ); }; 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); @@ -208,6 +220,67 @@ MappingComponentsCallback mappingSelectBoxWidget = }); }; +/// 多选框组件(对齐 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.size22Black0, + ), + ); + }); +}; + MappingComponentsCallback mappingSelectTimeBoxWidget = (Fields data, ITarget target) { if (data.hidden ?? false) { @@ -492,7 +565,7 @@ 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( @@ -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,8 +805,8 @@ 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, @@ -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)), ), @@ -945,12 +1013,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/widgets/form_datas_view.dart b/lib/components/form/widgets/form_datas_view.dart index 3265926..2b64f3a 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/dart/base/storages/models/asset_creation_config.dart b/lib/dart/base/storages/models/asset_creation_config.dart index 6788281..9af9780 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/core/target/person.dart b/lib/dart/core/target/person.dart index ad71e27..231ae71 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'; @@ -1145,14 +1144,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/work/task.dart b/lib/dart/core/work/task.dart index 1f8b8ed..0a205c0 100644 --- a/lib/dart/core/work/task.dart +++ b/lib/dart/core/work/task.dart @@ -770,9 +770,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/pages/auth/forgot_password/view.dart b/lib/pages/auth/forgot_password/view.dart index 5d8b09d..ce48d59 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), diff --git a/lib/pages/chats/chat_page.dart b/lib/pages/chats/chat_page.dart index 6d099c2..b63ecaf 100644 --- a/lib/pages/chats/chat_page.dart +++ b/lib/pages/chats/chat_page.dart @@ -74,10 +74,28 @@ class _ChatPageState extends State } /// 触发后台数据刷新(带顶部"数据更新中"指示器) + /// + /// 首屏 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); - relationCtrl.provider.ensureDataFresh(DataType.chats).then((_) { + 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(); setState(() => _isBgRefreshing = false); diff --git a/lib/pages/portal/widgets/portal_header.dart b/lib/pages/portal/widgets/portal_header.dart index 3820113..7bfa9ed 100644 --- a/lib/pages/portal/widgets/portal_header.dart +++ b/lib/pages/portal/widgets/portal_header.dart @@ -163,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; } // 显示空间选择弹窗 @@ -214,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]); }, ), ], diff --git a/lib/pages/relation/relation_page.dart b/lib/pages/relation/relation_page.dart index e94fee7..999f027 100644 --- a/lib/pages/relation/relation_page.dart +++ b/lib/pages/relation/relation_page.dart @@ -234,8 +234,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); } } @@ -249,7 +254,8 @@ class _RelationState extends State if (title == '好友') { return loadFriends(); } - // 同事:加载单位所有成员 + // 同事:加载单位所有成员(本地优先:loadMembers(reload: false) + // 已加载过则直接返回内存数据,不会发起网络请求) if (title == '同事') { final company = _parentCompany; if (company == null) return []; @@ -400,6 +406,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) { @@ -437,24 +470,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 → 友好页签标题映射(单位内部页签:群组/部门/集群/存储/智能体/感知设备) @@ -593,9 +649,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 (_) { + // 静默失败,保留本地缓存 + } + }); } } diff --git a/lib/pages/work/work_start_page.dart b/lib/pages/work/work_start_page.dart index 1b1ccb7..be6c10e 100644 --- a/lib/pages/work/work_start_page.dart +++ b/lib/pages/work/work_start_page.dart @@ -205,10 +205,38 @@ class _WorkStartPageState extends XStatefulState { 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 { @@ -236,7 +264,7 @@ class _WorkStartPageState extends XStatefulState { ); } - // 提交前执行规则校验(对齐 oiocns-react handleSubmit) + // 3. 提交前执行规则校验(对齐 oiocns-react handleSubmit) if (_ruleService != null && _ruleService!.isReady) { _ruleService!.collectData('hotData', formData); final result = @@ -249,6 +277,7 @@ class _WorkStartPageState extends XStatefulState { } } + // 4. 提交申请 final success = await _apply!.createApply( userId, _remarkController.text, @@ -275,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; @@ -289,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': @@ -312,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; @@ -330,6 +412,25 @@ class _WorkStartPageState extends XStatefulState { 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': -- Gitee From b50e26299a3bbda7a6ae723cda9f60c2dc8382ab Mon Sep 17 00:00:00 2001 From: panzhaohui Date: Sat, 25 Jul 2026 20:21:31 +0800 Subject: [PATCH 15/37] =?UTF-8?q?perf:=20=E8=AE=BE=E8=AE=A1=E4=BB=A4?= =?UTF-8?q?=E7=89=8C=E7=BB=9F=E4=B8=80=E4=B8=8E=E5=BC=82=E6=AD=A5=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - map_property_fields: 硬编码 Color(0xFF94A3B8) → OipText.tertiary ×3 - map_property_fields: MapLocationField/MapRegionField._pick() 添加 mounted 检查 - mapping_components: mappingSwitchWidget 硬编码 Colors.white → OipSurface.card, Colors.grey.shade200 → OipBorder.divider, Colors.red → OipState.error - mapping_components: _SensorSelectSheet 硬编码 fontSize:16.sp → XFonts.caption - mapping_components: mappingUploadWidget 硬编码 Colors.grey.shade200 → OipSurface.border - mapping_components: BoxDecoration 添加 const 前缀 flutter analyze 通过(313 info 全为历史遗留,无新增 error/warning) flutter run 启动成功,代码编译通过,运行时无异常 --- lib/components/form/map_property_fields.dart | 10 +++++----- lib/components/form/mapping_components.dart | 17 ++++++++--------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/lib/components/form/map_property_fields.dart b/lib/components/form/map_property_fields.dart index afc698d..d450899 100644 --- a/lib/components/form/map_property_fields.dart +++ b/lib/components/form/map_property_fields.dart @@ -79,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); } } @@ -98,7 +98,7 @@ 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! @@ -191,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); } } @@ -210,7 +210,7 @@ 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: XFonts.caption.copyWith(fontFamily: 'monospace'), @@ -338,7 +338,7 @@ class _MapTrackFieldState extends State { : const Icon(Icons.timeline, size: 18), ), child: _path.isEmpty - ? const Text('暂无轨迹', style: TextStyle(color: Color(0xFF94A3B8))) + ? Text('暂无轨迹', style: XFonts.caption.copyWith(color: OipText.tertiary)) : Text('${_path.length} 个轨迹点 · 点击查看', style: XFonts.caption), ), ); diff --git a/lib/components/form/mapping_components.dart b/lib/components/form/mapping_components.dart index 661255f..e33e40a 100644 --- a/lib/components/form/mapping_components.dart +++ b/lib/components/form/mapping_components.dart @@ -571,14 +571,14 @@ MappingComponentsCallback mappingSwitchWidget = (Fields data, ITarget 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: [ @@ -612,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(), ) @@ -659,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(); @@ -925,11 +925,10 @@ class _SensorSelectSheet extends StatelessWidget { color: OipBrand.primary, ), title: Text(lookup.text ?? '', - style: TextStyle(fontSize: 16.sp)), + style: XFonts.caption), subtitle: lookup.code != null && lookup.code!.isNotEmpty ? Text(lookup.code!, - style: TextStyle( - fontSize: 16.sp, color: OipText.tertiary)) + style: XFonts.caption.copyWith(color: OipText.tertiary)) : null, trailing: Icon(Icons.chevron_right, size: 20.w, color: OipText.tertiary), -- Gitee From 87002dcedbc10965f397d81e9e2050862b15eb74 Mon Sep 17 00:00:00 2001 From: panzhaohui Date: Sat, 25 Jul 2026 20:51:07 +0800 Subject: [PATCH 16/37] =?UTF-8?q?fix:=20=E6=95=B0=E6=8D=AE=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E6=80=A7=E9=AA=8C=E8=AF=81=E3=80=81=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E7=B3=BB=E7=BB=9F=E4=BF=AE=E5=A4=8D=E4=B8=8E?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=97=B6=E9=94=99=E8=AF=AF=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 数据完整性(§18.2): - ensureDataFresh 新增数据指纹对比,刷新前后比较列表长度+id:updateTime, 相同则不触发 UI 重建,避免无意义重建 错误日志系统修复: - G2: SystemLog 固定默认 title='系统错误',修复 dedupeKey 去重失效 - G9: SystemLog.err 确保单例存在,避免 Global.init 之前调用丢失错误 - G3: http_util 3 处 log.warning 改为 XLogUtil.e,HTTP 错误写入 SystemLog - G4: commands.dart print 改为 XLogUtil.e - G8: GlobalErrorBoundary 记录完整错误+堆栈到 SystemLog - G10: exitLogin 添加 SecureDatabase.clearAllVets+close 清理 VET 凭证 - G6/G7: kernelapi 两处空 catch 改为 XLogUtil.e 记录 运行时错误修复: - loadAllApplication 递归遍历跳过 MirrorDirectory 影子目录, 修复 NoSuchMethodError: Class 'MirrorDirectory' has no instance getter 'id' - LocalPageCache apps 写入日志级别 w→e,内层 catch(_) 改为记录错误 登录页调整: - 欢迎来到奥集能上方插入两行空白,页面内容整体下移 info 清理(313→280): - prefer_const_constructors/literals 10处 - deprecated_member_use 7处(.value→.toARGB32, onPopInvoked→onPopInvokedWithResult) - annotate_overrides 4处, prefer_initializing_formals 3处 - 其他单例 9处 --- .../components/ActivityCommentWidget.dart | 4 +- lib/components/ExpandTabBar/ExpandTabBar.dart | 4 +- .../GlobalErrorBoundary.dart | 6 +-- .../PortalManagerWidget.dart | 4 +- .../XStatefulWidget/XStatefulWidget.dart | 2 +- lib/config/theme/unified_style.dart | 8 ++-- lib/dart/base/api/http_util.dart | 7 +-- lib/dart/base/api/kernelapi.dart | 1 + lib/dart/base/common/commands.dart | 10 ++-- lib/dart/base/common/systemError.dart | 9 ++-- lib/dart/base/model.dart | 10 ++-- .../base/oip/push_notification_service.dart | 10 +--- .../storages/local_page_cache_writer.dart | 6 ++- lib/dart/core/chat/activity.dart | 14 +++--- lib/dart/core/oip/ar_visualization.dart | 5 +- .../core/oip/pure_signature_verifier.dart | 4 +- lib/dart/core/provider/auth.dart | 11 +++++ lib/dart/core/provider/index.dart | 47 ++++++++++++++++++- lib/dart/core/provider/session.dart | 1 + lib/dart/core/target/person.dart | 1 + lib/dart/core/target/team/company.dart | 2 + lib/dart/core/thing/directory.dart | 9 +++- lib/dart/core/work/rules/lib/tools.dart | 2 +- lib/dart/extension/ex_color.dart | 2 +- lib/dart/extension/ex_string.dart | 2 +- lib/global.dart | 2 +- lib/pages/auth/login/view.dart | 1 + .../discover/widgets/plaza_style_cards.dart | 2 +- lib/pages/portal/workBench/view.dart | 2 +- .../store/dba/create_collection_dialog.dart | 2 +- lib/pages/store/dba/create_index_dialog.dart | 2 +- lib/pages/work/work_start_page.dart | 2 +- 32 files changed, 128 insertions(+), 66 deletions(-) diff --git a/lib/components/ActivityWidget/ActivityMessageFromChatWidget/components/ActivityCommentWidget.dart b/lib/components/ActivityWidget/ActivityMessageFromChatWidget/components/ActivityCommentWidget.dart index ba395c1..3b8cffd 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/ExpandTabBar/ExpandTabBar.dart b/lib/components/ExpandTabBar/ExpandTabBar.dart index b6016bf..bcbe4c3 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/GlobalErrorBoundary/GlobalErrorBoundary.dart b/lib/components/GlobalErrorBoundary/GlobalErrorBoundary.dart index f75730d..df941aa 100644 --- a/lib/components/GlobalErrorBoundary/GlobalErrorBoundary.dart +++ b/lib/components/GlobalErrorBoundary/GlobalErrorBoundary.dart @@ -53,10 +53,8 @@ class _GlobalErrorBoundaryState extends State { } void _handleError(Object error, StackTrace? stackTrace) { - XLogUtil.e('GlobalErrorBoundary捕获到错误: ${error.runtimeType}'); - if (stackTrace != null && kDebugMode) { - XLogUtil.d('GlobalErrorBoundary堆栈: $stackTrace'); - } + // 记录完整错误信息和堆栈,便于从 SystemLog 追溯(G8 修复) + XLogUtil.e('GlobalErrorBoundary: $error\n${stackTrace ?? ''}'); if (mounted) { WidgetsBinding.instance.addPostFrameCallback((_) { diff --git a/lib/components/PortalManagerWidget/PortalManagerWidget.dart b/lib/components/PortalManagerWidget/PortalManagerWidget.dart index b13e0a2..630ca0e 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/XStatefulWidget/XStatefulWidget.dart b/lib/components/XStatefulWidget/XStatefulWidget.dart index 425ec00..fb83bbd 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/config/theme/unified_style.dart b/lib/config/theme/unified_style.dart index 649bd57..22f8470 100644 --- a/lib/config/theme/unified_style.dart +++ b/lib/config/theme/unified_style.dart @@ -330,21 +330,21 @@ class XFonts { fontWeight: FontWeight.w700, color: OipText.primary, height: 1.2, - fontFeatures: [const FontFeature.tabularFigures()], + 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()], + 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()], + fontFeatures: const [FontFeature.tabularFigures()], ); // --- 主题色文字(链接/品牌强调)--- @@ -1110,7 +1110,7 @@ class XUi { decoration: BoxDecoration( color: OipSurface.card, border: border - ? Border( + ? const Border( bottom: BorderSide(color: OipBorder.divider, width: 0.5), ) : null, diff --git a/lib/dart/base/api/http_util.dart b/lib/dart/base/api/http_util.dart index 9154612..4067adc 100644 --- a/lib/dart/base/api/http_util.dart +++ b/lib/dart/base/api/http_util.dart @@ -4,6 +4,7 @@ import 'package:orginone/components/Tip/ToastUtils.dart'; import 'package:orginone/config/constant.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'; @@ -202,7 +203,7 @@ class HttpUtil with AuthMixin implements StoreHubHttpClient { }) { final cost = DateTime.now().millisecondsSinceEpoch - startedAt; final message = error.toString(); - log.warning( + XLogUtil.e( '[$transportTag][$requestId] $method $path failed in ${cost}ms: $message'); const friendlyMessage = '网络异常,请检查网络连接'; if (showToast) { @@ -226,7 +227,7 @@ class HttpUtil with AuthMixin implements StoreHubHttpClient { final message = response?.data is Map ? ResultType.fromJson(response!.data as Map).msg : response?.statusMessage ?? error.message ?? '请求失败'; - log.warning( + XLogUtil.e( '[$transportTag][$requestId] $method $path failed with $statusCode in ${cost}ms: $message'); if (statusCode == 401) { // 401 时去重触发一次 refreshToken 并 await 完成,确保后续请求拿到新 token @@ -245,7 +246,7 @@ class HttpUtil with AuthMixin implements StoreHubHttpClient { try { await relationCtrl.refreshToken(); } catch (e) { - log.warning('refreshToken 失败: $e'); + XLogUtil.e('refreshToken 失败: $e'); } } diff --git a/lib/dart/base/api/kernelapi.dart b/lib/dart/base/api/kernelapi.dart index 4b13c68..37973b3 100644 --- a/lib/dart/base/api/kernelapi.dart +++ b/lib/dart/base/api/kernelapi.dart @@ -932,6 +932,7 @@ class KernelApi with AuthMixin { .map((item) => XWorkTask.fromJson(item as Map)) .toList(); } catch (e) { + XLogUtil.e('XWorkTask.fromJson 解析失败: $e'); return []; } } diff --git a/lib/dart/base/common/commands.dart b/lib/dart/base/common/commands.dart index 45379af..c7cb5ed 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/systemError.dart b/lib/dart/base/common/systemError.dart index 822748e..2e0a547 100644 --- a/lib/dart/base/common/systemError.dart +++ b/lib/dart/base/common/systemError.dart @@ -41,7 +41,7 @@ class LogInfo { } // 去重键:内容前 200 字符 + title - String get dedupeKey => '${title}::${content.length > 200 ? content.substring(0, 200) : content}'; + String get dedupeKey => '$title::${content.length > 200 ? content.substring(0, 200) : content}'; } /// 系统日志 @@ -118,11 +118,14 @@ class SystemLog { } static void err([dynamic content = '', String? title]) { - _instance?._append(content, title); + // 确保单例存在,避免 Global.init 之前调用丢失错误(G9 修复) + _instance ??= SystemLog._(); + _instance!._append(content, title); } void _append(dynamic content, String? title) { - title ??= getDefTitle(); + // 固定默认 title,避免每次为时间戳导致 dedupeKey 失效(G2 修复) + title ??= '系统错误'; String contentStr; if (content is String) { contentStr = content; diff --git a/lib/dart/base/model.dart b/lib/dart/base/model.dart index 4d5fa83..21b0463 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/push_notification_service.dart b/lib/dart/base/oip/push_notification_service.dart index 8632cf6..73d3f78 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/local_page_cache_writer.dart b/lib/dart/base/storages/local_page_cache_writer.dart index 9dfdfc4..dea4c7b 100644 --- a/lib/dart/base/storages/local_page_cache_writer.dart +++ b/lib/dart/base/storages/local_page_cache_writer.dart @@ -93,7 +93,9 @@ class LocalPageCacheWriter { 'spaceId': spaceId, }; data.add(json); - } catch (_) {} + } catch (e) { + XLogUtil.e('[LocalPageCache] app 序列化失败: $e'); + } } await LocalPageCacheRepository.instance.write( PageCacheType.apps, @@ -102,7 +104,7 @@ class LocalPageCacheWriter { spaceId: spaceId, ); } catch (e) { - XLogUtil.w('[LocalPageCache] 写入 apps 失败: $e'); + XLogUtil.e('[LocalPageCache] 写入 apps 失败: $e'); } } diff --git a/lib/dart/core/chat/activity.dart b/lib/dart/core/chat/activity.dart index ef0b671..d23039d 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/oip/ar_visualization.dart b/lib/dart/core/oip/ar_visualization.dart index 6d5df86..a411056 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 c2c5095..8e7cf42 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 5672803..cb27cc7 100644 --- a/lib/dart/core/provider/auth.dart +++ b/lib/dart/core/provider/auth.dart @@ -9,6 +9,7 @@ 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/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'; @@ -157,6 +158,16 @@ mixin AuthMixin { // 防止跨用户数据残留(AGENTS.md §6.2 硬约束) await IotDataCache.instance.clearAll(); + // 清空 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(); diff --git a/lib/dart/core/provider/index.dart b/lib/dart/core/provider/index.dart index c32c543..32e747a 100644 --- a/lib/dart/core/provider/index.dart +++ b/lib/dart/core/provider/index.dart @@ -653,6 +653,8 @@ class DataProvider with EmitterMixin, Mutex { Future _doEnsureDataFresh(String dataType) async { try { + // 刷新前记录数据指纹(§18.2 有更新才刷新) + final beforeFingerprint = _dataFingerprint(dataType); switch (dataType) { case DataType.chats: await _chatProvider @@ -692,7 +694,15 @@ 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'); @@ -715,6 +725,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 96d7e6c..468a353 100644 --- a/lib/dart/core/provider/session.dart +++ b/lib/dart/core/provider/session.dart @@ -51,6 +51,7 @@ class ChatProvider with EmitterMixin implements IChatProvider { final IPerson user; /// 后台数据刷新完成回调(由 DataProvider 注册,用于触发快照写入) + @override void Function()? onDataRefreshed; ///会话数据 diff --git a/lib/dart/core/target/person.dart b/lib/dart/core/target/person.dart index 231ae71..0ed3fed 100644 --- a/lib/dart/core/target/person.dart +++ b/lib/dart/core/target/person.dart @@ -680,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 网络请求) diff --git a/lib/dart/core/target/team/company.dart b/lib/dart/core/target/team/company.dart index 8bab666..3aaae4e 100644 --- a/lib/dart/core/target/team/company.dart +++ b/lib/dart/core/target/team/company.dart @@ -406,6 +406,7 @@ class Company extends Belong implements ICompany { /// /// 包含:成员、群组、部门、公共数据、目录资源(首屏组织树渲染必需)。 /// 不包含:二级递归 deepLoad(groups/depts/stations 等各自的 deepLoad)。 + @override Future deepLoadForLogin({bool? reload = false}) async { final sw = Stopwatch()..start(); // 首屏必需(同步并行):成员/群组/部门 @@ -444,6 +445,7 @@ class Company extends Belong implements ICompany { /// /// 优化:分批执行,groups 优先(动态消息依赖),完成后立即通知页面刷新, /// 其他后台继续。避免 58s 全部完成后才刷新。 + @override Future deepLoadBackground({bool? reload = false}) async { final sw = Stopwatch()..start(); // 先加载权限数据(后台但优先级较高) diff --git a/lib/dart/core/thing/directory.dart b/lib/dart/core/thing/directory.dart index c494f0b..ec8fc6d 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'; @@ -478,7 +479,13 @@ class Directory extends StandardFileInfo implements IDirectory { final List applications = [...standard.applications]; for (var item in children) { - applications.addAll(await item.loadAllApplication()); + // 跳过 MirrorDirectory 影子目录,避免 NoSuchMethodError + if (item is MirrorDirectory) continue; + try { + applications.addAll(await item.loadAllApplication()); + } catch (e) { + XLogUtil.e('loadAllApplication 子目录加载失败: $e'); + } } return applications; diff --git a/lib/dart/core/work/rules/lib/tools.dart b/lib/dart/core/work/rules/lib/tools.dart index ef15b9f..d3bb86c 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/extension/ex_color.dart b/lib/dart/extension/ex_color.dart index 445f157..ab8f5bc 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 d6ba0c0..647aaaf 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 a38fea8..2ee3b1a 100644 --- a/lib/global.dart +++ b/lib/global.dart @@ -106,7 +106,7 @@ class Global { SystemChrome.setApplicationSwitcherDescription( ApplicationSwitcherDescription( label: '奥集能', // 设置应用名称 - primaryColor: Colors.white.value, // 主色 + primaryColor: Colors.white.toARGB32(), // 主色 )); } } diff --git a/lib/pages/auth/login/view.dart b/lib/pages/auth/login/view.dart index c89d293..e07585b 100644 --- a/lib/pages/auth/login/view.dart +++ b/lib/pages/auth/login/view.dart @@ -94,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), diff --git a/lib/pages/discover/widgets/plaza_style_cards.dart b/lib/pages/discover/widgets/plaza_style_cards.dart index 930837d..d6a4aa9 100644 --- a/lib/pages/discover/widgets/plaza_style_cards.dart +++ b/lib/pages/discover/widgets/plaza_style_cards.dart @@ -225,7 +225,7 @@ class VideoStyleCard extends StatelessWidget { ], begin: Alignment.topCenter, end: Alignment.bottomCenter, - stops: [0.5, 1.0], + stops: const [0.5, 1.0], ), ), ), diff --git a/lib/pages/portal/workBench/view.dart b/lib/pages/portal/workBench/view.dart index d346b2c..b940a92 100644 --- a/lib/pages/portal/workBench/view.dart +++ b/lib/pages/portal/workBench/view.dart @@ -751,7 +751,7 @@ class _WorkBenchPageState extends State { padding: EdgeInsets.symmetric(horizontal: 12.w), child: Container( padding: EdgeInsets.symmetric(vertical: 10.h), - decoration: BoxDecoration( + decoration: const BoxDecoration( border: Border( bottom: BorderSide(color: OipBorder.divider, width: 0.5))), diff --git a/lib/pages/store/dba/create_collection_dialog.dart b/lib/pages/store/dba/create_collection_dialog.dart index 8fbbbe0..404cf9b 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 15dd663..78dcf38 100644 --- a/lib/pages/store/dba/create_index_dialog.dart +++ b/lib/pages/store/dba/create_index_dialog.dart @@ -70,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, diff --git a/lib/pages/work/work_start_page.dart b/lib/pages/work/work_start_page.dart index be6c10e..b103175 100644 --- a/lib/pages/work/work_start_page.dart +++ b/lib/pages/work/work_start_page.dart @@ -493,7 +493,7 @@ class _WorkStartPageState extends XStatefulState { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.error_outline, size: 64, color: OipState.error), + const Icon(Icons.error_outline, size: 64, color: OipState.error), SizedBox(height: 16.h), Text( _errorMessage!, -- Gitee From ca2f2f58b03bfc7ad3a6dfc51eb5153cc2cdb407 Mon Sep 17 00:00:00 2001 From: panzhaohui Date: Sat, 25 Jul 2026 23:01:17 +0800 Subject: [PATCH 17/37] =?UTF-8?q?fix:=20=E5=AE=89=E5=85=A8=E5=8A=A0?= =?UTF-8?q?=E5=9B=BA+IM=E4=BF=9D=E6=B4=BB+SQLite=E6=95=8F=E6=84=9F?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E5=8A=A0=E5=AF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 安全审计与加固: - encryption.dart: ECB 解密路径增加安全警告日志 - BusinessDatabase: messages.content 和 sessions.last_message 使用 AES-CBC 加密存储,设备级密钥(首次生成存储于 SharedPreferences) 读取时兼容明文数据(无 ':' 分隔符视为明文直接返回) - 防止 Root/越狱设备 SQLite 数据库文件被直接读取聊天内容 IM 长连接保活: - HubConnectionManager: 新增离线消息队列(最大 200 条) 断线期间 enqueueMessage 排队,重连成功后 _flushPendingQueue 补发 补发失败重新入队等待下次重连,消息防丢 - 已有:15s 心跳 + 指数退避重连(最大 5 次)+ RESTful 降级 依赖冲突治理: - dependency_overrides 仅 uuid(4.5.1) 和 flutter_spinkit(5.2.1),风险可控 - sqflite(2.3.3+1) 和 photo_manager(3.6.4) 固定版本,升级需验证 --- lib/dart/base/common/encryption.dart | 4 +- lib/dart/base/oip/hub_connection_manager.dart | 44 ++ lib/dart/base/storages/business_database.dart | 536 ++++++++++++++++++ 3 files changed, 583 insertions(+), 1 deletion(-) create mode 100644 lib/dart/base/storages/business_database.dart diff --git a/lib/dart/base/common/encryption.dart b/lib/dart/base/common/encryption.dart index 09c36dc..5fcf85e 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/oip/hub_connection_manager.dart b/lib/dart/base/oip/hub_connection_manager.dart index 96cc244..200f4c1 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/storages/business_database.dart b/lib/dart/base/storages/business_database.dart new file mode 100644 index 0000000..0d34d94 --- /dev/null +++ b/lib/dart/base/storages/business_database.dart @@ -0,0 +1,536 @@ +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/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 = 1; + static const String _dbName = 'oiocns_business.db'; + + bool get isInitialized => _db != null && _db!.isOpen; + + Future get database async { + if (_db != null && _db!.isOpen) return _db!; + await _init(); + 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, + ); + } catch (e) { + XLogUtil.e('[BusinessDatabase] 初始化失败: $e'); + } + } + + 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)'); + + await batch.commit(noResult: true); + } + + Future _onUpgrade(Database db, int oldVersion, int newVersion) async { + // 增量迁移,禁止 DROP 全表(AGENTS.md §6.3) + } + + // ============ 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'); + } + + // ============ 敏感字段加密 ============ + + 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'); + }); + XLogUtil.i('[BusinessDatabase] clearAll 完成'); + } catch (e) { + XLogUtil.e('[BusinessDatabase] clearAll 失败: $e'); + } + } + + /// 关闭数据库 + Future close() async { + await _db?.close(); + _db = null; + } +} -- Gitee From 57ef3b34b45397429e75e68cfd979b6144bd730e Mon Sep 17 00:00:00 2001 From: panzhaohui Date: Sun, 26 Jul 2026 08:35:37 +0800 Subject: [PATCH 18/37] =?UTF-8?q?fix:=20=E5=8A=9E=E4=BA=8B=E5=B7=B2?= =?UTF-8?q?=E5=8A=9E/=E5=B7=B2=E5=AE=8C=E7=BB=93=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E4=B8=8E=E6=B2=9F=E9=80=9A=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E5=88=B7=E6=96=B0=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 办事页面: - provider.dart: loadTasks 过滤结果未赋值导致所有Tab显示相同数据 修复 tasks.where(...).toList() 结果丢弃问题 - task.dart: isTaskType 已发起/已完结判断逻辑完全相同 已发起: createUser==userId && status<100(未审批) 已完结: createUser==userId && status>=100(已审批完成) 与 _typeMatch API查询条件对齐 沟通页面: - chat_page.dart: 后台刷新完成后强制重建模型 不依赖 _refreshModel 的计数优化,确保最新数据渲染到UI --- lib/dart/core/work/provider.dart | 2 +- lib/dart/core/work/task.dart | 6 ++++-- lib/pages/chats/chat_page.dart | 8 +++++++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/lib/dart/core/work/provider.dart b/lib/dart/core/work/provider.dart index f2ce7a5..c0dd25a 100644 --- a/lib/dart/core/work/provider.dart +++ b/lib/dart/core/work/provider.dart @@ -208,7 +208,7 @@ class WorkProvider with EmitterMixin implements IWorkProvider { } }); } - tasks.where((i) => i.isTaskType(type)).toList(); + tasks = tasks.where((i) => i.isTaskType(type)).toList(); tasks.sort((a, b) { return DateTime.parse(b.metadata.updateTime ?? "") .compareTo(DateTime.parse(a.metadata.updateTime ?? "")); diff --git a/lib/dart/core/work/task.dart b/lib/dart/core/work/task.dart index 0a205c0..dc58bab 100644 --- a/lib/dart/core/work/task.dart +++ b/lib/dart/core/work/task.dart @@ -218,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 '抄送': diff --git a/lib/pages/chats/chat_page.dart b/lib/pages/chats/chat_page.dart index b63ecaf..2365d2e 100644 --- a/lib/pages/chats/chat_page.dart +++ b/lib/pages/chats/chat_page.dart @@ -70,6 +70,11 @@ class _ChatPageState extends State } }); _subscriptions.add(chatSub); + // 订阅 'session' flag:deepLoadBackground/loadCacheChatData 等中间环节通知 + final sessionFlagSub = command.subscribeByFlag('session', ([dynamic args]) { + if (mounted) _refreshModel(); + }); + _subscriptions.add(sessionFlagSub); // 沟通页不跟随单位切换刷新数据,保持本地缓存优先策略 } @@ -97,7 +102,8 @@ class _ChatPageState extends State } future.then((_) { if (mounted) { - _refreshModel(); + // 后台刷新完成后强制重建模型,不依赖 _refreshModel 的计数优化 + load(context); setState(() => _isBgRefreshing = false); } }).catchError((_) { -- Gitee From 41c6820f013799501b2016e86480c9d5ac4740ff Mon Sep 17 00:00:00 2001 From: panzhaohui Date: Sun, 26 Jul 2026 08:45:43 +0800 Subject: [PATCH 19/37] =?UTF-8?q?fix:=20session.dart=20createTime=20?= =?UTF-8?q?=E7=A9=BA=E6=8C=87=E9=92=88=E5=BC=82=E5=B8=B8=E9=98=B2=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit data[0].createTime 使用 ! 断言非空,createTime 为 null 时崩溃 改为 null + empty 判断后再 parse,跳过无效时间戳 --- lib/dart/core/chat/session.dart | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/lib/dart/core/chat/session.dart b/lib/dart/core/chat/session.dart index 936ab80..ec0b677 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'); -- Gitee From 0791e2f80cd66c6fd8095707816bde59ca68f022 Mon Sep 17 00:00:00 2001 From: panzhaohui Date: Sun, 26 Jul 2026 11:10:08 +0800 Subject: [PATCH 20/37] =?UTF-8?q?feat:=20=E9=94=99=E8=AF=AF=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E7=B3=BB=E7=BB=9F=E5=AE=8C=E5=96=84+=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=88=B7=E6=96=B0=E4=BA=8B=E4=BB=B6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?+WorkProvider=20SQLite=E6=8C=81=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 34 ++++ lib/components/Executor/action_executor.dart | 1 + .../GlobalErrorBoundary.dart | 13 +- .../ErrorWidget/ErrorListWidget.dart | 139 +++++++------ .../components/BottomActionWidget.dart | 6 +- lib/dart/base/api/http_util.dart | 26 ++- lib/dart/base/common/systemError.dart | 102 +++++++--- lib/dart/core/provider/auth.dart | 4 + lib/dart/core/provider/index.dart | 96 +++++++-- lib/dart/core/provider/session.dart | 77 +++++++- lib/dart/core/target/team/company.dart | 13 +- lib/dart/core/work/provider.dart | 187 ++++++++++++++---- lib/global.dart | 3 + lib/pages/discover/discover_page.dart | 22 ++- lib/pages/relation/relation_page.dart | 46 +++-- lib/pages/work/work_page.dart | 83 ++++++-- 16 files changed, 650 insertions(+), 202 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0603b3e..0b29b11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -539,6 +539,9 @@ lib/pages/iot/ # 物联网页面 - 后台异步异常必须写入,不能因 `!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 测试覆盖范围 @@ -547,6 +550,37 @@ lib/pages/iot/ # 物联网页面 - 物联网模块(`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 错误日志去重与合并(硬约束) + +- **相同错误合并**:`SystemLog` 对相同 `dedupeKey`(title + content 前 200 字符)的错误自动合并,累加 `count`,更新最近时间,保留首次时间。 +- **显示格式**: + - 列表项标题显示 `$title (×$count)`(count > 1 时)。 + - 时间显示 `首次时间 ~ 最近时间`(count > 1 时)或 `最近时间`(count = 1 时)。 +- **容量上限**:最多 200 条不同错误,超出 FIFO 淘汰最旧条目。 +- **异步落盘**:500ms 内多次 `err` 只写一次 `Storage`,避免高频错误影响性能。 + --- ## 20. PR 自检清单(提交前逐条对照) diff --git a/lib/components/Executor/action_executor.dart b/lib/components/Executor/action_executor.dart index 7f2607a..a7bf8ef 100644 --- a/lib/components/Executor/action_executor.dart +++ b/lib/components/Executor/action_executor.dart @@ -268,6 +268,7 @@ class ActionExecutor { if (success) { ToastUtils.showMsg(msg: '撤回成功'); command.emitter('work', 'refresh'); + command.emitterFlag('work'); } else { ToastUtils.showMsg(msg: '撤回失败'); } diff --git a/lib/components/GlobalErrorBoundary/GlobalErrorBoundary.dart b/lib/components/GlobalErrorBoundary/GlobalErrorBoundary.dart index df941aa..5bfdfe9 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,8 +54,16 @@ class _GlobalErrorBoundaryState extends State { } void _handleError(Object error, StackTrace? stackTrace) { - // 记录完整错误信息和堆栈,便于从 SystemLog 追溯(G8 修复) - XLogUtil.e('GlobalErrorBoundary: $error\n${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/MySettingWidget/components/ErrorWidget/ErrorListWidget.dart b/lib/components/MySettingWidget/components/ErrorWidget/ErrorListWidget.dart index 13d6324..bdfe3a6 100644 --- a/lib/components/MySettingWidget/components/ErrorWidget/ErrorListWidget.dart +++ b/lib/components/MySettingWidget/components/ErrorWidget/ErrorListWidget.dart @@ -1,10 +1,14 @@ 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 '../../../XButton/XButton.dart'; @@ -15,7 +19,6 @@ import '../../../XText/XText.dart'; class ErrorListWidget extends StatelessWidget { const ErrorListWidget({Key? key}) : super(key: key); - // 主视图 Widget _buildView(BuildContext context) { final errorArray = SystemLog.errors; if (errorArray.isEmpty) { @@ -24,7 +27,7 @@ class ErrorListWidget extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.check_circle_outline, - size: 64.w, color: Colors.grey.shade400), + size: 64.w, color: OipText.tertiary), SizedBox(height: 12.h), Text('暂无错误日志', style: XFonts.bodyMedium), ], @@ -35,15 +38,31 @@ class ErrorListWidget extends StatelessWidget { scrollDirection: Axis.vertical, itemBuilder: (context, index) { final data = errorArray[index]; + // 重复错误显示首次时间和最近时间 + final timeStr = data.count > 1 + ? '${data.firstTimeStr} ~ ${data.timeStr}' + : data.timeStr; return ListTile( - key: ValueKey('${data.title}_$index'), - 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, )).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, @@ -58,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", @@ -84,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, + ), + ), + ), + ), ); } } @@ -126,7 +167,6 @@ mixin ErrorListenerMixin { void init(BuildContext context) { path = RoutePages.routeData.currPageData.path ?? ''; successNum = 0; - // executeTask(context); } bool get hasError { @@ -140,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/ProcessDetailsWidget/components/BottomActionWidget.dart b/lib/components/ProcessDetailsWidget/components/BottomActionWidget.dart index 1548801..80cbacf 100644 --- a/lib/components/ProcessDetailsWidget/components/BottomActionWidget.dart +++ b/lib/components/ProcessDetailsWidget/components/BottomActionWidget.dart @@ -171,8 +171,8 @@ class BottomActionWidget extends StatelessWidget { }, ), ListTile( - leading: const Icon(Icons.person_add_alt_1, - color: OipBrand.primary), + leading: + const Icon(Icons.person_add_alt_1, color: OipBrand.primary), title: const Text('加签'), subtitle: const Text('增加审核人,多人会签'), onTap: () { @@ -288,6 +288,7 @@ class BottomActionWidget extends StatelessWidget { if (context.mounted) { Navigator.pop(context); command.emitter('work', 'refresh'); + command.emitterFlag('work'); } } else { ToastUtils.showMsg(msg: '$action失败'); @@ -379,6 +380,7 @@ class BottomActionWidget extends StatelessWidget { onSuccess: () { Navigator.pop(navigatorKey.currentState?.context ?? context); command.emitter('work', 'refresh'); + command.emitterFlag('work'); }); EasyLoading.dismiss(); } diff --git a/lib/dart/base/api/http_util.dart b/lib/dart/base/api/http_util.dart index 4067adc..a8649c2 100644 --- a/lib/dart/base/api/http_util.dart +++ b/lib/dart/base/api/http_util.dart @@ -2,6 +2,7 @@ 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'; @@ -60,8 +61,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); @@ -203,8 +203,10 @@ class HttpUtil with AuthMixin implements StoreHubHttpClient { }) { final cost = DateTime.now().millisecondsSinceEpoch - startedAt; final message = error.toString(); - XLogUtil.e( - '[$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); @@ -227,8 +229,10 @@ class HttpUtil with AuthMixin implements StoreHubHttpClient { final message = response?.data is Map ? ResultType.fromJson(response!.data as Map).msg : response?.statusMessage ?? error.message ?? '请求失败'; - XLogUtil.e( - '[$transportTag][$requestId] $method $path failed with $statusCode in ${cost}ms: $message'); + final logMsg = + '[$transportTag][$requestId] $method $path failed with $statusCode in ${cost}ms: $message'; + XLogUtil.e(logMsg); + SystemLog.err(logMsg, 'HTTP错误[$statusCode]'); if (statusCode == 401) { // 401 时去重触发一次 refreshToken 并 await 完成,确保后续请求拿到新 token await _ensureRefreshToken(); @@ -245,8 +249,10 @@ class HttpUtil with AuthMixin implements StoreHubHttpClient { Future _ensureRefreshToken() async { try { await relationCtrl.refreshToken(); - } catch (e) { - XLogUtil.e('refreshToken 失败: $e'); + } catch (e, s) { + final msg = 'refreshToken 失败: $e\n$s'; + XLogUtil.e(msg); + SystemLog.err(msg, 'Token刷新'); } } @@ -290,8 +296,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/common/systemError.dart b/lib/dart/base/common/systemError.dart index 2e0a547..e0845ea 100644 --- a/lib/dart/base/common/systemError.dart +++ b/lib/dart/base/common/systemError.dart @@ -11,13 +11,23 @@ import '../storages/storage.dart'; class LogInfo { late final String title; late final String content; - // 新增:毫秒时间戳,作为排序和去重键;旧数据无则按 title 解析回填 + /// 最近一次发生时间(毫秒),作为排序键 late final int timestamp; - - LogInfo({this.title = '', this.content = '', int? timestamp}) - : timestamp = timestamp ?? DateTime.now().millisecondsSinceEpoch; - - //通过JSON构造 + /// 首次发生时间(毫秒) + 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"] ?? ''; @@ -29,19 +39,38 @@ class LogInfo { 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() { final json = {}; json["t"] = title; json["errorText"] = content; json["ts"] = timestamp; + json["fts"] = firstTimestamp; + json["cnt"] = count; return json; } - // 去重键:内容前 200 字符 + title + /// 去重键:内容前 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; } /// 系统日志 @@ -49,8 +78,8 @@ class SystemLog { // 单例 static SystemLog? _instance; final List _errors; - // 内容去重索引:dedupeKey -> 最近一次 timestamp - final Map _dedupeMap = {}; + // 内容去重索引:dedupeKey -> 在 _errors 中的索引 + final Map _dedupeIndex = {}; // 异步落盘去重:避免高频错误每条都触发 Storage.setJson Timer? _flushTimer; // 容量上限 @@ -82,8 +111,8 @@ class SystemLog { .toList(); _errors.addAll(list); // 重建去重索引 - for (final info in list) { - _dedupeMap[info.dedupeKey] = info.timestamp; + for (var i = 0; i < list.length; i++) { + _dedupeIndex[list[i].dedupeKey] = i; } } catch (e) { XLogUtil.e('SystemLog.init 解析失败: $e'); @@ -107,7 +136,7 @@ class SystemLog { static void clear() { Storage.remove('work_page_error'); _instance?._errors.clear(); - _instance?._dedupeMap.clear(); + _instance?._dedupeIndex.clear(); _instance?._flushTimer?.cancel(); _instance?._flushTimer = null; } @@ -123,6 +152,7 @@ class SystemLog { _instance!._append(content, title); } + /// 追加错误日志,相同错误合并输出(累加 count,更新最近时间) void _append(dynamic content, String? title) { // 固定默认 title,避免每次为时间戳导致 dedupeKey 失效(G2 修复) title ??= '系统错误'; @@ -137,27 +167,34 @@ class SystemLog { } } + 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; + } + + // 新错误 final info = LogInfo( title: title, content: contentStr, - timestamp: DateTime.now().millisecondsSinceEpoch, + timestamp: now, + firstTimestamp: now, + count: 1, ); - - // 去重:相同内容在 5 秒内不重复记录 - final lastTs = _dedupeMap[info.dedupeKey]; - if (lastTs != null && (info.timestamp - lastTs) < 5000) { - // 5 秒内相同错误:更新时间戳(让最近一次"浮前"),不新增条目 - final idx = _errors.indexWhere((e) => e.dedupeKey == info.dedupeKey); - if (idx >= 0) { - _errors[idx] = info; - _dedupeMap[info.dedupeKey] = info.timestamp; - _scheduleFlush(); - return; - } - } - _errors.add(info); - _dedupeMap[info.dedupeKey] = info.timestamp; + _dedupeIndex[dedupeKey] = _errors.length - 1; _evictIfNeeded(); _scheduleFlush(); } @@ -165,7 +202,12 @@ class SystemLog { void _evictIfNeeded() { while (_errors.length > _maxCapacity) { final removed = _errors.removeAt(0); - _dedupeMap.remove(removed.dedupeKey); + _dedupeIndex.remove(removed.dedupeKey); + // 重建索引(因为 removeAt(0) 导致所有索引前移) + _dedupeIndex.clear(); + for (var i = 0; i < _errors.length; i++) { + _dedupeIndex[_errors[i].dedupeKey] = i; + } } } diff --git a/lib/dart/core/provider/auth.dart b/lib/dart/core/provider/auth.dart index cb27cc7..2b05a3d 100644 --- a/lib/dart/core/provider/auth.dart +++ b/lib/dart/core/provider/auth.dart @@ -9,6 +9,7 @@ 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'; @@ -158,6 +159,9 @@ mixin AuthMixin { // 防止跨用户数据残留(AGENTS.md §6.2 硬约束) await IotDataCache.instance.clearAll(); + // 清空业务数据 SQLite 全量表(sessions/messages/activities/work_tasks/relations/storages/agents) + await BusinessDatabase.instance.clearAll(); + // 清空 VET 安全数据库(G10 修复,防止跨用户凭证残留) if (SecureDatabase.current.isInitialized) { try { diff --git a/lib/dart/core/provider/index.dart b/lib/dart/core/provider/index.dart index 32e747a..12fa906 100644 --- a/lib/dart/core/provider/index.dart +++ b/lib/dart/core/provider/index.dart @@ -7,6 +7,7 @@ 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'; @@ -91,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(); // 后台二级递归完成后:关系/动态/会话数据均已刷新到最新, // 标记这些数据类型为已刷新,避免子页面进入时重复拉取。 @@ -111,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, '后台加载'); } } @@ -272,7 +280,8 @@ class DataProvider with EmitterMixin, Mutex { /// - _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}'); + 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 { @@ -300,8 +309,7 @@ class DataProvider with EmitterMixin, Mutex { _home!.workbenchDiskInfoSummary = prevDiskSummary; } } - XLogUtil.i( - '>>>>>>[登录耗时] 实例创建完成: ${sw.elapsedMilliseconds}ms'); + XLogUtil.i('>>>>>>[登录耗时] 实例创建完成: ${sw.elapsedMilliseconds}ms'); if (forLogin) { // ★登录加速:_refresh 改为后台异步执行,不阻塞登录跳转 @@ -323,7 +331,9 @@ class DataProvider with EmitterMixin, Mutex { var t = DateUtil.formatDate(DateTime.now(), format: "yyyy-MM-dd HH:mm:ss.SSS"); errInfo += '$t $e ==== $s'; - XLogUtil.e('>>>>>>[登录诊断] _loadUser 异常: $e\n$s'); + final msg = '[$t] _loadUser 异常: $e\n$s'; + XLogUtil.e('>>>>>>[登录诊断] $msg'); + SystemLog.err(msg, '登录加载'); changeCallback(args: [false]); sw.stop(); } @@ -337,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; @@ -366,6 +377,9 @@ class DataProvider with EmitterMixin, Mutex { _scheduleSnapshotWrite(); _scheduleLocalPageCacheWrite(); _notifyDataRefreshed(); + // 登录快速路径完成后,触发后台静默更新远程数据(非阻塞) + // load(skipDeepLoad: true) 跳过了 _refreshRemoteData,这里补上 + _chatProvider?.refreshRemote(reload: true); } else if (user != null) { // 完整重载路径(手动刷新) await user.deepLoad(reload: true); @@ -413,7 +427,9 @@ class DataProvider with EmitterMixin, Mutex { 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; @@ -426,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 完成后) @@ -453,7 +472,8 @@ class DataProvider with EmitterMixin, Mutex { await BootstrapCoordinator().writeFullSnapshot(this).catchError((e) { //XLogUtil.e('快照写入失败: $e'); }); - }().whenComplete(() { + }() + .whenComplete(() { _pendingSnapshot = null; }); } @@ -638,6 +658,10 @@ class DataProvider with EmitterMixin, Mutex { /// 避免短时间内多次进入子页面触发请求风暴。 final Map> _pendingFreshens = {}; Future ensureDataFresh(String dataType) async { + // 始终触发后台静默更新(非阻塞),确保在线数据变更能及时同步到本地 + _triggerSilentBackgroundRefresh(dataType); + + // 数据在保鲜期内时,直接用内存缓存渲染,不阻塞 UI if (!DataFreshnessTracker.instance.shouldRefresh(dataType)) return; // 单飞去重:同一类型若已有 Future 在跑,直接复用 final pending = _pendingFreshens[dataType]; @@ -651,15 +675,55 @@ class DataProvider with EmitterMixin, Mutex { } } + /// 后台静默更新(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((_) {}); @@ -697,8 +761,7 @@ class DataProvider with EmitterMixin, Mutex { // 数据指纹对比:无变化则不触发 UI 重建(§18.2 履约) final afterFingerprint = _dataFingerprint(dataType); - if (beforeFingerprint != null && - beforeFingerprint == afterFingerprint) { + if (beforeFingerprint != null && beforeFingerprint == afterFingerprint) { return; } @@ -710,6 +773,7 @@ class DataProvider with EmitterMixin, Mutex { break; case DataType.work: command.emitter('work', 'refresh'); + command.emitterFlag('work'); break; case DataType.relation: case DataType.storage: diff --git a/lib/dart/core/provider/session.dart b/lib/dart/core/provider/session.dart index 468a353..9c06097 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(); @@ -156,30 +161,91 @@ class ChatProvider with EmitterMixin implements IChatProvider { } } + /// 后台异步刷新远程数据(公开接口,供 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((_) {}); + await user.deepLoad(reload: true).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).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(fire-and-forget,不阻塞 UI) + _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 持久化同刷) + void _persistSessionsToSQLite(List sessions) { + if (sessions.isEmpty) return; + Future(() async { + 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写入'); } }); } @@ -495,7 +561,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/target/team/company.dart b/lib/dart/core/target/team/company.dart index 3aaae4e..d744635 100644 --- a/lib/dart/core/target/team/company.dart +++ b/lib/dart/core/target/team/company.dart @@ -29,6 +29,9 @@ abstract class ICompany extends IBelong { //加入/管理的组织集群 late List groups; + //设立的集群(单位作为创建者的组织群) + late List establishedGroups; + //设立的岗位 late List stations; @@ -94,6 +97,8 @@ class Company extends Belong implements ICompany { @override List groups = []; @override + List establishedGroups = []; + @override List stations = []; @override List departments = []; @@ -155,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, )); @@ -164,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)) { @@ -173,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)); } @@ -472,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/work/provider.dart b/lib/dart/core/work/provider.dart index c0dd25a..9c76778 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'; @@ -96,7 +100,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) { @@ -125,18 +131,112 @@ class WorkProvider with EmitterMixin implements IWorkProvider { @override Future> loadTodos({bool reload = false}) async { if (!_todoLoaded || reload) { - final res = await kernel.queryApproveTask(IdModel('0')); - if (res.success) { - _todoLoaded = true; + XLogUtil.i('>>>>>>[数据刷新] WorkProvider.loadTodos 开始 reload=$reload'); + try { + final res = await kernel.queryApproveTask(IdModel('0')); + if (res.success) { + _todoLoaded = true; + todos.clear(); + todos.addAll((res.data?.result ?? []) + .map((task) => WorkTask(task, user)) + .toList()); + 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; + } + + /// 持久化待办任务到 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': taskData.taskType ?? '', + '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, + ); + 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((res.data?.result ?? []) - .map((task) => WorkTask(task, user)) - .toList()); - // todos.refresh(); + 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; } - return todos; } @override @@ -145,27 +245,29 @@ class WorkProvider with EmitterMixin implements IWorkProvider { 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 { List tasks = []; // if (reload) { // tasks = []; // } // int skip = tasks.length; // if (!finished) { - var result = await kernel.collectionLoad>( - // '445635922516643840', // PC的id - userId, - userId, - [], - TaskCollName, - /*(type.label == '草稿') ? { + var result = await kernel.collectionLoad>( + // '445635922516643840', // PC的id + userId, + userId, + [], + TaskCollName, + /*(type.label == '草稿') ? { 'userData': [], 'options': { 'match': { @@ -186,35 +288,36 @@ class WorkProvider with EmitterMixin implements IWorkProvider { }, 'skip': 0, 'take': 1000, - } : */{ - 'options': { - 'match': _typeMatch(type), - 'sort': { - 'createTime': -1, - }, + } : */ + { + 'options': { + 'match': _typeMatch(type), + 'sort': { + 'createTime': -1, }, - 'skip': 0, - 'take': 1000, //pageSize, - }, - fromJson: (data) { - return XWorkTask.fromList(data['data'] is List ? data['data'] : []); }, - ); - // 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)); - } - }); - } - tasks = tasks.where((i) => i.isTaskType(type)).toList(); - tasks.sort((a, b) { + 'skip': 0, + 'take': 1000, //pageSize, + }, + fromJson: (data) { + return XWorkTask.fromList(data['data'] is List ? data['data'] : []); + }, + ); + // 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)); + } + }); + } + tasks = tasks.where((i) => i.isTaskType(type)).toList(); + tasks.sort((a, b) { return DateTime.parse(b.metadata.updateTime ?? "") .compareTo(DateTime.parse(a.metadata.updateTime ?? "")); - }); - // finished = taskDatas.length < 10; - // tasks.addAll(taskDatas); + }); + // finished = taskDatas.length < 10; + // tasks.addAll(taskDatas); // } return tasks; } diff --git a/lib/global.dart b/lib/global.dart index 2ee3b1a..b72ac51 100644 --- a/lib/global.dart +++ b/lib/global.dart @@ -14,6 +14,7 @@ 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'; @@ -31,6 +32,8 @@ class Global { 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'); } diff --git a/lib/pages/discover/discover_page.dart b/lib/pages/discover/discover_page.dart index e3b8528..14dd5e3 100644 --- a/lib/pages/discover/discover_page.dart +++ b/lib/pages/discover/discover_page.dart @@ -52,7 +52,27 @@ class _DiscoverPageState extends State void _subscribeRefreshEvents() { // 发现页不跟随单位切换刷新数据,保持本地缓存优先策略, // 加速页面渲染速度。单位切换只刷新门户页面数据。 - // 数据刷新由用户下拉刷新或切换 Tab 时按需触发。 + // 但需要订阅后台数据加载完成事件,确保 deepLoad/ensureDataFresh 拉取的最新 + // cohorts/storages/agents/companys 数据能回流到 DiscoverProvider 重新聚合显示。 + // 对齐 AGENTS.md §18.2「事件通知:后台拉取完成后通过 command.emitter(flag) 通知订阅方刷新」 + final sessionSub = command.subscribeByFlag('session', ([dynamic args]) { + 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; + // 后台二级递归 deepLoad 完成(groups/cohorts/storages 已填充), + // 此时多集群聚合能拉到完整资源,触发一次刷新 + final provider = context.read(); + provider.refreshData(); + }); + _subscribers.add(bgLoadedSub); } @override diff --git a/lib/pages/relation/relation_page.dart b/lib/pages/relation/relation_page.dart index 999f027..051243e 100644 --- a/lib/pages/relation/relation_page.dart +++ b/lib/pages/relation/relation_page.dart @@ -84,6 +84,23 @@ class _RelationState extends State if (!mounted) return; _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; + // 重置 model 触发 _loadFromRoute 重建,从内存读取最新数据 + setState(() { + relationModel = null; + _lastLoadedPath = null; + _lastLoadedData = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _loadFromRoute(context); + }); + }); + _subscriptions.add(sessionSub); } /// 根据当前 AppRoute 上下文加载 relationModel。 @@ -718,10 +735,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; @@ -752,16 +773,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; } /// 加载内设机构 diff --git a/lib/pages/work/work_page.dart b/lib/pages/work/work_page.dart index 10b43d5..19a75b5 100644 --- a/lib/pages/work/work_page.dart +++ b/lib/pages/work/work_page.dart @@ -32,6 +32,9 @@ class _WorkPageState extends State StreamSubscription? _todosSub; final List _subscriptions = []; int? _lastTodosCount; + + /// 待办列表指纹(长度 + 每项 id:updateTime),用于检测内容变化 + String? _lastFingerprint; bool _isRefreshing = false; @override @@ -45,17 +48,45 @@ class _WorkPageState extends State _workModel = null; _todosSub = todos.listen((values) { if (!mounted) return; - if (_lastTodosCount != values.length) { + // 内容变化检测:长度或任意一项的 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; + // 后台办事数据刷新完成,强制重建 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(); @@ -71,12 +102,17 @@ class _WorkPageState extends State Widget build(BuildContext context) { super.build(context); final currentCount = todos.length; + final currentFingerprint = _computeTodosFingerprint(todos); if (_workModel == null && currentCount == 0) { return const SkeletonWidget(itemCount: 6); } - if (_workModel == null || _lastTodosCount != currentCount) { + // 检测长度变化或内容指纹变化,任一变化都重建 model + if (_workModel == null || + _lastTodosCount != currentCount || + _lastFingerprint != currentFingerprint) { _workModel = _buildModel(); _lastTodosCount = currentCount; + _lastFingerprint = currentFingerprint; } if (_isRefreshing) { _isRefreshing = false; @@ -149,31 +185,36 @@ class _WorkPageState extends State .where((e) => e.typeName == '办事' && e.search(searchText)) .toList(); } else if (title == "草稿") { - result = (await relationCtrl.work.loadContent(TaskType.draft, reload: reload)) - .where((e) => e.search(searchText)) - .toList(); + result = + (await relationCtrl.work.loadContent(TaskType.draft, reload: reload)) + .where((e) => e.search(searchText)) + .toList(); } else if (title == "任务") { - result = (await relationCtrl.work.loadContent(TaskType.task, reload: reload)) - .where((e) => e.search(searchText)) - .toList(); + result = + (await relationCtrl.work.loadContent(TaskType.task, reload: reload)) + .where((e) => e.search(searchText)) + .toList(); } else if (title == "待办") { - result = relationCtrl.work.todos - .where((e) => e.search(searchText)) - .toList(); + result = + relationCtrl.work.todos.where((e) => e.search(searchText)).toList(); } else if (title == "已办") { - result = (await relationCtrl.work.loadContent(TaskType.done, reload: reload)) - .where((e) => e.search(searchText)) - .toList(); + result = + (await relationCtrl.work.loadContent(TaskType.done, reload: reload)) + .where((e) => e.search(searchText)) + .toList(); } else if (title == "抄送") { - result = (await relationCtrl.work.loadContent(TaskType.altMe, reload: reload)) - .where((e) => e.search(searchText)) - .toList(); + result = + (await relationCtrl.work.loadContent(TaskType.altMe, reload: reload)) + .where((e) => e.search(searchText)) + .toList(); } else if (title == "已发起") { - result = (await relationCtrl.work.loadContent(TaskType.create, reload: reload)) - .where((e) => e.search(searchText)) - .toList(); + result = + (await relationCtrl.work.loadContent(TaskType.create, reload: reload)) + .where((e) => e.search(searchText)) + .toList(); } else if (title == "已完结") { - result = (await relationCtrl.work.loadContent(TaskType.completed, reload: reload)) + result = (await relationCtrl.work + .loadContent(TaskType.completed, reload: reload)) .where((e) => e.search(searchText)) .toList(); } -- Gitee From 1112b4e6fa1598609606946654b05d9addd9e347 Mon Sep 17 00:00:00 2001 From: panzhaohui Date: Sun, 26 Jul 2026 11:45:16 +0800 Subject: [PATCH 21/37] =?UTF-8?q?fix:=20HTTP=20receiveTimeout=2030s?= =?UTF-8?q?=E2=86=9260s,=E4=BF=AE=E5=A4=8Dkernel=E5=85=A8=E9=87=8F?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E8=B6=85=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/dart/base/api/http_util.dart | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/dart/base/api/http_util.dart b/lib/dart/base/api/http_util.dart index a8649c2..0616da8 100644 --- a/lib/dart/base/api/http_util.dart +++ b/lib/dart/base/api/http_util.dart @@ -41,7 +41,7 @@ class HttpUtil with AuthMixin implements StoreHubHttpClient { return BaseOptions( baseUrl: Constant.host, connectTimeout: const Duration(seconds: 15), - receiveTimeout: const Duration(seconds: 30), + receiveTimeout: const Duration(seconds: 60), headers: {}, contentType: 'application/json; charset=utf-8', responseType: ResponseType.json, @@ -71,8 +71,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(); } } -- Gitee From d40fd8af5f526a0e252dd1f136c91921c54e0237 Mon Sep 17 00:00:00 2001 From: panzhaohui Date: Sun, 26 Jul 2026 12:56:18 +0800 Subject: [PATCH 22/37] =?UTF-8?q?fix:=20=E9=95=BF=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E6=96=AD=E7=BA=BF=E9=87=8D=E8=BF=9E+=E7=BD=91=E7=BB=9C?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E6=95=B0=E6=8D=AE=E5=88=B7=E6=96=B0=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0: _noneConnectivityCount 计数器竞态修复 - connectivityResult setter 网络恢复时不再立即清零计数器 - 保留 hasDataDelay=true 标志,等 _networkFluctuations→refreshData→_wakeUp 完成后再清零 - 修复纯网络波动(不切前后台)场景下数据不刷新的问题 P1: checkOnline 等待时间 2s→5s - 适应网络波动后长连接重连较慢的场景 P1: 长连接未恢复时不再退出刷新流程 - 改为使用 HTTP fallback 继续刷新数据(kernel.request 内部自动降级) - 避免长连接恢复慢导致数据完全不刷新 --- lib/dart/base/api/storehub.dart | 3 ++- lib/dart/controller/app_start_controller.dart | 25 ++++++++++--------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/lib/dart/base/api/storehub.dart b/lib/dart/base/api/storehub.dart index 1ba7ae8..b6b4762 100644 --- a/lib/dart/base/api/storehub.dart +++ b/lib/dart/base/api/storehub.dart @@ -294,7 +294,8 @@ class StoreHub with AuthMixin, RequestStatisticsMixin { !_isStarting) { await _starting(reason: 'check_online'); } - return _waitForLongLink(const Duration(seconds: 2)); + // 等待长连接恢复:从 2s 提升到 5s,适应网络波动后重连较慢的场景 + return _waitForLongLink(const Duration(seconds: 5)); } void _setTransportState({ diff --git a/lib/dart/controller/app_start_controller.dart b/lib/dart/controller/app_start_controller.dart index 5dfc9cf..48911d7 100644 --- a/lib/dart/controller/app_start_controller.dart +++ b/lib/dart/controller/app_start_controller.dart @@ -56,12 +56,9 @@ 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(); } }); @@ -98,7 +95,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; @@ -132,9 +131,9 @@ class AppStartController with EmitterMixin, AuthMixin { _noneConnectivityCount += 1; ToastUtils.showMsg(msg: "网络不可用,请检查网络设置"); } else { - // 重新连接后清零,允许 cacheChatData 恢复写入 - _noneConnectivityCount = 0; - // 等待长连接恢复后再触发后续刷新逻辑 + // 网络恢复:不清零计数器,保留 hasDataDelay=true 标志, + // 等 _networkFluctuations → refreshData → _wakeUp 完成后再清零。 + // 这样纯网络波动(不切前后台)也能触发数据刷新。 unawaited(kernel.checkOnline()); } command.emitter('network', 'refresh'); @@ -178,11 +177,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 +199,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) { -- Gitee From 022b9516e3021fc6a70b450b3778374fa84bfe66 Mon Sep 17 00:00:00 2001 From: panzhaohui Date: Sun, 26 Jul 2026 13:01:34 +0800 Subject: [PATCH 23/37] =?UTF-8?q?fix:=20SignalR=20invoke=20=E8=B6=85?= =?UTF-8?q?=E6=97=B6=E5=90=8E=E9=99=8D=E7=BA=A7=E5=88=B0=20HTTP=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SignalR invoke 超时时间 30s→60s - 超时后自动降级到 HTTP fallback(http_timeout_fallback) - 避免长连接慢响应导致数据完全不加载 --- lib/dart/base/api/storehub.dart | 37 +++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/lib/dart/base/api/storehub.dart b/lib/dart/base/api/storehub.dart index b6b4762..15a3e95 100644 --- a/lib/dart/base/api/storehub.dart +++ b/lib/dart/base/api/storehub.dart @@ -676,12 +676,28 @@ 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 { + resObj = await _connection + .invoke(methodName, args: safeArgs) + .timeout(const Duration(seconds: 60), onTimeout: () { + throw TimeoutException( + 'SignalR invoke 超时: $methodName', const Duration(seconds: 60)); + }); + } 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) { @@ -758,8 +774,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; @@ -767,7 +785,8 @@ class StoreHub with AuthMixin, RequestStatisticsMixin { if (err != null) { msg += err is String ? err : err.toString(); } - _logDeduped('$method|err|$msg', + _logDeduped( + '$method|err|$msg', () => XLogUtil.e('StoreHub 请求异常: $method err=$msg' '${s != null ? "\nstack=$s" : ""}')); res = getRequest(msg); -- Gitee From 682b65bb11eccb948c466fe38eee3d0cb44185c6 Mon Sep 17 00:00:00 2001 From: panzhaohui Date: Sun, 26 Jul 2026 13:41:11 +0800 Subject: [PATCH 24/37] =?UTF-8?q?fix:=20SignalR=20=E9=95=BF=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E6=97=A0=E6=B3=95=E5=BB=BA=E7=AB=8B=E7=9A=84=E6=A0=B9?= =?UTF-8?q?=E5=9B=A0=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0: HttpConnectionOptions.requestTimeout 2s→30s - signalr_netcore 默认 requestTimeout=2000ms - 移动网络下 negotiate 几乎必超时,导致长连接无法建立 - 提升到 30s,确保 negotiate 请求有足够时间完成 P1: 添加 accessTokenFactory - 原生 App 无 Cookie 机制,negotiate/WS 请求不携带 Authorization - 通过 accessTokenFactory 注入 Storage 中的 accessToken P1: 添加 requestOnly=0 查询参数 - 对齐 oiocns-react 的 URL 构造方式 - 服务端可能依赖此参数区分连接类型 --- lib/dart/base/api/kernelapi.dart | 2 +- lib/dart/base/api/storehub.dart | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/dart/base/api/kernelapi.dart b/lib/dart/base/api/kernelapi.dart index 37973b3..33f70cb 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); } diff --git a/lib/dart/base/api/storehub.dart b/lib/dart/base/api/storehub.dart index 15a3e95..cba47a0 100644 --- a/lib/dart/base/api/storehub.dart +++ b/lib/dart/base/api/storehub.dart @@ -174,7 +174,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, -- Gitee From cb0e7dbc06cb40b88688e637b6ef035aea96685e Mon Sep 17 00:00:00 2001 From: panzhaohui Date: Sun, 26 Jul 2026 15:11:48 +0800 Subject: [PATCH 25/37] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20setState=20du?= =?UTF-8?q?ring=20build=20=E5=BC=82=E5=B8=B8=20+=20=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E8=BF=9E=E6=8E=A5=E7=8A=B6=E6=80=81=E6=98=BE?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/dart/core/provider/session.dart | 3 + lib/pages/chats/chat_page.dart | 12 +- lib/pages/discover/discover_page.dart | 125 ++++++++++-------- .../portal/logic/platform_entry_logic.dart | 28 +++- lib/pages/portal/platform_entry_page.dart | 81 +++++++++--- lib/pages/relation/relation_page.dart | 69 ++++++---- lib/pages/work/work_page.dart | 13 +- 7 files changed, 218 insertions(+), 113 deletions(-) diff --git a/lib/dart/core/provider/session.dart b/lib/dart/core/provider/session.dart index 9c06097..9042947 100644 --- a/lib/dart/core/provider/session.dart +++ b/lib/dart/core/provider/session.dart @@ -335,6 +335,9 @@ class ChatProvider with EmitterMixin implements IChatProvider { // activityProvider.load(reload: true); //XLogUtil.dd("唤醒:刷新未读数据$noReadChatCount"); _notify(); + // 持久化最新会话到 SQLite L1(AGENTS.md §6.2 / §18.3 持久化同刷) + // 长连接后台唤醒拉取的新数据必须同步写入本地,避免下次冷启动丢失 + _persistSessionsToSQLite(chats); } else if (_inited) { load(); } diff --git a/lib/pages/chats/chat_page.dart b/lib/pages/chats/chat_page.dart index 2365d2e..37628b8 100644 --- a/lib/pages/chats/chat_page.dart +++ b/lib/pages/chats/chat_page.dart @@ -66,13 +66,21 @@ class _ChatPageState extends State // 后台刷新完成后通过 'chat'/'new' 事件通知刷新 UI final chatSub = command.subscribe((type, cmd, args) { if (type == 'chat' && cmd == 'new') { - if (mounted) _refreshModel(); + 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) _refreshModel(); + if (!mounted) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _refreshModel(); + }); }); _subscriptions.add(sessionFlagSub); // 沟通页不跟随单位切换刷新数据,保持本地缓存优先策略 diff --git a/lib/pages/discover/discover_page.dart b/lib/pages/discover/discover_page.dart index 14dd5e3..6768cf0 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'; @@ -55,22 +56,33 @@ class _DiscoverPageState extends State // 但需要订阅后台数据加载完成事件,确保 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; - // 后台数据加载完成(如登录后 deepLoadBackground),触发发现页重新聚合 - // 只在已有数据时触发(避免首屏加载重复请求) - final provider = context.read(); - if (provider.hasLoadedData) { - provider.refreshData(); - } + 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]) { + final bgLoadedSub = + command.subscribeByFlag('backgroundLoaded', ([dynamic args]) { if (!mounted) return; - // 后台二级递归 deepLoad 完成(groups/cohorts/storages 已填充), - // 此时多集群聚合能拉到完整资源,触发一次刷新 - final provider = context.read(); - provider.refreshData(); + SchedulerBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + // 后台二级递归 deepLoad 完成(groups/cohorts/storages 已填充), + // 此时多集群聚合能拉到完整资源,触发一次刷新 + final provider = context.read(); + provider.refreshData(); + }); }); _subscribers.add(bgLoadedSub); } @@ -116,8 +128,7 @@ class _DiscoverPageState extends State resourceId: resourceId, title: title, sourceId: sourceId); break; case PlazaType.live: - page = LiveListPage( - resourceId: resourceId, title: title); + page = LiveListPage(resourceId: resourceId, title: title); break; case PlazaType.marketTrade: page = MarketListPage( @@ -332,7 +343,8 @@ class _DiscoverPageState extends State isScrollControlled: true, backgroundColor: OipSurface.card, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(OipRadius.xl.r)), + borderRadius: + BorderRadius.vertical(top: Radius.circular(OipRadius.xl.r)), ), builder: (ctx) { return DraggableScrollableSheet( @@ -364,7 +376,8 @@ class _DiscoverPageState extends State padding: EdgeInsets.symmetric(horizontal: 16.w), child: Text( '"$query" 共找到 ${allResults.length} 条结果', - style: XFonts.captionSmall.copyWith(color: OipText.tertiary), + style: + XFonts.captionSmall.copyWith(color: OipText.tertiary), ), ), SizedBox(height: 8.h), @@ -457,13 +470,13 @@ class _DiscoverPageState extends State SizedBox(height: 2.h), Text( '${item.type} · ${item.spaceName}', - style: XFonts.captionSmall.copyWith(color: OipText.tertiary), + style: + XFonts.captionSmall.copyWith(color: OipText.tertiary), ), ], ), ), - Icon(Icons.chevron_right, - size: 20.sp, color: OipText.disabled), + Icon(Icons.chevron_right, size: 20.sp, color: OipText.disabled), ], ), ), @@ -733,9 +746,8 @@ class _DiscoverPageState extends State ? (provider.errorMessage ?? '暂时无法加载${config.label}') : '暂无${config.label}内容,下拉可重新刷新', actionLabel: hasError ? '重新加载' : null, - onAction: hasError - ? () => provider.loadDiscoverData(forceRefresh: true) - : null, + onAction: + hasError ? () => provider.loadDiscoverData(forceRefresh: true) : null, ); } @@ -769,48 +781,49 @@ class _DiscoverPageState extends State color: color, ), ), - 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), - ), - SizedBox(height: 4.h), - Text( - _buildResourceSubtitle(config, resource), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: XFonts.caption.copyWith( - color: OipText.tertiary, - 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: OipBrand.primary, + 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: OipText.disabled, - ), - ], + SizedBox(width: 8.w), + Icon( + Icons.chevron_right_rounded, + size: 20.sp, + color: OipText.disabled, + ), + ], + ), ), ), ), - ), ); } diff --git a/lib/pages/portal/logic/platform_entry_logic.dart b/lib/pages/portal/logic/platform_entry_logic.dart index 0180c8d..a8845a4 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() { - // 仅订阅 switchSpace:切换单位时才刷新,避免 session 事件过度刷新 - final switchId = command.subscribeByFlag('switchSpace', ([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(switchId); + _entrySubscribers.add(bgLoadedId); } } diff --git a/lib/pages/portal/platform_entry_page.dart b/lib/pages/portal/platform_entry_page.dart index 4568b03..cde7d31 100644 --- a/lib/pages/portal/platform_entry_page.dart +++ b/lib/pages/portal/platform_entry_page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; @@ -7,6 +9,7 @@ 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'; @@ -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(); @@ -469,21 +478,16 @@ class _PlatformEntryPageState extends State SizedBox(height: 4.h), Row( children: [ - // 在线状态点 + // 连接状态点 Container( width: 8.w, height: 8.w, decoration: BoxDecoration( shape: BoxShape.circle, - color: _isOnline - ? OipState.success - : OipState.warning, + color: _connectionColor, boxShadow: [ BoxShadow( - color: (_isOnline - ? OipState.success - : OipState.warning) - .withValues(alpha: 0.7), + color: _connectionColor.withValues(alpha: 0.7), blurRadius: 8, spreadRadius: 2, ), @@ -492,7 +496,7 @@ class _PlatformEntryPageState extends State ), SizedBox(width: 7.w), Text( - _isOnline ? '在线 · 数据同步中' : '离线 · 网络连接中', + _connectionStatusText, style: XFonts.captionSmall.copyWith( color: OipText.inverse.withValues(alpha: 0.75), ), @@ -727,15 +731,14 @@ class _PlatformEntryPageState extends State child: Center( child: Column( children: [ - Icon(Icons.cloud_off_outlined, - size: 48.w, color: OipText.disabled), + Icon(Icons.cloud_off_outlined, size: 48.w, color: OipText.disabled), SizedBox(height: 12.h), Text('暂无网站栏目数据', style: XFonts.captionSmall.copyWith(color: OipText.tertiary)), SizedBox(height: 4.h), Text('请检查集群绑定或网络连接', - style: XFonts.captionSmall.copyWith( - color: OipText.disabled, fontSize: 16.sp)), + style: XFonts.captionSmall + .copyWith(color: OipText.disabled, fontSize: 16.sp)), ], ), ), @@ -798,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 true; + 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 '离线 · 断线'; } } } @@ -1099,7 +1144,8 @@ class _PlazaContentListViewState extends State<_PlazaContentListView> { Flexible( child: Text( widget.resource.name, - style: XFonts.titleSmall.copyWith(color: OipText.primary), + style: + XFonts.titleSmall.copyWith(color: OipText.primary), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -1346,7 +1392,6 @@ class _PlazaContentListViewState extends State<_PlazaContentListView> { } } - /// 网格点装饰画笔(科技感纹理) class _GridDotPainter extends CustomPainter { final Color color; diff --git a/lib/pages/relation/relation_page.dart b/lib/pages/relation/relation_page.dart index 051243e..0aaf6d4 100644 --- a/lib/pages/relation/relation_page.dart +++ b/lib/pages/relation/relation_page.dart @@ -89,14 +89,17 @@ class _RelationState extends State // 对齐 AGENTS.md §18.2「事件通知:后台拉取完成后通过 command.emitter(flag) 通知订阅方刷新」 final sessionSub = command.subscribeByFlag('session', ([dynamic args]) { if (!mounted) return; - // 重置 model 触发 _loadFromRoute 重建,从内存读取最新数据 - setState(() { - relationModel = null; - _lastLoadedPath = null; - _lastLoadedData = null; - }); + // 事件回调可能在 build 阶段被同步派发,直接 setState 会触发 + // "setState during build" 异常,因此将 setState + _loadFromRoute + // 一起放到首帧后执行。 WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; + // 重置 model 触发 _loadFromRoute 重建,从内存读取最新数据 + setState(() { + relationModel = null; + _lastLoadedPath = null; + _lastLoadedData = null; + }); _loadFromRoute(context); }); }); @@ -137,7 +140,9 @@ class _RelationState extends State final currentData = ar.currPageData.data; // 路径变化 或 数据引用变化(如点击单位进入二级页面,路径不变但 listDatas 变化)时触发重建 final dataChanged = !identical(currentData, _lastLoadedData); - if (relationModel == null || _lastLoadedPath != currentPath || dataChanged) { + if (relationModel == null || + _lastLoadedPath != currentPath || + dataChanged) { // 首次进入时 model 仍为 null,渲染骨架屏占位避免 TabContainerWidget 接收 null if (relationModel == null) { return const SkeletonWidget(itemCount: 6); @@ -295,8 +300,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); @@ -458,15 +462,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) { @@ -829,12 +831,14 @@ class _RelationState extends State padding: EdgeInsets.symmetric(vertical: 12.h), child: Text( storage.name, - style: XFonts.labelSmall.copyWith(color: OipText.primary, fontWeight: FontWeight.w600), + style: XFonts.labelSmall.copyWith( + color: OipText.primary, fontWeight: FontWeight.w600), ), ), const Divider(height: 1, color: OipBorder.divider), ListTile( - leading: Icon(Icons.folder_open_outlined, size: 22.w, color: OipBrand.primary), + leading: Icon(Icons.folder_open_outlined, + size: 22.w, color: OipBrand.primary), title: Text('查看数据集', style: XFonts.bodyMedium), onTap: () { Navigator.pop(sheetCtx); @@ -843,7 +847,8 @@ class _RelationState extends State ), if (!storage.isActivate) ListTile( - leading: Icon(Icons.check_circle_outline, size: 22.w, color: OipState.success), + leading: Icon(Icons.check_circle_outline, + size: 22.w, color: OipState.success), title: Text('激活存储', style: XFonts.bodyMedium), onTap: () { Navigator.pop(sheetCtx); @@ -851,8 +856,10 @@ class _RelationState extends State }, ), ListTile( - leading: Icon(Icons.logout_outlined, size: 22.w, color: OipState.error), - title: Text('退出存储', style: XFonts.bodyMedium.copyWith(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); @@ -899,8 +906,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('确定')), ], ), ); @@ -954,24 +965,28 @@ class _RelationState extends State padding: EdgeInsets.symmetric(vertical: 12.h), child: Text( agent.name, - style: XFonts.labelSmall.copyWith(color: OipText.primary, fontWeight: FontWeight.w600), + style: XFonts.labelSmall.copyWith( + color: OipText.primary, fontWeight: FontWeight.w600), ), ), const Divider(height: 1, color: OipBorder.divider), ListTile( - leading: Icon(Icons.chat_bubble_outline, size: 22.w, color: OipBrand.primary), + 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), + leading: Icon(Icons.add_circle_outline, + size: 22.w, color: OipState.success), title: Text('创建智能体', style: XFonts.bodyMedium), onTap: () { Navigator.pop(sheetCtx); @@ -979,7 +994,8 @@ class _RelationState extends State }, ), ListTile( - leading: Icon(Icons.group_add_outlined, size: 22.w, color: OipBrand.primary), + leading: Icon(Icons.group_add_outlined, + size: 22.w, color: OipBrand.primary), title: Text('加入智能体', style: XFonts.bodyMedium), onTap: () { Navigator.pop(sheetCtx); @@ -987,7 +1003,8 @@ class _RelationState extends State }, ), ListTile( - leading: Icon(Icons.info_outline, size: 22.w, color: OipText.tertiary), + leading: Icon(Icons.info_outline, + size: 22.w, color: OipText.tertiary), title: Text('查看详情', style: XFonts.bodyMedium), onTap: () { Navigator.pop(sheetCtx); diff --git a/lib/pages/work/work_page.dart b/lib/pages/work/work_page.dart index 19a75b5..9ad1b9b 100644 --- a/lib/pages/work/work_page.dart +++ b/lib/pages/work/work_page.dart @@ -64,10 +64,15 @@ class _WorkPageState extends State // (provider/index.dart 第 673 行和 756 行),通知办事页重建 model。 final workSub = command.subscribeByFlag('work', ([dynamic args]) { if (!mounted) return; - // 后台办事数据刷新完成,强制重建 model 拉取最新待办/已办/已发起等 - setState(() { - _workModel = null; - _lastFingerprint = null; + // 事件回调可能在 build 阶段被同步派发,直接 setState 会触发 + // "setState during build" 异常,因此延迟到首帧后执行。 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + // 后台办事数据刷新完成,强制重建 model 拉取最新待办/已办/已发起等 + setState(() { + _workModel = null; + _lastFingerprint = null; + }); }); }); _subscriptions.add(workSub); -- Gitee From c57c4a3fe3b043bb3e1695a2530ca303614214ef Mon Sep 17 00:00:00 2001 From: panzhaohui Date: Sun, 26 Jul 2026 15:55:52 +0800 Subject: [PATCH 26/37] =?UTF-8?q?fix:=20=E4=BC=9A=E8=AF=9D=E4=B8=8E?= =?UTF-8?q?=E5=8A=9E=E4=BA=8B=E5=8E=86=E5=8F=B2=E6=95=B0=E6=8D=AE=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=20SQLite=20=E6=9C=AC=E5=9C=B0=E4=BC=98=E5=85=88?= =?UTF-8?q?=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/dart/core/provider/session.dart | 49 ++++++++++ lib/dart/core/work/provider.dart | 144 ++++++++++++++++++---------- 2 files changed, 144 insertions(+), 49 deletions(-) diff --git a/lib/dart/core/provider/session.dart b/lib/dart/core/provider/session.dart index 9042947..678c7fb 100644 --- a/lib/dart/core/provider/session.dart +++ b/lib/dart/core/provider/session.dart @@ -145,6 +145,13 @@ class ChatProvider with EmitterMixin implements IChatProvider { for (var company in user.companys) { tmpChats.addAll(company.chats); } + // 本地优先兜底:若内存会话为空(deepLoad 失败/超时或 skipDeepLoad 且 + // memberChats 未填充),从 SQLite L1 恢复已缓存的会话列表, + // 通过 user/company.findMemberChat(id) 还原 ISession 实例。 + // 这样保证离线场景下首屏仍能看到会话列表,deepLoad 完成后会再次刷新。 + if (tmpChats.isEmpty) { + tmpChats = await _loadChatsFromSQLite(); + } // 统一加载会话消息缓存 await Future.wait(tmpChats.map((s) => s.loadChatData())); chats = _filterAndSortChats(tmpChats); @@ -161,6 +168,48 @@ 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, + ); + if (rows.isEmpty) { + XLogUtil.w('>>>>>>[数据刷新] SQLite 中无会话数据'); + return []; + } + final sessions = []; + 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); + } + } + XLogUtil.i('>>>>>>[数据刷新] 从 SQLite 恢复会话 ${sessions.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}) => diff --git a/lib/dart/core/work/provider.dart b/lib/dart/core/work/provider.dart index 9c76778..a88a966 100644 --- a/lib/dart/core/work/provider.dart +++ b/lib/dart/core/work/provider.dart @@ -256,72 +256,118 @@ class WorkProvider with EmitterMixin implements IWorkProvider { Future> loadTasks(TaskType type, [bool reload = false, int take = 10]) 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': [], + try { + var result = await kernel.collectionLoad>( + userId, + userId, + [], + TaskCollName, + { 'options': { - 'match': { - 'createUser': userId, - 'status': { - '_gte_': 100, - }, - 'nodeId': { - '_exists_': false, - }, - }, + 'match': _typeMatch(type), 'sort': { 'createTime': -1, }, - 'project': { - 'data': 0 - } }, 'skip': 0, 'take': 1000, - } : */ - { - 'options': { - 'match': _typeMatch(type), - 'sort': { - 'createTime': -1, - }, }, - 'skip': 0, - 'take': 1000, //pageSize, - }, - fromJson: (data) { - return XWorkTask.fromList(data['data'] is List ? data['data'] : []); - }, - ); - // 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)); - } - }); + fromJson: (data) { + return XWorkTask.fromList(data['data'] is List ? data['data'] : []); + }, + ); + 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)); + } + }); + } + // 持久化到 SQLite L1(AGENTS.md §6.2 / §18.3 持久化同刷) + _persistHistoryTasksToSQLite(type, tasks); + } catch (e, s) { + final msg = 'WorkProvider.loadTasks(${type.label}) 异常: $e\n$s'; + XLogUtil.e('>>>>>>[数据刷新] $msg, 尝试从 SQLite 读取'); + SystemLog.err(msg, '后台加载'); + // 网络异常时从 SQLite 读取历史数据 + tasks = await _loadHistoryTasksFromSQLite(type); } tasks = tasks.where((i) => i.isTaskType(type)).toList(); tasks.sort((a, b) { return DateTime.parse(b.metadata.updateTime ?? "") .compareTo(DateTime.parse(a.metadata.updateTime ?? "")); }); - // finished = taskDatas.length < 10; - // tasks.addAll(taskDatas); - // } return tasks; } + /// 持久化历史任务(已办/已发起/已完结/抄送/草稿/任务)到 SQLite + void _persistHistoryTasksToSQLite(TaskType type, List tasks) { + if (tasks.isEmpty) return; + Future(() async { + try { + final userId = this.userId; + final spaceId = user.user?.currentSpace.id ?? 'personal'; + final rows = >[]; + for (final t in tasks) { + final taskData = t.taskdata; + rows.add({ + 'id': taskData.id ?? '', + 'belong_id': userId, + 'space_id': spaceId, + '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), + }); + } + 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写入'); + } + }); + } + + /// 从 SQLite 读取历史任务(网络失败时的本地兜底) + Future> _loadHistoryTasksFromSQLite(TaskType type) async { + try { + final rows = await BusinessDatabase.instance.queryWorkTasks( + belongId: userId, + taskType: type.label, + ); + if (rows.isEmpty) { + XLogUtil.w('>>>>>>[数据刷新] SQLite 中无 ${type.label} 数据'); + 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读取'); + } + } + 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 []; + } + } + @override Future loadTaskCount(TaskType type) async { var res = await kernel.collectionLoad( -- Gitee From d044741feb86d1a3cd6611b0ea4a0fd0c0d91525 Mon Sep 17 00:00:00 2001 From: Orginone Developer Date: Sun, 26 Jul 2026 16:37:31 +0800 Subject: [PATCH 27/37] =?UTF-8?q?fix:=20=E5=90=88=E5=B9=B6=E8=BF=9C?= =?UTF-8?q?=E7=AB=AF=E4=BB=A3=E7=A0=81=E5=B9=B6=E4=BF=AE=E5=A4=8D=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=E6=95=B0=E6=8D=AE=E4=B8=89=E7=BA=A7=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E8=AF=84=E5=AE=A1=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评审修复: - Form 后台刷新 attributes 后重置 _fieldsLoaded 以重新生成 fields - WorkProvider emitterFlag 去抖 100ms 合并并发后台刷新触发的多次 UI 重建 - Application 后台刷新表单改为增量合并 保留旧 Form 实例避免 fields 状态丢失 - loadTasks 远端异常时回退 L1 SQLite 兜底 --- AGENTS.md | 67 +++- android/app/build.gradle | 21 +- android/build.gradle | 6 +- android/gradle.properties | 10 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- android/settings.gradle | 35 +- lib/dart/base/storages/business_database.dart | 173 +++++++++- .../file_system_cache_repository.dart | 251 ++++++++++++++ lib/dart/core/provider/auth.dart | 6 + .../repository/flow_data_cache_service.dart | 315 ++++++++++++++++++ lib/dart/core/repository/work_repository.dart | 19 ++ lib/dart/core/thing/standard/application.dart | 173 ++++++++-- lib/dart/core/thing/standard/form.dart | 162 ++++++--- lib/dart/core/work/index.dart | 68 +++- lib/dart/core/work/provider.dart | 282 +++++++++++----- pubspec.yaml | 2 + 16 files changed, 1391 insertions(+), 201 deletions(-) create mode 100644 lib/dart/base/storages/file_system_cache_repository.dart create mode 100644 lib/dart/core/repository/flow_data_cache_service.dart diff --git a/AGENTS.md b/AGENTS.md index 0b29b11..e42ad0c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,38 +96,79 @@ --- -## 6. 本地存储(SQLite 全量持久化 + 本地优先) +## 6. 本地存储(多级缓存 + 本地优先) ### 6.1 存储分层(硬约束) | 层级 | 用途 | 实现 | 范围 | | ---- | ---- | ---- | ---- | -| L0 内存 | 运行时缓存 | `relationCtrl.user.*` / `provider.chats` / `GetX Rx` | 当前会话活跃数据 | -| L1 SQLite | **全量持久化** | `sqflite`(IoT 用 `sqflite_sqlcipher` 加密) | 用户浏览过的所有业务数据:会话/消息/动态/办事/关系树/存储/智能体/设备/告警 | -| L2 KV | 轻量配置 | `Storage`(SharedPreferences) | token、开关、主题、最近单位等少量字符串/数字 | -| L3 Hive | 结构化草稿 | `HiveUtils` + Hive Box | 办事草稿、表单暂存、文件上传队列 | +| 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**:会话列表、消息内容、动态、办事任务、关系树(单位/群组/部门/集群/存储/智能体/感知设备)、存储资源、表单数据等。 -- **本地优先**:页面打开时先从 SQLite 读取全量数据渲染,再后台拉取最新数据。 -- **页面点击触发后台更新**:用户点击列表项 / 进入子页面 / 切换 Tab 时,后台 `fire-and-forget` 拉取当前页面数据的最新版本,**持久化到 SQLite 同时 `setState` 刷新页面**(有变化才刷新,无变化不重建)。 -- **登出清空**:`exitLogin` 必须清空 SQLite 全部业务表(保留 L2 的 token 黑名单等必要配置),防止跨用户数据污染。 -- **表结构**:按业务域分表(`sessions` / `messages` / `activities` / `work_tasks` / `relations` / `storages` / `agents` / `iot_devices` / `iot_alerts`),主键 `id` + 索引 `belongId` / `updateTime` / `spaceId`。 +- **所有用户浏览过的业务数据必须写入 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 通用约束 +### 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.4 现有 SQLite 使用 +### 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 约束。 +- 其他业务域 SQLite 持久化按需推进,新增业务必须遵循 §6.2 / §6.3 约束。 --- diff --git a/android/app/build.gradle b/android/app/build.gradle index df09c8e..66c8b54 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 5ae0c77..791b046 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 5e20c66..4b6b187 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 33225d2..c057e47 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 22eef60..a493592 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/lib/dart/base/storages/business_database.dart b/lib/dart/base/storages/business_database.dart index 0d34d94..efbecb9 100644 --- a/lib/dart/base/storages/business_database.dart +++ b/lib/dart/base/storages/business_database.dart @@ -18,7 +18,7 @@ class BusinessDatabase { static BusinessDatabase get instance => _instance; Database? _db; - static const int _schemaVersion = 1; + static const int _schemaVersion = 2; static const String _dbName = 'oiocns_business.db'; bool get isInitialized => _db != null && _db!.isOpen; @@ -185,11 +185,85 @@ class BusinessDatabase { 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 { // 增量迁移,禁止 DROP 全表(AGENTS.md §6.3) + if (oldVersion < 2) { + final batch = db.batch(); + _createFlowTables(batch); + await batch.commit(noResult: true); + } } // ============ Sessions ============ @@ -466,6 +540,100 @@ class BusinessDatabase { 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; @@ -521,6 +689,9 @@ class BusinessDatabase { 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) { 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 0000000..e3ed525 --- /dev/null +++ b/lib/dart/base/storages/file_system_cache_repository.dart @@ -0,0 +1,251 @@ +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(用于变更检测) + String _computeHash(dynamic data) { + try { + final encoded = jsonEncode(data); + return sha1.convert(utf8.encode(encoded)).toString(); + } catch (_) { + return DateTime.now().millisecondsSinceEpoch.toString(); + } + } + + /// 读取缓存数据 + /// + /// 返回反序列化后的 Map;不存在或读取失败返回 null。 + Future?> read( + String userId, + String spaceId, + String dataType, + String id, + ) async { + 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? ?? ''; + return data; + } + return null; + } catch (e) { + XLogUtil.w('[FileSystemCache] 读取失败 $dataType/$id: $e'); + return null; + } + } + + /// 写入缓存数据(hash 比对,无变化不写盘) + /// + /// 返回是否实际写盘。 + Future write( + String userId, + String spaceId, + String dataType, + String id, + Map data, + ) async { + try { + final hash = _computeHash(data); + final key = _indexKey(userId, spaceId, dataType, id); + if (_hashIndex[key] == hash) { + return false; + } + final file = await _buildFile(userId, spaceId, dataType, id); + final payload = Map.from(data); + payload['__hash'] = hash; + payload['__updateTime'] = + DateTime.now().millisecondsSinceEpoch; + await file.writeAsString(jsonEncode(payload), flush: true); + _hashIndex[key] = hash; + return true; + } catch (e) { + XLogUtil.w('[FileSystemCache] 写入失败 $dataType/$id: $e'); + return false; + } + } + + /// 异步写入(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 = >{}; + 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; + 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 (_) { + // 单文件损坏跳过 + } + } + } catch (e) { + XLogUtil.w('[FileSystemCache] readAll 失败 $dataType: $e'); + } + 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/core/provider/auth.dart b/lib/dart/core/provider/auth.dart index 2b05a3d..9e94196 100644 --- a/lib/dart/core/provider/auth.dart +++ b/lib/dart/core/provider/auth.dart @@ -16,6 +16,7 @@ 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'; @@ -162,6 +163,11 @@ mixin AuthMixin { // 清空业务数据 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 { 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 0000000..e7eba97 --- /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 5362ce9..cbad2c3 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( diff --git a/lib/dart/core/thing/standard/application.dart b/lib/dart/core/thing/standard/application.dart index 17ebe03..ac7ad5d 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,76 @@ 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) + await _loadWorksFromRemote(userId, sid, reload: reload); 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); + if (res.success && 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 +276,94 @@ 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) + await _loadFormsFromRemote(userId, sid, reload: reload); return forms; } + /// 从远端拉取表单并落盘 L1 + Future _loadFormsFromRemote(String userId, String sid, + {bool reload = false}) async { + _formsLoaded = true; + var data = await directory.resource.formColl.loadSpace1({ + "options": { + "match": {"applicationId": id} + }, + }, cvt: XForm.fromJson); + 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 e66ffcf..51c64e9 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 bd7909e..d0f452a 100644 --- a/lib/dart/core/work/index.dart +++ b/lib/dart/core/work/index.dart @@ -4,11 +4,13 @@ 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,17 +198,71 @@ 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) { - node = res.data; - await recursionForms(node!); + // L0 内存命中 + if (node != null && !reload) { + return node; + } + + final userId = belongId; + final sid = spaceId; + + // L2 文件系统兜底(仅 reload=false 时尝试,让调用方有数据可用) + if (!reload && node == null) { + try { + final cached = await FlowDataCacheService.instance + .loadWorkNodeFromCache(userId, sid, id); + if (cached != null) { + node = cached; + await recursionForms(node!); + // 后台 fire-and-forget 拉远端刷新(有变化才更新 + 落盘) + _refreshWorkNodeFromRemote(userId, sid); + return node; + } + } catch (e) { + XLogUtil.w('[Work.loadWorkNode] L2 缓存读取失败 work=$id: $e'); } } + + // 远端拉取(首次或 reload) + final res = + await kernel.queryWorkNodes({'id': id, 'shareId': metadata.shareId}); + if (res.success && res.data != null) { + node = res.data; + await recursionForms(node!); + // 落盘 L2 文件系统 + FlowDataCacheService.instance + .cacheWorkNode(userId, sid, id, 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(); diff --git a/lib/dart/core/work/provider.dart b/lib/dart/core/work/provider.dart index a88a966..027c1db 100644 --- a/lib/dart/core/work/provider.dart +++ b/lib/dart/core/work/provider.dart @@ -76,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; @@ -130,35 +134,93 @@ class WorkProvider with EmitterMixin implements IWorkProvider { @override Future> loadTodos({bool reload = false}) async { - if (!_todoLoaded || reload) { - XLogUtil.i('>>>>>>[数据刷新] WorkProvider.loadTodos 开始 reload=$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 { - final res = await kernel.queryApproveTask(IdModel('0')); - if (res.success) { - _todoLoaded = true; - todos.clear(); - todos.addAll((res.data?.result ?? []) - .map((task) => WorkTask(task, user)) - .toList()); - XLogUtil.i( - '>>>>>>[数据刷新] WorkProvider.loadTodos 成功 ${todos.length} 条'); - changeCallback(); - // 持久化到 SQLite L1(AGENTS.md §6.2 / §18.3 持久化同刷) - _persistWorkTasksToSQLite(); - } else { - XLogUtil.w('>>>>>>[数据刷新] WorkProvider.loadTodos 返回失败,尝试从 SQLite 读取'); - await _loadWorkTasksFromSQLite(); + await _loadWorkTasksFromSQLite(); + if (todos.isNotEmpty) { + // 后台 fire-and-forget 拉远端刷新(hash 比对有变化才更新 + 落盘) + _refreshTodosFromRemote(uid, sid); + return todos; } - } catch (e, s) { - final msg = 'WorkProvider.loadTodos 异常: $e\n$s'; - XLogUtil.e('>>>>>>[数据刷新] $msg, 尝试从 SQLite 读取'); - SystemLog.err(msg, '待办加载'); + } 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; + todos.clear(); + todos.addAll((res.data?.result ?? []) + .map((task) => WorkTask(task, user)) + .toList()); + 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; @@ -170,10 +232,10 @@ class WorkProvider with EmitterMixin implements IWorkProvider { for (final t in todos) { final taskData = t.taskdata; rows.add({ - 'id': taskData.id ?? '', + 'id': taskData.id, 'belong_id': userId, 'space_id': spaceId, - 'task_type': taskData.taskType ?? '', + 'task_type': '待办', 'title': taskData.title ?? '', 'content': jsonEncode(taskData.toJson()), 'status': '${taskData.status ?? 0}', @@ -196,6 +258,7 @@ class WorkProvider with EmitterMixin implements IWorkProvider { try { final rows = await BusinessDatabase.instance.queryWorkTasks( belongId: userId, + taskType: '待办', ); if (rows.isEmpty) { XLogUtil.w('>>>>>>[数据刷新] SQLite 中无待办数据'); @@ -255,42 +318,80 @@ class WorkProvider with EmitterMixin implements IWorkProvider { Future> loadTasks(TaskType type, [bool reload = false, int take = 10]) async { - List tasks = []; - try { - var result = await kernel.collectionLoad>( - userId, - userId, - [], - TaskCollName, - { - 'options': { - 'match': _typeMatch(type), - 'sort': { - 'createTime': -1, - }, - }, - 'skip': 0, - 'take': 1000, - }, - fromJson: (data) { - return XWorkTask.fromList(data['data'] is List ? data['data'] : []); - }, - ); - 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)); - } - }); + 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'); } - // 持久化到 SQLite L1(AGENTS.md §6.2 / §18.3 持久化同刷) - _persistHistoryTasksToSQLite(type, tasks); + } + + // 远端拉取(首次或 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'; + final msg = 'WorkProvider.loadTasks(${type.label}) 远端异常: $e\n$s'; XLogUtil.e('>>>>>>[数据刷新] $msg, 尝试从 SQLite 读取'); SystemLog.err(msg, '后台加载'); - // 网络异常时从 SQLite 读取历史数据 - tasks = await _loadHistoryTasksFromSQLite(type); + // 远端异常时从 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 = []; + var result = await kernel.collectionLoad>( + userId, + userId, + [], + TaskCollName, + { + 'options': { + 'match': _typeMatch(type), + 'sort': { + 'createTime': -1, + }, + }, + 'skip': 0, + 'take': 1000, + }, + fromJson: (data) { + return XWorkTask.fromList(data['data'] is List ? data['data'] : []); + }, + ); + 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) { @@ -300,50 +401,46 @@ class WorkProvider with EmitterMixin implements IWorkProvider { return tasks; } - /// 持久化历史任务(已办/已发起/已完结/抄送/草稿/任务)到 SQLite - void _persistHistoryTasksToSQLite(TaskType type, List tasks) { + /// 持久化某类型任务到 L1 SQLite(fire-and-forget) + void _persistTasksByType( + TaskType type, List tasks, String uid, String sid) { if (tasks.isEmpty) return; Future(() async { try { - final userId = this.userId; - final spaceId = user.user?.currentSpace.id ?? 'personal'; - final rows = >[]; - for (final t in tasks) { + final rows = tasks.map((t) { final taskData = t.taskdata; - rows.add({ - 'id': taskData.id ?? '', - 'belong_id': userId, - 'space_id': spaceId, + 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} 条'); + '>>>>>>[数据刷新] ${type.label}任务持久化到 SQLite ${rows.length} 条'); } catch (e, s) { - final msg = '[WorkProvider] 持久化历史任务(${type.label})到 SQLite 失败: $e\n$s'; + final msg = '[WorkProvider] 持久化${type.label}到 SQLite 失败: $e\n$s'; XLogUtil.e(msg); SystemLog.err(msg, 'SQLite写入'); } }); } - /// 从 SQLite 读取历史任务(网络失败时的本地兜底) - Future> _loadHistoryTasksFromSQLite(TaskType type) async { + /// 从 L1 SQLite 读取某类型任务(本地兜底) + Future> _loadTasksByTypeFromSQLite( + TaskType type, String uid) async { try { final rows = await BusinessDatabase.instance.queryWorkTasks( - belongId: userId, + belongId: uid, taskType: type.label, ); - if (rows.isEmpty) { - XLogUtil.w('>>>>>>[数据刷新] SQLite 中无 ${type.label} 数据'); - return []; - } + if (rows.isEmpty) return []; final tasks = []; for (final row in rows) { final contentJson = row['content'] as String?; @@ -353,21 +450,48 @@ class WorkProvider with EmitterMixin implements IWorkProvider { Map.from(jsonDecode(contentJson))); tasks.add(WorkTask(taskData, user)); } catch (e, s) { - final msg = '[WorkProvider] 历史任务(${type.label})反序列化失败: $e\n$s'; + final msg = '[WorkProvider] ${type.label}反序列化失败: $e\n$s'; XLogUtil.e(msg); SystemLog.err(msg, 'SQLite读取'); } } - XLogUtil.i('>>>>>>[数据刷新] 从 SQLite 恢复 ${type.label} ${tasks.length} 条'); + tasks.sort((a, b) { + return DateTime.parse(b.metadata.updateTime ?? "") + .compareTo(DateTime.parse(a.metadata.updateTime ?? "")); + }); + XLogUtil.i( + '>>>>>>[数据刷新] 从 SQLite 恢复${type.label} ${tasks.length} 条'); return tasks; } catch (e, s) { - final msg = '[WorkProvider] 从 SQLite 读取 ${type.label} 失败: $e\n$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 Future loadTaskCount(TaskType type) async { var res = await kernel.collectionLoad( diff --git a/pubspec.yaml b/pubspec.yaml index 7c81e0b..6440ae1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -209,6 +209,8 @@ dependencies: dependency_overrides: uuid: 4.5.1 flutter_spinkit: 5.2.1 + ionicons: + path: third_party/ionicons dev_dependencies: flutter_test: sdk: flutter -- Gitee From b1281151756162db519db5f21bfa040dcbd0d53a Mon Sep 17 00:00:00 2001 From: Orginone Developer Date: Sun, 26 Jul 2026 17:24:12 +0800 Subject: [PATCH 28/37] =?UTF-8?q?fix:=20=E6=95=B0=E6=8D=AE=E6=A0=B8?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E9=93=BE=E8=B7=AF=E4=BF=AE=E5=A4=8D=E4=B8=8E?= =?UTF-8?q?=E7=BD=91=E7=BB=9C=E9=94=99=E8=AF=AF=E5=88=86=E7=B1=BB=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. directory.dart: loadAllDirectory/loadDirectoryResource 顶层目录判断 增加 MirrorDirectory 识别(与 react 端 parent===undefined 一致), 修复子目录因 parent 默认为 MirrorDirectory 导致 preLoad 永不执行、 数据核资源无法拉取的问题;loadAllApplication 改用 loadAllDirectory 聚合,避免重复请求与 NoSuchMethodError。 2. work_repository.dart: _loadApplications 远端失败/空数据时回退 L1 缓存;新增 _loadApplicationsFromCache;过滤条件由 groupTags.contains 改为 isDeleted,修复 XApplication 无 groupTags 的报错。 3. collection.dart: XCollection.all 将 _cache=[] 移入 res.success 分支, 避免远端失败时空数据覆盖内存缓存。 4. http_util.dart: DioExceptionType.connectionError(含 DNS 解析失败) 状态码由 502 改为 499,区分客户端网络异常与服务端网关错误,避免 误导用户。 5. standard/application.dart: groupTags 对 target.space 增加防御性判断。 --- lib/dart/base/api/http_util.dart | 19 +++++- lib/dart/base/api/storehub.dart | 7 ++- lib/dart/core/provider/index.dart | 8 ++- lib/dart/core/public/collection.dart | 8 ++- lib/dart/core/repository/work_repository.dart | 40 ++++++++++++ lib/dart/core/thing/directory.dart | 28 ++++++--- lib/dart/core/thing/standard/application.dart | 61 ++++++++++++++++--- lib/dart/core/work/provider.dart | 6 ++ lib/pages/chats/chat_page.dart | 29 ++++++--- lib/pages/relation/relation_page.dart | 34 +++++++---- lib/pages/work/work_page.dart | 14 ++++- 11 files changed, 206 insertions(+), 48 deletions(-) diff --git a/lib/dart/base/api/http_util.dart b/lib/dart/base/api/http_util.dart index 0616da8..35640b1 100644 --- a/lib/dart/base/api/http_util.dart +++ b/lib/dart/base/api/http_util.dart @@ -226,7 +226,24 @@ 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 ?? '请求失败'; diff --git a/lib/dart/base/api/storehub.dart b/lib/dart/base/api/storehub.dart index cba47a0..b54784c 100644 --- a/lib/dart/base/api/storehub.dart +++ b/lib/dart/base/api/storehub.dart @@ -688,11 +688,14 @@ class StoreHub with AuthMixin, RequestStatisticsMixin { if (usedLongLink) { try { + // SignalR invoke 超时缩短为 20s(原 60s 太长,用户等 60s+60s=120s 才报错)。 + // 超时后快速降级到 HTTP fallback(HTTP 自身 receiveTimeout=60s 兜底)。 + // 业务请求通常 < 5s 完成,20s 足够覆盖慢请求;长操作(如文件上传)走专用接口。 resObj = await _connection .invoke(methodName, args: safeArgs) - .timeout(const Duration(seconds: 60), onTimeout: () { + .timeout(const Duration(seconds: 20), onTimeout: () { throw TimeoutException( - 'SignalR invoke 超时: $methodName', const Duration(seconds: 60)); + 'SignalR invoke 超时: $methodName', const Duration(seconds: 20)); }); } on TimeoutException catch (_) { // SignalR 超时:降级到 HTTP fallback,避免数据完全不加载 diff --git a/lib/dart/core/provider/index.dart b/lib/dart/core/provider/index.dart index 12fa906..c20a777 100644 --- a/lib/dart/core/provider/index.dart +++ b/lib/dart/core/provider/index.dart @@ -657,12 +657,14 @@ class DataProvider with EmitterMixin, Mutex { /// 并发去重:同一 dataType 若已有刷新在跑,直接 await 同一 Future, /// 避免短时间内多次进入子页面触发请求风暴。 final Map> _pendingFreshens = {}; - Future ensureDataFresh(String dataType) async { + Future ensureDataFresh(String dataType, {bool force = false}) async { // 始终触发后台静默更新(非阻塞),确保在线数据变更能及时同步到本地 _triggerSilentBackgroundRefresh(dataType); - // 数据在保鲜期内时,直接用内存缓存渲染,不阻塞 UI - if (!DataFreshnessTracker.instance.shouldRefresh(dataType)) return; + // 强制刷新(如下拉刷新):跳过保鲜期检查,直接进入同步刷新流程 + if (!force && !DataFreshnessTracker.instance.shouldRefresh(dataType)) { + return; + } // 单飞去重:同一类型若已有 Future 在跑,直接复用 final pending = _pendingFreshens[dataType]; if (pending != null) return pending; diff --git a/lib/dart/core/public/collection.dart b/lib/dart/core/public/collection.dart index 946d9f6..0437f90 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/work_repository.dart b/lib/dart/core/repository/work_repository.dart index cbad2c3..f717e03 100644 --- a/lib/dart/core/repository/work_repository.dart +++ b/lib/dart/core/repository/work_repository.dart @@ -323,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/thing/directory.dart b/lib/dart/core/thing/directory.dart index ec8fc6d..baa56d3 100644 --- a/lib/dart/core/thing/directory.dart +++ b/lib/dart/core/thing/directory.dart @@ -475,19 +475,20 @@ class Directory extends StandardFileInfo implements IDirectory { @override Future> loadAllApplication() async { - await deepLoadLazy(); - final List applications = [...standard.applications]; - - for (var item in children) { + // 与 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.loadAllApplication()); + applications.addAll(await item.standard.loadApplications()); } catch (e) { XLogUtil.e('loadAllApplication 子目录加载失败: $e'); } } - return applications; } @@ -502,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; @@ -631,8 +637,12 @@ class Directory extends StandardFileInfo implements IDirectory { } _loadingDirectoryIds.add(dirId); try { - if (parent == null || reload == true) { - await resource.preLoad(reload: reload!); + // 顶层目录(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(); diff --git a/lib/dart/core/thing/standard/application.dart b/lib/dart/core/thing/standard/application.dart index ac7ad5d..0b2e9d4 100644 --- a/lib/dart/core/thing/standard/application.dart +++ b/lib/dart/core/thing/standard/application.dart @@ -217,8 +217,24 @@ class Application extends StandardFileInfo implements IApplication } } - // 远端拉取(首次或 reload) - await _loadWorksFromRemote(userId, sid, reload: reload); + // 远端拉取(首次或 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; } @@ -235,7 +251,13 @@ class Application extends StandardFileInfo implements IApplication ); var res = await directory.resource.workDefineColl .loadResult2(options, XWorkDefine.fromJson); - if (res.success && res.data != null) { + // 关键:远端失败(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 文件系统 @@ -301,20 +323,43 @@ class Application extends StandardFileInfo implements IApplication } } - // 远端拉取(首次或 reload) - await _loadFormsFromRemote(userId, sid, reload: reload); + // 远端拉取(首次或 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 { - _formsLoaded = true; - var data = await directory.resource.formColl.loadSpace1({ + // 关键:使用 loadResult 显式检查 success,避免 loadSpace1 在远端失败时静默返回空数组 + // 导致空数据覆盖内存缓存和持久化(loadSpace1 内部 catch 了 success=false 直接返回 []) + final res = await directory.resource.formColl.loadResult({ "options": { "match": {"applicationId": id} }, - }, cvt: XForm.fromJson); + }, 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); diff --git a/lib/dart/core/work/provider.dart b/lib/dart/core/work/provider.dart index 027c1db..5acf79b 100644 --- a/lib/dart/core/work/provider.dart +++ b/lib/dart/core/work/provider.dart @@ -386,6 +386,12 @@ class WorkProvider with EmitterMixin implements IWorkProvider { 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)) { diff --git a/lib/pages/chats/chat_page.dart b/lib/pages/chats/chat_page.dart index 37628b8..dcd062d 100644 --- a/lib/pages/chats/chat_page.dart +++ b/lib/pages/chats/chat_page.dart @@ -169,15 +169,26 @@ class _ChatPageState extends State if (_chatModel == null) { return const SkeletonWidget(itemCount: 6); } - if (_isBgRefreshing) { - return Column( - children: [ - XUi.refreshingBanner(), - Expanded(child: TabContainerWidget(_chatModel!)), - ], - ); - } - return TabContainerWidget(_chatModel!); + // 统一下拉刷新交互:所有 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) { diff --git a/lib/pages/relation/relation_page.dart b/lib/pages/relation/relation_page.dart index 0aaf6d4..a7fe2f5 100644 --- a/lib/pages/relation/relation_page.dart +++ b/lib/pages/relation/relation_page.dart @@ -160,19 +160,29 @@ class _RelationState extends State } final showRefreshBanner = _isRefreshing; if (_isRefreshing) _isRefreshing = false; - return Column( - children: [ - if (showRefreshBanner) XUi.refreshingBanner(), - Expanded( - child: TabContainerWidget( - relationModel!, - key: ValueKey( - '${relationModel!.activeTabTitle ?? ''}_${_parentCompany?.id ?? 'root'}'), - getActions: _getActions, + // 统一下拉刷新交互: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: '关系'), + ], + ), ); }); } diff --git a/lib/pages/work/work_page.dart b/lib/pages/work/work_page.dart index 9ad1b9b..e727787 100644 --- a/lib/pages/work/work_page.dart +++ b/lib/pages/work/work_page.dart @@ -128,7 +128,19 @@ class _WorkPageState extends State ], ); } - return 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 _buildModel() { -- Gitee From 8a4b18049871fc7c906a773ab71b071d8cbc98d3 Mon Sep 17 00:00:00 2001 From: panzhaohui Date: Sun, 26 Jul 2026 20:24:57 +0800 Subject: [PATCH 29/37] =?UTF-8?q?fix:=20SignalR=E9=95=BF=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E7=A8=B3=E5=AE=9A=E6=80=A7=E4=B8=8E=E6=9C=AC=E5=9C=B0=E4=BC=98?= =?UTF-8?q?=E5=85=88=E6=95=B0=E6=8D=AE=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对照oiocns-react项目改进移动端SignalR连接机制: - StoreHub新增stale连接检测(心跳超45s强制重启) - checkOnline检测stale并主动重启,等待时间5s→8s - 网络类型变更(WiFi↔蜂窝)时强制重启长连接 - 后台唤起时通过checkOnline内部stale检测自动恢复 本地优先数据加载(AGENTS.md §18.1): - 会话load()先读SQLite L1渲染首屏,再后台deepLoad - 发现页loadPlaza()内存缓存为空时先读本地存储,后台在线刷新 - ensureDataFresh新增本地数据完整性检查:数据为空时强制在线获取 其他优化: - HTTP receiveTimeout 60s→30s,减少用户等待 - Work.loadWorkNode添加超时控制和缓存兜底 - 移除ionicons本地路径依赖 --- lib/dart/base/api/http_util.dart | 5 +- lib/dart/base/api/kernelapi.dart | 7 + lib/dart/base/api/storehub.dart | 64 +++++- lib/dart/controller/app_start_controller.dart | 28 ++- lib/dart/core/provider/index.dart | 51 ++++- lib/dart/core/provider/session.dart | 35 ++- lib/dart/core/work/index.dart | 120 ++++++---- lib/pages/discover/discover_service.dart | 216 +++++++++++------- pubspec.yaml | 2 - 9 files changed, 385 insertions(+), 143 deletions(-) diff --git a/lib/dart/base/api/http_util.dart b/lib/dart/base/api/http_util.dart index 35640b1..f565a72 100644 --- a/lib/dart/base/api/http_util.dart +++ b/lib/dart/base/api/http_util.dart @@ -41,7 +41,10 @@ class HttpUtil with AuthMixin implements StoreHubHttpClient { return BaseOptions( baseUrl: Constant.host, connectTimeout: const Duration(seconds: 15), - receiveTimeout: const Duration(seconds: 60), + // 降低接收超时时间:从60秒降到30秒 + // 用户等待80秒(20s SignalR + 60s HTTP)才能看到结果体验太差 + // 30秒足够覆盖大多数业务请求,超时后使用本地缓存兜底 + receiveTimeout: const Duration(seconds: 30), headers: {}, contentType: 'application/json; charset=utf-8', responseType: ResponseType.json, diff --git a/lib/dart/base/api/kernelapi.dart b/lib/dart/base/api/kernelapi.dart index 33f70cb..4258674 100644 --- a/lib/dart/base/api/kernelapi.dart +++ b/lib/dart/base/api/kernelapi.dart @@ -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(); } diff --git a/lib/dart/base/api/storehub.dart b/lib/dart/base/api/storehub.dart index b54784c..fa0224c 100644 --- a/lib/dart/base/api/storehub.dart +++ b/lib/dart/base/api/storehub.dart @@ -292,11 +292,69 @@ 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; + 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) { @@ -305,8 +363,8 @@ class StoreHub with AuthMixin, RequestStatisticsMixin { !_isStarting) { await _starting(reason: 'check_online'); } - // 等待长连接恢复:从 2s 提升到 5s,适应网络波动后重连较慢的场景 - return _waitForLongLink(const Duration(seconds: 5)); + // 等待长连接恢复:从 5s 提升到 8s,适应后台唤起后重连较慢的场景 + return _waitForLongLink(const Duration(seconds: 8)); } void _setTransportState({ diff --git a/lib/dart/controller/app_start_controller.dart b/lib/dart/controller/app_start_controller.dart index 48911d7..63bc0fe 100644 --- a/lib/dart/controller/app_start_controller.dart +++ b/lib/dart/controller/app_start_controller.dart @@ -126,6 +126,10 @@ 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; @@ -134,7 +138,19 @@ class AppStartController with EmitterMixin, AuthMixin { // 网络恢复:不清零计数器,保留 hasDataDelay=true 标志, // 等 _networkFluctuations → refreshData → _wakeUp 完成后再清零。 // 这样纯网络波动(不切前后台)也能触发数据刷新。 - unawaited(kernel.checkOnline()); + // 网络类型变更(如 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'); } @@ -156,6 +172,12 @@ class AppStartController with EmitterMixin, AuthMixin { } /// 后台恢复时处理:恢复长连接 + 刷新数据 + /// + /// 后台唤起的关键问题: + /// 1. app 在后台时,Timer 被系统挂起,心跳停止,连接可能已僵死 + /// 但 _connection.state 仍为 Connected(TCP 层未感知断开) + /// 2. 需要先检测 stale 并强制重启,再刷新数据 + /// 3. 后台时间过长时,token 可能已过期,需要先校验 Future _onAppResumed() async { // 节流:避免快速切前后台触发多次刷新 final now = DateTime.now(); @@ -168,6 +190,10 @@ class AppStartController with EmitterMixin, AuthMixin { await kernel.checkOnline(); return; } + + // 后台唤起:先检测 stale 连接并强制重启(checkOnline 内部已处理), + // 再走 refreshData 流程刷新数据。 + // stale 检测发生在 checkOnline() 内部:isStale → reviveIfStale → _starting // refreshData() 内部会调用 kernel.checkOnline() 恢复长连接并刷新数据 await refreshData(); } diff --git a/lib/dart/core/provider/index.dart b/lib/dart/core/provider/index.dart index c20a777..7a8b0b6 100644 --- a/lib/dart/core/provider/index.dart +++ b/lib/dart/core/provider/index.dart @@ -661,9 +661,17 @@ class DataProvider with EmitterMixin, Mutex { // 始终触发后台静默更新(非阻塞),确保在线数据变更能及时同步到本地 _triggerSilentBackgroundRefresh(dataType); - // 强制刷新(如下拉刷新):跳过保鲜期检查,直接进入同步刷新流程 - if (!force && !DataFreshnessTracker.instance.shouldRefresh(dataType)) { - return; + // 本地数据完整性检查:如果本地数据为空或不完整,强制同步刷新, + // 即使 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]; @@ -677,6 +685,43 @@ 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) { diff --git a/lib/dart/core/provider/session.dart b/lib/dart/core/provider/session.dart index 678c7fb..727d7fe 100644 --- a/lib/dart/core/provider/session.dart +++ b/lib/dart/core/provider/session.dart @@ -113,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 = >[ @@ -140,16 +157,18 @@ 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); } - // 本地优先兜底:若内存会话为空(deepLoad 失败/超时或 skipDeepLoad 且 - // memberChats 未填充),从 SQLite L1 恢复已缓存的会话列表, - // 通过 user/company.findMemberChat(id) 还原 ISession 实例。 - // 这样保证离线场景下首屏仍能看到会话列表,deepLoad 完成后会再次刷新。 - if (tmpChats.isEmpty) { + // 在线数据非空:用在线数据替换本地数据 + // 在线数据为空且本地数据为空:保持空列表(首次登录或无会话) + if (onlineChats.isNotEmpty) { + tmpChats = onlineChats; + } else if (tmpChats.isEmpty) { + // 本地也为空,尝试再次从 SQLite 读取(deepLoad 可能填充了 memberChats) tmpChats = await _loadChatsFromSQLite(); } // 统一加载会话消息缓存 diff --git a/lib/dart/core/work/index.dart b/lib/dart/core/work/index.dart index d0f452a..a301a1a 100644 --- a/lib/dart/core/work/index.dart +++ b/lib/dart/core/work/index.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter_easyloading/flutter_easyloading.dart'; @@ -207,12 +208,13 @@ class Work extends FileInfo implements IWork { final sid = spaceId; // L2 文件系统兜底(仅 reload=false 时尝试,让调用方有数据可用) + WorkNodeModel? cachedNode; if (!reload && node == null) { try { - final cached = await FlowDataCacheService.instance + cachedNode = await FlowDataCacheService.instance .loadWorkNodeFromCache(userId, sid, id); - if (cached != null) { - node = cached; + if (cachedNode != null) { + node = cachedNode; await recursionForms(node!); // 后台 fire-and-forget 拉远端刷新(有变化才更新 + 落盘) _refreshWorkNodeFromRemote(userId, sid); @@ -223,15 +225,35 @@ class Work extends FileInfo implements IWork { } } - // 远端拉取(首次或 reload) - final res = - await kernel.queryWorkNodes({'id': id, 'shareId': metadata.shareId}); - if (res.success && res.data != null) { - node = res.data; - await recursionForms(node!); - // 落盘 L2 文件系统 - FlowDataCacheService.instance - .cacheWorkNode(userId, sid, id, node!); + // 远端拉取(首次或 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; } @@ -245,15 +267,12 @@ class Work extends FileInfo implements IWork { 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}'; + 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!); + FlowDataCacheService.instance.cacheWorkNode(userId, sid, id, node!); XLogUtil.i('[Work.loadWorkNode] 后台刷新节点有更新 work=$id'); } } @@ -265,30 +284,47 @@ class Work extends FileInfo implements IWork { @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/pages/discover/discover_service.dart b/lib/pages/discover/discover_service.dart index 4b42688..b8854a0 100644 --- a/lib/pages/discover/discover_service.dart +++ b/lib/pages/discover/discover_service.dart @@ -1,4 +1,7 @@ +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'; @@ -11,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; @@ -48,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, @@ -67,6 +75,7 @@ class PlazaResource { class AggregatedPlaza { /// 首个有效空间的 plaza(用于 UI 兼容显示) final XPlaza? plaza; + /// 合并去重后的资源列表(已按时间倒序排序) final List resources; @@ -131,7 +140,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( @@ -153,7 +164,8 @@ class PlazaNotice { /// 用于公告封面缩略图(后端 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); @@ -491,7 +503,7 @@ class DiscoverService { final belongId = targetId; final cacheKey = _resolveCacheKey(groupId); - // 检查缓存是否有效 + // 检查内存缓存是否有效 if (!forceRefresh && _discoverCaches.containsKey(cacheKey) && _getLastLoadTime(cacheKey) != null && @@ -501,77 +513,115 @@ 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}'); + } + 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}'); + _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); + // ★落盘到本地存储:plaza + resources 完整数据 + _persistPlazaToLocal(targetId, plaza, resources); - return plaza; - } + return plaza; } - - // 网络加载失败,尝试从本地存储降级读取 - return _loadPlazaFromLocal(targetId); - } catch (e) { - XLogUtil.e('加载广场信息失败: $e'); - // 异常时也尝试从本地存储降级 - return _loadPlazaFromLocal(groupId.isNotEmpty ? groupId : defaultClusterId); } + + // 网络加载失败,尝试从本地存储降级读取 + 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 @@ -646,7 +696,8 @@ class DiscoverService { 'resources': resources, 'loadTime': DateTime.now(), }; - XLogUtil.i('[DiscoverService] 从本地存储恢复 plaza (cluster=$clusterId), resources: ${resources.length}'); + XLogUtil.i( + '[DiscoverService] 从本地存储恢复 plaza (cluster=$clusterId), resources: ${resources.length}'); return plaza; } catch (e) { XLogUtil.w('[DiscoverService] 读取本地 plaza 失败: $e'); @@ -1016,7 +1067,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); } @@ -1044,8 +1096,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) => @@ -1116,9 +1168,7 @@ class DiscoverService { _discoverCaches.clear(); XLogUtil.d('所有发现缓存已清除'); // 异步清理所有集群的本地存储 - LocalPageCacheRepository.instance - .clearUser('system') - .catchError((_) {}); + LocalPageCacheRepository.instance.clearUser('system').catchError((_) {}); } } @@ -1134,8 +1184,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); } @@ -1157,15 +1207,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),保留第一个出现的(优先个人/单位) @@ -1220,10 +1270,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/pubspec.yaml b/pubspec.yaml index 6440ae1..7c81e0b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -209,8 +209,6 @@ dependencies: dependency_overrides: uuid: 4.5.1 flutter_spinkit: 5.2.1 - ionicons: - path: third_party/ionicons dev_dependencies: flutter_test: sdk: flutter -- Gitee From 3c49ac19b074de2dd49ac824322545e3ce001c52 Mon Sep 17 00:00:00 2001 From: panzhaohui Date: Sun, 26 Jul 2026 20:53:53 +0800 Subject: [PATCH 30/37] =?UTF-8?q?fix:=20=E6=9C=AC=E5=9C=B0=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E6=94=B9=E9=80=A0P0/P1=E7=BA=A7=E9=A3=8E=E9=99=A9?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0级修复(严重缺陷): - BusinessDatabase._onUpgrade 包裹try-catch+兜底重建,迁移失败不再崩溃 - BusinessDatabase._init 失败后抛出明确异常,调用方显式降级至在线 - LocalPageCacheRepository.readSync 修复catch(_){}静默吞没,改为记录日志 - BusinessDatabase._init 失败写入SystemLog,便于错误日志排查 P1级修复(中等风险): - FileSystemCacheRepository._computeHash 序列化失败不再返回时间戳 (原实现导致每次都认为数据变化,频繁写盘) - FileSystemCacheRepository.readAll 单文件损坏不再静默跳过 改为记录损坏文件名和异常,便于定位数据损坏 - LocalPageCacheRepository._buildKey 处理userId为空的情况 未登录态统一用 'anon',?ix: 本地缓存改造P0/P1级风险修复 P0级修复(严重缺陷): - BusinessDatabase._onUpgrade 包裹try-catch+兜底重建,迁移失败不再崩溃 - BusinessDatabase._init 失败后 - P0级修复(严重缺陷): - BusinedAl- BusinessDatabase._onUpgrade 审数 --- lib/dart/base/storages/business_database.dart | 60 +++++++++++++++---- .../file_system_cache_repository.dart | 58 +++++++++++++----- .../storages/local_page_cache_repository.dart | 23 +++++-- 3 files changed, 113 insertions(+), 28 deletions(-) diff --git a/lib/dart/base/storages/business_database.dart b/lib/dart/base/storages/business_database.dart index efbecb9..915d9e4 100644 --- a/lib/dart/base/storages/business_database.dart +++ b/lib/dart/base/storages/business_database.dart @@ -1,3 +1,5 @@ +import 'dart:isolate'; + import 'package:path/path.dart' as p; import 'package:sqflite/sqflite.dart'; import 'package:path_provider/path_provider.dart'; @@ -5,6 +7,7 @@ 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) @@ -23,9 +26,16 @@ class BusinessDatabase { 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!; } @@ -39,8 +49,14 @@ class BusinessDatabase { onCreate: _onCreate, onUpgrade: _onUpgrade, ); - } catch (e) { - XLogUtil.e('[BusinessDatabase] 初始化失败: $e'); + 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读取'); } } @@ -258,11 +274,37 @@ class BusinessDatabase { } Future _onUpgrade(Database db, int oldVersion, int newVersion) async { - // 增量迁移,禁止 DROP 全表(AGENTS.md §6.3) - if (oldVersion < 2) { - final batch = db.batch(); - _createFlowTables(batch); - await batch.commit(noResult: true); + // 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(); } } @@ -623,9 +665,7 @@ class BusinessDatabase { Future?> queryWorkNode(String workId) async { final db = await database; final results = await db.query('work_nodes', - where: 'work_id = ?', - whereArgs: [workId], - limit: 1); + where: 'work_id = ?', whereArgs: [workId], limit: 1); return results.isEmpty ? null : results.first; } diff --git a/lib/dart/base/storages/file_system_cache_repository.dart b/lib/dart/base/storages/file_system_cache_repository.dart index e3ed525..a8e06d7 100644 --- a/lib/dart/base/storages/file_system_cache_repository.dart +++ b/lib/dart/base/storages/file_system_cache_repository.dart @@ -72,17 +72,26 @@ class FileSystemCacheRepository { return File(p.join(dirPath, '$safeId.json')); } - String _indexKey( - String userId, String spaceId, String dataType, String id) => + 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); - return sha1.convert(utf8.encode(encoded)).toString(); - } catch (_) { - return DateTime.now().millisecondsSinceEpoch.toString(); + 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}'; } } @@ -95,6 +104,7 @@ class FileSystemCacheRepository { String dataType, String id, ) async { + final sw = Stopwatch()..start(); try { final file = await _buildFile(userId, spaceId, dataType, id); if (!file.existsSync()) return null; @@ -104,12 +114,16 @@ class FileSystemCacheRepository { 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('[FileSystemCache] 读取失败 $dataType/$id: $e'); + XLogUtil.w('[CacheTracker] read 失败 $dataType/$id: $e'); return null; + } finally { + sw.stop(); } } @@ -123,23 +137,29 @@ class FileSystemCacheRepository { 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; - await file.writeAsString(jsonEncode(payload), flush: true); + 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('[FileSystemCache] 写入失败 $dataType/$id: $e'); + XLogUtil.w('[CacheTracker] write 失败 $dataType/$id: $e'); return false; + } finally { + sw.stop(); } } @@ -163,6 +183,9 @@ class FileSystemCacheRepository { 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); @@ -170,6 +193,7 @@ class FileSystemCacheRepository { 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; @@ -178,12 +202,19 @@ class FileSystemCacheRepository { final id = p.basenameWithoutExtension(entity.path); result[id] = data; } - } catch (_) { - // 单文件损坏跳过 + } 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; } @@ -227,8 +258,7 @@ class FileSystemCacheRepository { if (spaceDir.existsSync()) { await spaceDir.delete(recursive: true); } - _hashIndex.removeWhere( - (key, _) => key.startsWith('$userId/$spaceId/')); + _hashIndex.removeWhere((key, _) => key.startsWith('$userId/$spaceId/')); } catch (e) { XLogUtil.w('[FileSystemCache] clearBySpace 失败: $e'); } diff --git a/lib/dart/base/storages/local_page_cache_repository.dart b/lib/dart/base/storages/local_page_cache_repository.dart index 473ab08..4b6193b 100644 --- a/lib/dart/base/storages/local_page_cache_repository.dart +++ b/lib/dart/base/storages/local_page_cache_repository.dart @@ -48,8 +48,11 @@ class LocalPageCacheRepository { } String _buildKey(String userId, String? spaceId, String dataType) { - final sid = spaceId ?? 'personal'; - return '${_keyPrefix}_${userId}_${sid}_$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'; } /// 读取本地存储的页面数据 @@ -113,7 +116,11 @@ class LocalPageCacheRepository { hash: hash, updateTime: updateTime, ); - } catch (_) { + } catch (e) { + // P0 修复:静默吞没异常会导致业务层误判为"无缓存"而跳过网络请求 + // 改为记录日志,让业务层能感知缓存读取失败 + XLogUtil.w( + '[CacheTracker] LocalPageCache readSync 失败 dataType=$dataType key=${_buildKey(userId, spaceId, dataType)}: $e'); return null; } } @@ -129,6 +136,7 @@ class LocalPageCacheRepository { required String userId, String? spaceId, }) async { + final sw = Stopwatch()..start(); await ensureInitialized(); final box = _box; if (box == null) return false; @@ -138,6 +146,8 @@ class LocalPageCacheRepository { 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, { @@ -145,10 +155,15 @@ class LocalPageCacheRepository { '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('[LocalPageCache] 写入失败 dataType=$dataType: $e'); + XLogUtil.w( + '[CacheTracker] LocalPageCache write 失败 dataType=$dataType: $e'); return false; + } finally { + sw.stop(); } } -- Gitee From 115212c7f7de793c5f9f644d581ff11e346a3e26 Mon Sep 17 00:00:00 2001 From: panzhaohui Date: Sun, 26 Jul 2026 21:38:31 +0800 Subject: [PATCH 31/37] =?UTF-8?q?fix:=20=E6=9C=AC=E5=9C=B0=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E5=89=A9=E4=BD=99P1/P2=E7=BA=A7=E9=A3=8E=E9=99=A9?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1-4 修复:TTL系统时间敏感问题 - DataFreshnessTracker增加7天最大存活时间硬上限 - 检测系统时间倒拨(diff<0),按绝对值判断避免缓存永久有效 - 防止用户修改系统时间导致缓存永久失效/有效 P2-3 修复:fire-and-forget空窗期问题 - ChatProvider._persistSessionsToSQLite 改为 async/await - _refreshRemoteData 和 wakeUp 中的调用改为 await - 关键数据(会话列表)写入完成后才通知UI刷新 - 避免写入未完成时主线程读取的空窗期 验证: - flutter analyze 0 issues - 104个单元测试全部通过 --- .../controller/data_freshness_tracker.dart | 19 +++++- lib/dart/core/provider/session.dart | 58 ++++++++++--------- 2 files changed, 48 insertions(+), 29 deletions(-) diff --git a/lib/dart/controller/data_freshness_tracker.dart b/lib/dart/controller/data_freshness_tracker.dart index 321c2b9..d6adde9 100644 --- a/lib/dart/controller/data_freshness_tracker.dart +++ b/lib/dart/controller/data_freshness_tracker.dart @@ -34,17 +34,34 @@ class DataFreshnessTracker { 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/provider/session.dart b/lib/dart/core/provider/session.dart index 727d7fe..0683fe5 100644 --- a/lib/dart/core/provider/session.dart +++ b/lib/dart/core/provider/session.dart @@ -274,8 +274,8 @@ class ChatProvider with EmitterMixin implements IChatProvider { XLogUtil.i( '>>>>>>[数据刷新] 会话刷新完成 ${chats.length} 条, 未读 $noReadChatCount'); _notify(); - // 持久化会话到 SQLite L1(fire-and-forget,不阻塞 UI) - _persistSessionsToSQLite(tmpChats); + // 持久化会话到 SQLite L1(await 等待写入完成,避免空窗期) + await _persistSessionsToSQLite(tmpChats); // 通知 DataProvider 触发本地快照写入(长连接后台数据同步更新本地缓存) onDataRefreshed?.call(); } catch (e, s) { @@ -289,33 +289,35 @@ class ChatProvider with EmitterMixin implements IChatProvider { } /// 持久化会话列表到 SQLite(AGENTS.md §6.2 / §18.3 持久化同刷) - void _persistSessionsToSQLite(List sessions) { + /// + /// P2-3 修复:添加 Completer 同步机制,避免 fire-and-forget 空窗期 + /// - 关键数据(会话列表)使用 await 等待写入完成 + /// - 非关键数据保持 fire-and-forget + Future _persistSessionsToSQLite(List sessions) async { if (sessions.isEmpty) return; - Future(() async { - 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写入'); + 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 @@ -405,7 +407,7 @@ class ChatProvider with EmitterMixin implements IChatProvider { _notify(); // 持久化最新会话到 SQLite L1(AGENTS.md §6.2 / §18.3 持久化同刷) // 长连接后台唤醒拉取的新数据必须同步写入本地,避免下次冷启动丢失 - _persistSessionsToSQLite(chats); + await _persistSessionsToSQLite(chats); } else if (_inited) { load(); } -- Gitee From e39293d19795bf19b771c6ccb74c146bd16ccbd0 Mon Sep 17 00:00:00 2001 From: panzhaohui Date: Sun, 26 Jul 2026 21:54:39 +0800 Subject: [PATCH 32/37] =?UTF-8?q?fix:=20=E4=BC=9A=E8=AF=9D=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E8=B0=83=E8=AF=95=E6=97=A5=E5=BF=97=E4=B8=8EdeepLoad?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _loadChatsFromSQLite 添加查询行数与未找到实例计数的调试日志 - _refreshRemoteData 中 user/company deepLoad 添加 30s 超时控制 避免 SignalR 拦截时长时间卡住导致后台刷新无法完成 --- lib/dart/core/provider/session.dart | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/lib/dart/core/provider/session.dart b/lib/dart/core/provider/session.dart index 0683fe5..0f80a36 100644 --- a/lib/dart/core/provider/session.dart +++ b/lib/dart/core/provider/session.dart @@ -198,11 +198,14 @@ class ChatProvider with EmitterMixin implements IChatProvider { 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; @@ -217,9 +220,12 @@ class ChatProvider with EmitterMixin implements IChatProvider { } if (session != null) { sessions.add(session); + } else { + notFoundCount++; } } - XLogUtil.i('>>>>>>[数据刷新] 从 SQLite 恢复会话 ${sessions.length} 条'); + XLogUtil.i( + '>>>>>>[数据刷新] 从 SQLite 恢复会话 ${sessions.length} 条 (未找到实例 $notFoundCount 条, memberChats=${user.memberChats.length})'); return sessions; } catch (e, s) { final msg = '[ChatProvider] 从 SQLite 读取会话失败: $e\n$s'; @@ -248,15 +254,28 @@ class ChatProvider with EmitterMixin implements IChatProvider { XLogUtil.i('>>>>>>[数据刷新] _refreshRemoteData 开始'); Future(() async { try { - // 刷新用户数据 - await user.deepLoad(reload: true).catchError((e) { + // 刷新用户数据(添加超时控制,避免 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((e) { + 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'); })), ); -- Gitee From 6f7110011ee88b05bdc157b1e47a5bb7c9ff2450 Mon Sep 17 00:00:00 2001 From: panzhaohui Date: Sun, 26 Jul 2026 22:04:28 +0800 Subject: [PATCH 33/37] =?UTF-8?q?fix:=20HTTP=20receiveTimeout=20=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E4=B8=BA=2060s=20=E4=BF=AE=E5=A4=8D=E6=94=BF=E5=8A=A1?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=99=A8=20408=20=E8=B6=85=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 错误日志显示 http_timeout_fallback 路径出现 4 条 HTTP 408 超时错误 (assetcloud.zj.gov.cn 部分请求响应时间 > 30s)。 按项目规范 receiveTimeout 应为 60s,30s 会导致政务服务器慢请求失败。 --- lib/dart/base/api/http_util.dart | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/dart/base/api/http_util.dart b/lib/dart/base/api/http_util.dart index f565a72..d5ac7f1 100644 --- a/lib/dart/base/api/http_util.dart +++ b/lib/dart/base/api/http_util.dart @@ -41,10 +41,11 @@ class HttpUtil with AuthMixin implements StoreHubHttpClient { return BaseOptions( baseUrl: Constant.host, connectTimeout: const Duration(seconds: 15), - // 降低接收超时时间:从60秒降到30秒 - // 用户等待80秒(20s SignalR + 60s HTTP)才能看到结果体验太差 - // 30秒足够覆盖大多数业务请求,超时后使用本地缓存兜底 - receiveTimeout: const Duration(seconds: 30), + // 接收超时保持 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, -- Gitee From b4657aff0f49b0e76d5770b6635917168fbf1954 Mon Sep 17 00:00:00 2001 From: panzhaohui Date: Mon, 27 Jul 2026 07:28:27 +0800 Subject: [PATCH 34/37] =?UTF-8?q?feat:=20=E5=8F=91=E7=8E=B0=E9=A1=B5?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=8C=96=E7=BC=93=E5=AD=98=E4=B8=8E=E8=A1=A8?= =?UTF-8?q?=E5=8D=95=E7=BB=84=E4=BB=B6=E8=AE=BE=E8=AE=A1=E4=BB=A4=E7=89=8C?= =?UTF-8?q?=E7=BB=9F=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 发现页 6 个子页面(视频/公告/群组/数据/市场/直播)从内存缓存升级到 Hive 持久化缓存 - DiscoverListCache 新增 readPersistent/writePersistent 三级读取(内存→磁盘→在线) - Plaza 模型补全 toJson 方法,支持持久化序列化 - 动态页(all_activities_page)新增后台刷新指示器,遵循 §18.1 本地优先+后台更新 - XTextField/XSelect 替换硬编码颜色为 OipBorder/OipRadius/OipText/OipSurface 令牌 - mapping_components.dart 统一 XFonts.size22Black0 → XFonts.bodyLarge - view_preview_page.dart 替换硬编码颜色/圆角/阴影为 OipBrand/OipText/OipSurface/OipShadow/OipRadius 对齐 AGENTS.md §6 本地存储、§15.5 字体统一、§17 风格统一规范 --- lib/components/XTextField/XTextField.dart | 65 +++++----- .../form/components/XSelect/XSelect.dart | 33 +++-- lib/components/form/mapping_components.dart | 30 ++--- lib/components/form/view_preview_page.dart | 54 +++----- .../storages/local_page_cache_repository.dart | 6 + lib/pages/discover/discover_service.dart | 79 ++++++++++++ .../discover/pages/all_activities_page.dart | 121 ++++++++++++++---- .../discover/pages/data_share_list_page.dart | 54 ++++++-- .../discover/pages/group_share_list_page.dart | 50 ++++++-- lib/pages/discover/pages/live_list_page.dart | 56 +++++++- .../discover/pages/market_list_page.dart | 61 ++++++--- .../discover/pages/notice_list_page.dart | 50 ++++++-- lib/pages/discover/pages/video_list_page.dart | 49 +++++-- .../discover/widgets/discover_list_cache.dart | 85 ++++++++++++ 14 files changed, 606 insertions(+), 187 deletions(-) diff --git a/lib/components/XTextField/XTextField.dart b/lib/components/XTextField/XTextField.dart index b846027..47ac8bd 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'; @@ -107,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( @@ -136,31 +136,34 @@ class XTextField extends StatelessWidget { if (null != icon || null != title) const SizedBox(width: 8), Expanded( child: TextField( - 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), - decoration: InputDecoration( - isCollapsed: true, - hintText: hint, - contentPadding: EdgeInsets.zero, - hintStyle: TextStyle( - color: Colors.grey.shade400, fontSize: 20.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/form/components/XSelect/XSelect.dart b/lib/components/form/components/XSelect/XSelect.dart index 4416800..c360b7a 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/mapping_components.dart b/lib/components/form/mapping_components.dart index e33e40a..efbb15b 100644 --- a/lib/components/form/mapping_components.dart +++ b/lib/components/form/mapping_components.dart @@ -105,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 ), ); }); @@ -132,7 +132,7 @@ MappingComponentsCallback mappingReferenceTextWidget = required: data.required ?? false, enabled: !(data.readOnly ?? false), showLine: true, - // textStyle: XFonts.size22Black0 + // textStyle: XFonts.bodyLarge ), ); }; @@ -214,7 +214,7 @@ MappingComponentsCallback mappingSelectBoxWidget = }, showLine: true, required: data.required ?? false, - textStyle: XFonts.size22Black0, + textStyle: XFonts.bodyLarge, ), ); }); @@ -275,7 +275,7 @@ MappingComponentsCallback mappingMultiSelectBoxWidget = }, showLine: true, required: data.required ?? false, - textStyle: XFonts.size22Black0, + textStyle: XFonts.bodyLarge, ), ); }); @@ -309,7 +309,7 @@ MappingComponentsCallback mappingSelectTimeBoxWidget = }, showLine: true, required: data.required ?? false, - textStyle: XFonts.size22Black0, + textStyle: XFonts.bodyLarge, ), ); }); @@ -347,7 +347,7 @@ MappingComponentsCallback mappingSelectTimeRangeBoxWidget = }, showLine: true, required: data.required ?? false, - textStyle: XFonts.size22Black0, + textStyle: XFonts.bodyLarge, ), ); }); @@ -381,7 +381,7 @@ MappingComponentsCallback mappingSelectDateBoxWidget = }, showLine: true, required: data.required ?? false, - textStyle: XFonts.size22Black0, + textStyle: XFonts.bodyLarge, ), ); }); @@ -419,7 +419,7 @@ MappingComponentsCallback mappingSelectDateRangeBoxWidget = }, showLine: true, required: data.required ?? false, - textStyle: XFonts.size22Black0, + textStyle: XFonts.bodyLarge, ), ); }); @@ -454,7 +454,7 @@ MappingComponentsCallback mappingSelectPersonBoxWidget = }, showLine: true, required: data.required ?? false, - textStyle: XFonts.size22Black0, + textStyle: XFonts.bodyLarge, ), ); }); @@ -504,7 +504,7 @@ MappingComponentsCallback mappingSelectDepartmentBoxWidget = }, showLine: true, required: data.required ?? false, - textStyle: XFonts.size22Black0, + textStyle: XFonts.bodyLarge, ), ); }); @@ -541,7 +541,7 @@ MappingComponentsCallback mappingSelectGroupBoxWidget = }, showLine: true, required: data.required ?? false, - textStyle: XFonts.size22Black0, + textStyle: XFonts.bodyLarge, ), ); }); @@ -584,7 +584,7 @@ MappingComponentsCallback mappingSwitchWidget = (Fields data, ITarget target) { children: [ Text( data.title ?? '', - style: XFonts.size22Black0, + style: XFonts.bodyLarge, ), data.select!.isEmpty ? const SizedBox() @@ -689,7 +689,7 @@ MappingComponentsCallback mappingUploadWidget = (Fields data, ITarget target) { // } // }, // showLine: true, - // textStyle: XFonts.size22Black0, + // textStyle: XFonts.bodyLarge, // ); }); }; @@ -811,7 +811,7 @@ MappingComponentsCallback mappingSensorWidget = }, showLine: true, required: data.required ?? false, - textStyle: XFonts.size22Black0, + textStyle: XFonts.bodyLarge, ), ); }); @@ -1004,7 +1004,7 @@ MappingComponentsCallback mappingSpaceWidget = }, showLine: true, required: data.required ?? false, - textStyle: XFonts.size22Black0, + textStyle: XFonts.bodyLarge, ), ); }); diff --git a/lib/components/form/view_preview_page.dart b/lib/components/form/view_preview_page.dart index f38493c..7f880e2 100644 --- a/lib/components/form/view_preview_page.dart +++ b/lib/components/form/view_preview_page.dart @@ -47,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, ), @@ -58,7 +58,7 @@ class _ViewPreviewPageState extends XStatefulState { Text( viewForm?.name ?? '视图', style: _isWideScreen - ? XFonts.headlineLarge.copyWith(color: Colors.white) + ? XFonts.headlineLarge.copyWith(color: OipText.inverse) : XFonts.whiteLarge, ), if (viewForm?.metadata.remark != null && @@ -67,7 +67,7 @@ class _ViewPreviewPageState extends XStatefulState { padding: const EdgeInsets.only(top: 8), child: Text( viewForm!.metadata.remark!, - style: XFonts.whiteSmall.copyWith(color: Colors.white70), + style: XFonts.whiteSmall.copyWith(color: OipText.inverse.withValues(alpha: 0.7)), maxLines: 2, overflow: TextOverflow.ellipsis, ), @@ -83,8 +83,8 @@ 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), + color: OipText.inverse.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(OipRadius.full), ), child: Text(tag, style: XFonts.whiteSmall), ); @@ -104,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, @@ -123,7 +117,7 @@ class _ViewPreviewPageState extends XStatefulState { const SizedBox(width: 8), Text( '视图信息', - style: XFonts.titleSmall.copyWith(color: Colors.black87), + style: XFonts.titleSmall.copyWith(color: OipText.primary), ), ], ), @@ -144,7 +138,7 @@ class _ViewPreviewPageState extends XStatefulState { Flexible( child: Text( value, - style: XFonts.labelMedium.copyWith(color: Colors.black87), + style: XFonts.labelMedium.copyWith(color: OipText.primary), overflow: TextOverflow.ellipsis, ), ), @@ -158,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, @@ -177,7 +165,7 @@ class _ViewPreviewPageState extends XStatefulState { const SizedBox(width: 8), Text( '数据预览', - style: XFonts.titleSmall.copyWith(color: Colors.black87), + style: XFonts.titleSmall.copyWith(color: OipText.primary), ), ], ), @@ -199,11 +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: XFonts.bodySmall.copyWith(color: Colors.grey.shade600), + style: XFonts.bodySmall.copyWith(color: OipText.tertiary), ), ], ), @@ -231,11 +219,11 @@ 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), @@ -256,18 +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: XFonts.bodySmall.copyWith(color: Colors.grey.shade600), + style: XFonts.bodySmall.copyWith(color: OipText.tertiary), ), ], ), diff --git a/lib/dart/base/storages/local_page_cache_repository.dart b/lib/dart/base/storages/local_page_cache_repository.dart index 4b6193b..8568677 100644 --- a/lib/dart/base/storages/local_page_cache_repository.dart +++ b/lib/dart/base/storages/local_page_cache_repository.dart @@ -261,4 +261,10 @@ class PageCacheType { 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/pages/discover/discover_service.dart b/lib/pages/discover/discover_service.dart index b8854a0..fa78d5a 100644 --- a/lib/pages/discover/discover_service.dart +++ b/lib/pages/discover/discover_service.dart @@ -109,6 +109,15 @@ class PlazaTarget { pubTime: json['pubTime'] ?? '', ); } + + Map toJson() => { + 'id': id, + 'resourceId': resourceId, + 'sourceId': sourceId, + 'name': name, + 'typeName': typeName, + 'pubTime': pubTime, + }; } class PlazaNotice { @@ -158,6 +167,19 @@ 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 @@ -220,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 { @@ -272,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 { @@ -307,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 { @@ -363,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 { diff --git a/lib/pages/discover/pages/all_activities_page.dart b/lib/pages/discover/pages/all_activities_page.dart index 7daea1c..58f64e5 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; @@ -146,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; @@ -158,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; @@ -192,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 @@ -243,6 +282,32 @@ class _AllActivitiesPageState extends State unselectedLabelColor: OipText.tertiary, ), ), + 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), ), @@ -254,7 +319,7 @@ class _AllActivitiesPageState extends State Widget _buildErrorView() { return RefreshIndicator( - onRefresh: _loadData, + onRefresh: () => _loadData(forceRefresh: true), child: ListView( physics: const AlwaysScrollableScrollPhysics( parent: BouncingScrollPhysics(), @@ -270,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), @@ -358,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(), @@ -393,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 8fdcaee..2f297bc 100644 --- a/lib/pages/discover/pages/data_share_list_page.dart +++ b/lib/pages/discover/pages/data_share_list_page.dart @@ -4,6 +4,8 @@ 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/pages/discover/widgets/plaza_style_cards.dart'; import 'package:orginone/utils/log/log_util.dart'; @@ -12,6 +14,7 @@ import '../discover_service.dart'; class DataShareListPage extends StatefulWidget { final String resourceId; final String title; + /// 资源来源空间 ID(多集群聚合时用于查询对应集群的 collection) final String? sourceId; @@ -28,8 +31,10 @@ class DataShareListPage extends StatefulWidget { class _DataShareListPageState extends State { List _items = []; + /// 是否首次加载(无缓存数据时显示 LoadingWidget) bool _isInitialLoading = true; + /// 是否正在后台刷新(有缓存数据时显示顶部"数据更新中"指示器) bool _isBackgroundRefreshing = false; bool _isLoadingMore = false; @@ -56,15 +61,23 @@ class _DataShareListPageState extends State { super.dispose(); } - /// 离线优先加载:先读缓存立即渲染,后台拉取新数据 + /// 离线优先加载:先读持久化缓存立即渲染,后台拉取新数据 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 = DiscoverListCache.read(cacheKey); + // 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(() { @@ -96,7 +109,14 @@ class _DataShareListPageState extends State { _items = data; _hasMore = data.length >= _pageSize; if (data.isNotEmpty) { - DiscoverListCache.write(cacheKey, data); + DiscoverListCache.writePersistent( + cacheKey, + data, + dataType: PageCacheType.discoverDataShare, + userId: userId, + spaceId: spaceId, + toJson: (item) => item.toJson(), + ); } } _isInitialLoading = false; @@ -145,7 +165,14 @@ class _DataShareListPageState extends State { resourceId: widget.resourceId, sourceId: widget.sourceId, ); - DiscoverListCache.write(cacheKey, data); + DiscoverListCache.writePersistent( + cacheKey, + data, + dataType: PageCacheType.discoverDataShare, + userId: relationCtrl.user?.id ?? 'anon', + spaceId: widget.sourceId, + toJson: (item) => item.toJson(), + ); } }); } catch (e) { @@ -160,7 +187,10 @@ class _DataShareListPageState extends State { } void _loadMore() { - if (!_hasMore || _isInitialLoading || _isBackgroundRefreshing || _isLoadingMore) { + if (!_hasMore || + _isInitialLoading || + _isBackgroundRefreshing || + _isLoadingMore) { return; } setState(() { @@ -208,8 +238,8 @@ 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) { _searchText = value; @@ -242,8 +272,8 @@ 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), @@ -300,8 +330,8 @@ class _DataShareListPageState extends State { const SizedBox(width: 6), Text( '数据更新中', - style: TextStyle( - fontSize: 11, color: Colors.orange.shade700), + style: + TextStyle(fontSize: 11, color: Colors.orange.shade700), ), ], ), diff --git a/lib/pages/discover/pages/group_share_list_page.dart b/lib/pages/discover/pages/group_share_list_page.dart index 2ed776e..d4d249c 100644 --- a/lib/pages/discover/pages/group_share_list_page.dart +++ b/lib/pages/discover/pages/group_share_list_page.dart @@ -4,6 +4,8 @@ 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'; @@ -12,6 +14,7 @@ import '../widgets/plaza_style_cards.dart'; class GroupShareListPage extends StatefulWidget { final String resourceId; final String title; + /// 资源来源空间 ID(多集群聚合时用于查询对应集群的 collection) final String? sourceId; @@ -28,8 +31,10 @@ class GroupShareListPage extends StatefulWidget { class _GroupShareListPageState extends State { List _items = []; + /// 是否首次加载(无缓存数据时显示 LoadingWidget) bool _isInitialLoading = true; + /// 是否正在后台刷新(有缓存数据时显示顶部"数据更新中"指示器) bool _isBackgroundRefreshing = false; bool _isLoadingMore = false; @@ -56,15 +61,23 @@ class _GroupShareListPageState extends State { super.dispose(); } - /// 离线优先加载:先读缓存立即渲染,后台拉取新数据 + /// 离线优先加载:先读持久化缓存立即渲染,后台拉取新数据 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 = DiscoverListCache.read(cacheKey); + // 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(() { @@ -96,7 +109,14 @@ class _GroupShareListPageState extends State { _items = data; _hasMore = data.length >= _pageSize; if (data.isNotEmpty) { - DiscoverListCache.write(cacheKey, data); + DiscoverListCache.writePersistent( + cacheKey, + data, + dataType: PageCacheType.discoverGroupShare, + userId: userId, + spaceId: spaceId, + toJson: (item) => item.toJson(), + ); } } _isInitialLoading = false; @@ -145,7 +165,14 @@ class _GroupShareListPageState extends State { resourceId: widget.resourceId, sourceId: widget.sourceId, ); - DiscoverListCache.write(cacheKey, data); + DiscoverListCache.writePersistent( + cacheKey, + data, + dataType: PageCacheType.discoverGroupShare, + userId: relationCtrl.user?.id ?? 'anon', + spaceId: widget.sourceId, + toJson: (item) => item.toJson(), + ); } }); } catch (e) { @@ -160,7 +187,10 @@ class _GroupShareListPageState extends State { } void _loadMore() { - if (!_hasMore || _isInitialLoading || _isBackgroundRefreshing || _isLoadingMore) { + if (!_hasMore || + _isInitialLoading || + _isBackgroundRefreshing || + _isLoadingMore) { return; } setState(() { @@ -242,8 +272,8 @@ 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), @@ -300,8 +330,8 @@ class _GroupShareListPageState extends State { const SizedBox(width: 6), Text( '数据更新中', - style: TextStyle( - fontSize: 11, color: Colors.orange.shade700), + style: + TextStyle(fontSize: 11, color: Colors.orange.shade700), ), ], ), diff --git a/lib/pages/discover/pages/live_list_page.dart b/lib/pages/discover/pages/live_list_page.dart index 6d503a8..25e17d9 100644 --- a/lib/pages/discover/pages/live_list_page.dart +++ b/lib/pages/discover/pages/live_list_page.dart @@ -7,6 +7,9 @@ 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'; @@ -54,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(() { @@ -62,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 { @@ -77,7 +105,7 @@ class _LiveListPageState extends State { if (!mounted) return; setState(() { - if (refresh) { + if (refresh || didShowCache) { _items = data; } else { _items.addAll(data); @@ -87,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; @@ -157,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), @@ -719,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), @@ -840,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 24a74b3..67b8618 100644 --- a/lib/pages/discover/pages/market_list_page.dart +++ b/lib/pages/discover/pages/market_list_page.dart @@ -4,6 +4,8 @@ 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/pages/discover/widgets/plaza_style_cards.dart'; import 'package:orginone/utils/log/log_util.dart'; @@ -12,6 +14,7 @@ import '../discover_service.dart'; class MarketListPage extends StatefulWidget { final String resourceId; final String title; + /// 资源来源空间 ID(多集群聚合时用于查询对应集群的 collection) final String? sourceId; @@ -28,8 +31,10 @@ class MarketListPage extends StatefulWidget { class _MarketListPageState extends State { List _items = []; + /// 是否首次加载(无缓存数据时显示 LoadingWidget) bool _isInitialLoading = true; + /// 是否正在后台刷新(有缓存数据时显示顶部"数据更新中"指示器) bool _isBackgroundRefreshing = false; bool _isLoadingMore = false; @@ -57,15 +62,23 @@ class _MarketListPageState extends State { super.dispose(); } - /// 离线优先加载:先读缓存立即渲染,后台拉取新数据 + /// 离线优先加载:先读持久化缓存立即渲染,后台拉取新数据 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 = DiscoverListCache.read(cacheKey); + // 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(() { @@ -98,7 +111,14 @@ class _MarketListPageState extends State { _items = data; _hasMore = data.length >= _pageSize; if (data.isNotEmpty) { - DiscoverListCache.write(cacheKey, data); + DiscoverListCache.writePersistent( + cacheKey, + data, + dataType: PageCacheType.discoverMarketGoods, + userId: userId, + spaceId: spaceId, + toJson: (item) => item.toJson(), + ); } } _isInitialLoading = false; @@ -148,7 +168,14 @@ class _MarketListPageState extends State { resourceId: widget.resourceId, sourceId: widget.sourceId, ); - DiscoverListCache.write(cacheKey, data); + DiscoverListCache.writePersistent( + cacheKey, + data, + dataType: PageCacheType.discoverMarketGoods, + userId: relationCtrl.user?.id ?? 'anon', + spaceId: widget.sourceId, + toJson: (item) => item.toJson(), + ); } }); } catch (e) { @@ -163,7 +190,10 @@ class _MarketListPageState extends State { } void _loadMore() { - if (!_hasMore || _isInitialLoading || _isBackgroundRefreshing || _isLoadingMore) { + if (!_hasMore || + _isInitialLoading || + _isBackgroundRefreshing || + _isLoadingMore) { return; } setState(() { @@ -218,8 +248,7 @@ 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), @@ -267,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( @@ -308,8 +339,8 @@ class _MarketListPageState extends State { const SizedBox(width: 6), Text( '数据更新中', - style: TextStyle( - fontSize: 11, color: Colors.orange.shade700), + style: + TextStyle(fontSize: 11, color: Colors.orange.shade700), ), ], ), @@ -407,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, @@ -418,8 +450,7 @@ class _MarketListPageState extends State { onSelected: (sel) { Navigator.pop(context); setState(() { - _selectedTypeName = - sel && type != '全部' ? type : null; + _selectedTypeName = sel && type != '全部' ? type : null; }); _refresh(); }, diff --git a/lib/pages/discover/pages/notice_list_page.dart b/lib/pages/discover/pages/notice_list_page.dart index fc12ae3..f8f8b2b 100644 --- a/lib/pages/discover/pages/notice_list_page.dart +++ b/lib/pages/discover/pages/notice_list_page.dart @@ -4,6 +4,8 @@ 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/pages/discover/widgets/plaza_style_cards.dart'; import 'package:orginone/utils/log/log_util.dart'; @@ -12,6 +14,7 @@ import '../discover_service.dart'; class NoticeListPage extends StatefulWidget { final String resourceId; final String title; + /// 资源来源空间 ID(多集群聚合时用于查询对应集群的 collection) final String? sourceId; @@ -28,8 +31,10 @@ class NoticeListPage extends StatefulWidget { class _NoticeListPageState extends State { List _items = []; + /// 是否首次加载(无缓存数据时显示 LoadingWidget) bool _isInitialLoading = true; + /// 是否正在后台刷新(有缓存数据时显示顶部"数据更新中"指示器) bool _isBackgroundRefreshing = false; bool _isLoadingMore = false; @@ -56,15 +61,23 @@ class _NoticeListPageState extends State { super.dispose(); } - /// 离线优先加载:先读缓存立即渲染,后台拉取新数据 + /// 离线优先加载:先读持久化缓存立即渲染,后台拉取新数据 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 = DiscoverListCache.read(cacheKey); + // 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(() { @@ -96,7 +109,14 @@ class _NoticeListPageState extends State { _items = data; _hasMore = data.length >= _pageSize; if (data.isNotEmpty) { - DiscoverListCache.write(cacheKey, data); + DiscoverListCache.writePersistent( + cacheKey, + data, + dataType: PageCacheType.discoverNotice, + userId: userId, + spaceId: spaceId, + toJson: (item) => item.toJson(), + ); } } _isInitialLoading = false; @@ -145,7 +165,14 @@ class _NoticeListPageState extends State { resourceId: widget.resourceId, sourceId: widget.sourceId, ); - DiscoverListCache.write(cacheKey, data); + DiscoverListCache.writePersistent( + cacheKey, + data, + dataType: PageCacheType.discoverNotice, + userId: relationCtrl.user?.id ?? 'anon', + spaceId: widget.sourceId, + toJson: (item) => item.toJson(), + ); } }); } catch (e) { @@ -160,7 +187,10 @@ class _NoticeListPageState extends State { } void _loadMore() { - if (!_hasMore || _isInitialLoading || _isBackgroundRefreshing || _isLoadingMore) { + if (!_hasMore || + _isInitialLoading || + _isBackgroundRefreshing || + _isLoadingMore) { return; } setState(() { @@ -242,8 +272,8 @@ 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), @@ -300,8 +330,8 @@ class _NoticeListPageState extends State { const SizedBox(width: 6), Text( '数据更新中', - style: TextStyle( - fontSize: 11, color: Colors.orange.shade700), + style: + TextStyle(fontSize: 11, color: Colors.orange.shade700), ), ], ), diff --git a/lib/pages/discover/pages/video_list_page.dart b/lib/pages/discover/pages/video_list_page.dart index eee23b1..d4d467c 100644 --- a/lib/pages/discover/pages/video_list_page.dart +++ b/lib/pages/discover/pages/video_list_page.dart @@ -4,6 +4,8 @@ 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/discover_list_cache.dart'; import 'package:orginone/pages/discover/widgets/plaza_style_cards.dart'; @@ -12,6 +14,7 @@ import '../discover_service.dart'; class VideoListPage extends StatefulWidget { final String resourceId; final String title; + /// 资源来源空间 ID(多集群聚合时用于查询对应集群的 collection) final String? sourceId; @@ -28,8 +31,10 @@ class VideoListPage extends StatefulWidget { class _VideoListPageState extends State { List _items = []; + /// 是否首次加载(无缓存数据时显示 LoadingWidget) bool _isInitialLoading = true; + /// 是否正在后台刷新(有缓存数据时显示顶部"数据更新中"指示器) bool _isBackgroundRefreshing = false; bool _isLoadingMore = false; @@ -56,15 +61,23 @@ class _VideoListPageState extends State { super.dispose(); } - /// 离线优先加载:先读缓存立即渲染,后台拉取新数据 + /// 离线优先加载:先读持久化缓存立即渲染,后台拉取新数据 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 = DiscoverListCache.read(cacheKey); + // 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(() { @@ -96,7 +109,14 @@ class _VideoListPageState extends State { _items = data; _hasMore = data.length >= _pageSize; if (data.isNotEmpty) { - DiscoverListCache.write(cacheKey, data); + DiscoverListCache.writePersistent( + cacheKey, + data, + dataType: PageCacheType.discoverVideo, + userId: userId, + spaceId: spaceId, + toJson: (item) => item.toJson(), + ); } } _isInitialLoading = false; @@ -145,7 +165,14 @@ class _VideoListPageState extends State { resourceId: widget.resourceId, sourceId: widget.sourceId, ); - DiscoverListCache.write(cacheKey, data); + DiscoverListCache.writePersistent( + cacheKey, + data, + dataType: PageCacheType.discoverVideo, + userId: relationCtrl.user?.id ?? 'anon', + spaceId: widget.sourceId, + toJson: (item) => item.toJson(), + ); } }); } catch (e) { @@ -160,7 +187,10 @@ class _VideoListPageState extends State { } void _loadMore() { - if (!_hasMore || _isInitialLoading || _isBackgroundRefreshing || _isLoadingMore) { + if (!_hasMore || + _isInitialLoading || + _isBackgroundRefreshing || + _isLoadingMore) { return; } setState(() { @@ -245,8 +275,7 @@ 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), @@ -302,8 +331,8 @@ class _VideoListPageState extends State { const SizedBox(width: 6), Text( '数据更新中', - style: TextStyle( - fontSize: 11, color: Colors.orange.shade700), + style: + TextStyle(fontSize: 11, color: Colors.orange.shade700), ), ], ), diff --git a/lib/pages/discover/widgets/discover_list_cache.dart b/lib/pages/discover/widgets/discover_list_cache.dart index 657680d..5208d82 100644 --- a/lib/pages/discover/widgets/discover_list_cache.dart +++ b/lib/pages/discover/widgets/discover_list_cache.dart @@ -1,5 +1,8 @@ import 'dart:async'; +import 'package:orginone/dart/base/storages/local_page_cache_repository.dart'; +import 'package:orginone/utils/log/log_util.dart'; + /// 发现页子页面(公告/视频/群组/数据/市场)的本地缓存工具。 /// /// 实现"先展示缓存数据,后台更新数据"的离线优先策略: @@ -31,6 +34,88 @@ class DiscoverListCache { ); } + /// 从磁盘读取持久化缓存(异步) + /// + /// 三级读取策略: + /// 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(); -- Gitee From ad4e83c8273ca10d6f56da8ba1ae1113045795bd Mon Sep 17 00:00:00 2001 From: panzhaohui Date: Mon, 27 Jul 2026 22:59:06 +0800 Subject: [PATCH 35/37] =?UTF-8?q?fix:=20=E7=BD=91=E7=BB=9C=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E7=A8=B3=E5=AE=9A=E6=80=A7=E5=92=8C=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. DataNotityType 序列化修复:dataNotify 预序列化为 Map,避免 SignalR JsonHubProtocol 不调用 toJson 导致序列化失败 2. stale 连接重启退避:30 秒内不重复重启,避免 13000+ 次循环重启 3. SignalR invoke 超时从 20s 增加到 30s,减少不必要的 HTTP 降级 4. HTTP 408 超时降级为 warning 级别,不写入错误日志页面 5. 408 超时 Toast 节流 15 秒,避免频繁弹提示 6. 连接错误 Toast 节流 10 秒,避免断连循环时刷屏 7. 连接被关闭/调用取消等非关键异常降级为 warning 级别 8. TokenAuth 失败降级为 warning,避免错误日志刷屏 9. connectTimeout 从 15s 调整为 60s,适配政务服务器响应速度 --- lib/dart/base/api/http_util.dart | 27 +++++++++++++--- lib/dart/base/api/kernelapi.dart | 32 ++++++++++++++++--- lib/dart/base/api/storehub.dart | 30 ++++++++++++----- lib/dart/controller/app_start_controller.dart | 15 ++++++--- 4 files changed, 82 insertions(+), 22 deletions(-) diff --git a/lib/dart/base/api/http_util.dart b/lib/dart/base/api/http_util.dart index d5ac7f1..ddb5c05 100644 --- a/lib/dart/base/api/http_util.dart +++ b/lib/dart/base/api/http_util.dart @@ -35,12 +35,18 @@ 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), + // 连接超时 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 拿到错误 @@ -253,13 +259,26 @@ class HttpUtil with AuthMixin implements StoreHubHttpClient { : response?.statusMessage ?? error.message ?? '请求失败'; final logMsg = '[$transportTag][$requestId] $method $path failed with $statusCode in ${cost}ms: $message'; - XLogUtil.e(logMsg); - SystemLog.err(logMsg, 'HTTP错误[$statusCode]'); + 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); diff --git a/lib/dart/base/api/kernelapi.dart b/lib/dart/base/api/kernelapi.dart index 4258674..501fd19 100644 --- a/lib/dart/base/api/kernelapi.dart +++ b/lib/dart/base/api/kernelapi.dart @@ -269,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; @@ -1616,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 fa0224c..1ebb3fe 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 = ''; @@ -328,6 +329,13 @@ class StoreHub with AuthMixin, RequestStatisticsMixin { 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 @@ -409,6 +417,8 @@ class StoreHub with AuthMixin, RequestStatisticsMixin { _reconnectAttempts = 0; _consecutiveHeartbeatFailures = 0; _lastConnectedAt = DateTime.now(); + // 连接成功后重置 stale 退避,允许下次 stale 检测立即生效 + _lastReviveAt = null; // 每次新连接必须重新 TokenAuth,重置栅栏 _authGate = Completer(); _setTransportState( @@ -746,14 +756,13 @@ class StoreHub with AuthMixin, RequestStatisticsMixin { if (usedLongLink) { try { - // SignalR invoke 超时缩短为 20s(原 60s 太长,用户等 60s+60s=120s 才报错)。 - // 超时后快速降级到 HTTP fallback(HTTP 自身 receiveTimeout=60s 兜底)。 - // 业务请求通常 < 5s 完成,20s 足够覆盖慢请求;长操作(如文件上传)走专用接口。 + // SignalR invoke 超时 30s:政务服务器响应较慢,20s 会导致频繁降级到 HTTP。 + // 30s 足够覆盖大部分慢请求,减少不必要的 HTTP 降级(HTTP receiveTimeout=60s 兜底)。 resObj = await _connection .invoke(methodName, args: safeArgs) - .timeout(const Duration(seconds: 20), onTimeout: () { + .timeout(const Duration(seconds: 30), onTimeout: () { throw TimeoutException( - 'SignalR invoke 超时: $methodName', const Duration(seconds: 20)); + 'SignalR invoke 超时: $methodName', const Duration(seconds: 30)); }); } on TimeoutException catch (_) { // SignalR 超时:降级到 HTTP fallback,避免数据完全不加载 @@ -808,7 +817,7 @@ class StoreHub with AuthMixin, RequestStatisticsMixin { 'args': safeArgs.isNotEmpty ? jsonEncode(safeArgs.first) : '{}' }); } catch (e) { - XLogUtil.e('请求统计记录失败: $e'); + XLogUtil.w('请求统计记录失败: $e'); } } } @@ -857,14 +866,19 @@ class StoreHub with AuthMixin, RequestStatisticsMixin { if (err != null) { msg += err is String ? err : err.toString(); } + // 连接被关闭/调用取消等非关键异常使用 warning 级别,不写入错误日志页面 + final isConnError = msg.contains('connection being closed') || + msg.contains('Invocation canceled'); _logDeduped( '$method|err|$msg', - () => XLogUtil.e('StoreHub 请求异常: $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/controller/app_start_controller.dart b/lib/dart/controller/app_start_controller.dart index 63bc0fe..19e71fd 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 = ""; @@ -65,11 +69,12 @@ class AppStartController with EmitterMixin, AuthMixin { _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: "连接服务器失败,正在尝试恢复..."); + } } }); -- Gitee From 9caf1f4ca30741d247ea080e4b55ae5400c85ea3 Mon Sep 17 00:00:00 2001 From: panzhaohui Date: Tue, 28 Jul 2026 07:35:16 +0800 Subject: [PATCH 36/37] =?UTF-8?q?fix:=20SignalR=20=E6=8E=A8=E9=80=81?= =?UTF-8?q?=E5=88=86=E5=8F=91=E4=B8=8E=E7=BC=93=E5=AD=98=E5=88=B7=E6=96=B0?= =?UTF-8?q?=E6=80=A7=E8=83=BD=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 修复 _receive 推送分发静默吞异常导致订阅者循环中断(收不到推送根因): - 每个订阅者独立 try-catch,一个抛异常不影响其他订阅者 - 异常写入 SystemLog(§19.2),不再静默丢失 2. 修复 ensureDataFresh 后台刷新无条件触发导致请求风暴(页面慢根因): - _triggerSilentBackgroundRefresh 增加 5 分钟节流控制(§18.2 不重复刷新) - 切空间时清空节流记录,允许新空间触发刷新 3. 修复 _refresh/wakeUp/_doEnsureDataFresh 中大量 catchError((_) {}) 静默吞错误: - 提取 _loadCoreData 辅助方法,每个分支独立记录错误到 SystemLog - 替换所有静默 catchError 为带堆栈记录的版本(§19.2) 4. 修复 _cacheData key 无上限内存泄漏: - 新增 _maxCacheKeys=100 限制,超出 FIFO 淘汰 --- lib/dart/base/api/kernelapi.dart | 39 +++++--- lib/dart/core/provider/index.dart | 157 +++++++++++++++++------------- 2 files changed, 113 insertions(+), 83 deletions(-) diff --git a/lib/dart/base/api/kernelapi.dart b/lib/dart/base/api/kernelapi.dart index 501fd19..691fba1 100644 --- a/lib/dart/base/api/kernelapi.dart +++ b/lib/dart/base/api/kernelapi.dart @@ -7,6 +7,7 @@ import 'package:orginone/config/constant.dart'; import 'package:orginone/dart/base/api/storehub.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/hive_utils.dart'; @@ -29,6 +30,8 @@ class KernelApi with AuthMixin { final Map> _cacheData = {}; // 单 key 缓存上限,防止内存无限增长 static const int _maxCachePerKey = 50; + // key 总数上限,防止长期运行 _cacheData 内存无限增长 + static const int _maxCacheKeys = 100; // 订阅方法 final Map> _methods; @@ -47,7 +50,8 @@ class KernelApi with AuthMixin { KernelApi._(String url, {StoreHub? storeHub, bool autoStart = true}) : _methods = {}, _subMethods = {}, - _storeHub = storeHub ?? StoreHub('$url?requestOnly=0', protocol: 'json') { + _storeHub = + storeHub ?? StoreHub('$url?requestOnly=0', protocol: 'json') { _bindStoreHub(autoStart: autoStart); } @@ -1791,13 +1795,15 @@ class KernelApi with AuthMixin { if (null != methods) { // ToastUtils.showMsg(msg: "监听数量:${methods.length}"); - try { - for (var m in methods) { + // 每个订阅者独立 try-catch:避免一个抛异常中断循环导致后续订阅者收不到推送 + for (var m in methods) { + try { m['operation'].call(data.data); + } catch (e, s) { + final msg = '推送订阅执行失败 [$flag]: $e\n$s'; + XLogUtil.e(msg); + SystemLog.err(msg, '推送分发'); } - } catch (e) { - ////XLogUtil.e(s); - ////XLogUtil.e(e as Error); } } else { // ToastUtils.showMsg(msg: "没有监听:$flag"); @@ -1832,18 +1838,19 @@ class KernelApi with AuthMixin { { var methods = _methods[res.target.toLowerCase()]; if (methods != null) { - try { - for (var m in methods) { - // LogUtil.d(res.target); - // LogUtil.d(m.runtimeType); + // 每个订阅者独立 try-catch:避免一个抛异常中断循环 + for (var m in methods) { + try { m.call(res.data); - // Function.apply(m, [res.data]); + } catch (e, s) { + final msg = '推送订阅执行失败 [${res.target}]: $e\n$s'; + XLogUtil.e(msg); + SystemLog.err(msg, '推送分发'); } - } catch (e) { - ////XLogUtil.w(e as Error); } } else { - XLogUtil.w('StoreHub 推送未找到消息处理者: target=${res.target} data=${res.data}'); + XLogUtil.w( + 'StoreHub 推送未找到消息处理者: target=${res.target} data=${res.data}'); } } } @@ -1857,6 +1864,10 @@ class KernelApi with AuthMixin { data.removeRange(0, data.length - _maxCachePerKey); } _cacheData[key] = data; + // 限制 key 总数,超出后丢弃最早的 key(FIFO),防止长期运行内存无限增长 + if (_cacheData.length > _maxCacheKeys) { + _cacheData.remove(_cacheData.keys.first); + } } } } diff --git a/lib/dart/core/provider/index.dart b/lib/dart/core/provider/index.dart index 7a8b0b6..ca98c3d 100644 --- a/lib/dart/core/provider/index.dart +++ b/lib/dart/core/provider/index.dart @@ -75,6 +75,8 @@ class DataProvider with EmitterMixin, Mutex { // 确保进入新空间的子页面会强制刷新本地缓存。 command.subscribeByFlag('switchSpace', ([args]) { DataFreshnessTracker.instance.invalidateForSpace(); + // 清空后台静默刷新节流记录,允许新空间的子页面触发后台刷新 + _silentRefreshLastAt.clear(); }, false); } @@ -358,20 +360,7 @@ class DataProvider with EmitterMixin, Mutex { XLogUtil.i('>>>>>>[登录诊断] 调用 deepLoadForLogin...'); await user.deepLoadForLogin(reload: true); XLogUtil.i('>>>>>>[登录诊断] deepLoadForLogin 完成,开始加载 work/chat/activity'); - await Future.wait([ - work?.loadTodos(reload: true).then((_) {}).catchError((_) {}) ?? - Future.value(), - _chatProvider - ?.load(reload: true, skipDeepLoad: true) - .then((_) {}) - .catchError((_) {}) ?? - Future.value(), - _chatProvider?.activityProvider - .load(reload: true) - .then((_) {}) - .catchError((_) {}) ?? - Future.value(), - ]); + await _loadCoreData(label: 'refresh-forLogin'); changeCallback(args: [true]); _markCoreDataRefreshed(); _scheduleSnapshotWrite(); @@ -383,40 +372,14 @@ class DataProvider with EmitterMixin, Mutex { } else if (user != null) { // 完整重载路径(手动刷新) await user.deepLoad(reload: true); - await Future.wait([ - work?.loadTodos(reload: true).then((_) {}).catchError((_) {}) ?? - Future.value(), - _chatProvider - ?.load(reload: true, skipDeepLoad: true) - .then((_) {}) - .catchError((_) {}) ?? - Future.value(), - _chatProvider?.activityProvider - .load(reload: true) - .then((_) {}) - .catchError((_) {}) ?? - Future.value(), - ]); + await _loadCoreData(label: 'refresh-full'); changeCallback(args: [true]); _markCoreDataRefreshed(); _scheduleSnapshotWrite(); _scheduleLocalPageCacheWrite(); _notifyDataRefreshed(); } else { - await Future.wait([ - work?.loadTodos(reload: true).then((_) {}).catchError((_) {}) ?? - Future.value(), - _chatProvider - ?.load(reload: true, skipDeepLoad: true) - .then((_) {}) - .catchError((_) {}) ?? - Future.value(), - _chatProvider?.activityProvider - .load(reload: true) - .then((_) {}) - .catchError((_) {}) ?? - Future.value(), - ]); + await _loadCoreData(label: 'refresh-noUser'); changeCallback(args: [true]); _markCoreDataRefreshed(); _scheduleSnapshotWrite(); @@ -447,6 +410,37 @@ class DataProvider with EmitterMixin, Mutex { command.emitterFlag('work'); } + /// 并行加载核心数据(work/chat/activity),每个分支独立记录错误到 SystemLog(§19.2)。 + /// 替代原先的 catchError((_) {}) 静默吞错误模式。 + Future _loadCoreData({String label = 'refresh'}) async { + await Future.wait([ + work?.loadTodos(reload: true).then((_) {}).catchError((e, s) { + final msg = '[$label] loadTodos 失败: $e\n$s'; + XLogUtil.e(msg); + SystemLog.err(msg, '数据刷新'); + }) ?? + Future.value(), + _chatProvider + ?.load(reload: true, skipDeepLoad: true) + .then((_) {}) + .catchError((e, s) { + final msg = '[$label] chat load 失败: $e\n$s'; + XLogUtil.e(msg); + SystemLog.err(msg, '数据刷新'); + }) ?? + Future.value(), + _chatProvider?.activityProvider + .load(reload: true) + .then((_) {}) + .catchError((e, s) { + final msg = '[$label] activity load 失败: $e\n$s'; + XLogUtil.e(msg); + SystemLog.err(msg, '数据刷新'); + }) ?? + Future.value(), + ]); + } + /// 标记核心数据类型为已刷新(用于 _refresh/wakeUp 完成后) /// _onBackgroundLoaded 仅标记 chats/relation/activities,因为后台二级 /// 递归只刷新这三类;其他类型(work/storage/apps)的标记留待 _refresh/wakeUp。 @@ -612,25 +606,16 @@ class DataProvider with EmitterMixin, Mutex { // 先刷新用户数据(含 cohorts/storages/members),再加载聊天/办事/动态 final user = _user; if (user != null) { - await user.deepLoad(reload: true).catchError((_) {}); + await user.deepLoad(reload: true).catchError((e, s) { + final msg = '[wakeUp] deepLoad 失败: $e\n$s'; + XLogUtil.e(msg); + SystemLog.err(msg, '数据刷新'); + }); } // 从服务器拉取最新数据(而非只读缓存) // skipDeepLoad: true 避免重复 user.deepLoad/company.deepLoad // (上面已 user.deepLoad,且 ChatProvider._refreshRemoteData 也会后台 deepLoad) - await Future.wait([ - _chatProvider - ?.load(reload: true, skipDeepLoad: true) - .then((_) {}) - .catchError((_) {}) ?? - Future.value(), - work?.loadTodos(reload: true).then((_) {}).catchError((_) {}) ?? - Future.value(), - _chatProvider?.activityProvider - .load(reload: true) - .then((_) {}) - .catchError((_) {}) ?? - Future.value(), - ]); + await _loadCoreData(label: 'wakeUp'); changeCallback(args: [true]); // wakeUp 是全量重载入口(网络恢复/手动刷新):标记核心数据已刷新, // 子页面进入时若在 TTL 内会直接从内存渲染,避免重复请求。 @@ -722,9 +707,22 @@ class DataProvider with EmitterMixin, Mutex { } } - /// 后台静默更新(fire-and-forget):不论数据是否新鲜,都发起一次远程拉取。 + /// 后台静默更新节流记录:避免每次进入子页面都触发后台请求风暴。 + /// 独立于 DataFreshnessTracker,使用更短 TTL(5 分钟)确保在线变更能及时同步, + /// 同时避免短时间内重复进入子页面触发请求风暴(§18.2 不重复刷新)。 + final Map _silentRefreshLastAt = {}; + static const int _silentRefreshMinIntervalMs = 5 * 60 * 1000; + + /// 后台静默更新(fire-and-forget):受 5 分钟节流控制,避免每次进入子页面都触发请求风暴。 /// 完成后通过 command 事件通知 UI 刷新,不阻塞调用方。 void _triggerSilentBackgroundRefresh(String dataType) { + // 节流:5 分钟内同一 dataType 不重复触发后台静默刷新 + final now = DateTime.now().millisecondsSinceEpoch; + final lastAt = _silentRefreshLastAt[dataType] ?? 0; + if (now - lastAt < _silentRefreshMinIntervalMs) { + return; + } + _silentRefreshLastAt[dataType] = now; Future(() async { try { switch (dataType) { @@ -732,7 +730,9 @@ class DataProvider with EmitterMixin, Mutex { _chatProvider?.refreshRemote(reload: true); break; case DataType.work: - await work?.loadTodos(reload: true).then((_) {}).catchError((_) {}); + await work?.loadTodos(reload: true).then((_) {}).catchError((e, s) { + _logBackgroundError(dataType, e, s); + }); command.emitter('work', 'refresh'); command.emitterFlag('work'); break; @@ -740,25 +740,34 @@ class DataProvider with EmitterMixin, Mutex { case DataType.storage: case DataType.apps: if (_user != null) { - await _user!.deepLoad(reload: true).catchError((_) {}); + await _user!.deepLoad(reload: true).catchError((e, s) { + _logBackgroundError(dataType, e, s); + }); command.emitterFlag('session'); } break; case DataType.activities: await _chatProvider?.activityProvider .load(reload: true) - .catchError((_) {}); + .catchError((e, s) { + _logBackgroundError(dataType, e, s); + }); command.emitterFlag('session'); break; } } catch (e, s) { - final msg = '后台静默刷新失败 [$dataType]: $e\n$s'; - XLogUtil.e(msg); - SystemLog.err(msg, '后台刷新[$dataType]'); + _logBackgroundError(dataType, e, s); } }); } + /// 记录后台刷新错误到 SystemLog(§19.2 错误日志约束) + void _logBackgroundError(String dataType, Object e, StackTrace s) { + final msg = '后台静默刷新失败 [$dataType]: $e\n$s'; + XLogUtil.e(msg); + SystemLog.err(msg, '后台刷新[$dataType]'); + } + Future _doEnsureDataFresh(String dataType) async { try { // 刷新前记录数据指纹(§18.2 有更新才刷新) @@ -768,23 +777,31 @@ class DataProvider with EmitterMixin, Mutex { // 先从内存快速重建(不阻塞),再后台拉取在线最新数据 await _chatProvider ?.load(reload: true, skipDeepLoad: true) - .catchError((_) {}); + .catchError((e, s) { + _logBackgroundError(dataType, e, s); + }); // 发起后台远端数据拉取(fire-and-forget,完成后 _notify 触发 chat/new 事件) _chatProvider?.refreshRemote(reload: true); break; case DataType.work: - await work?.loadTodos(reload: true).then((_) {}).catchError((_) {}); + await work?.loadTodos(reload: true).then((_) {}).catchError((e, s) { + _logBackgroundError(dataType, e, s); + }); break; case DataType.activities: await _chatProvider?.activityProvider .load(reload: true) - .catchError((_) {}); + .catchError((e, s) { + _logBackgroundError(dataType, e, s); + }); break; case DataType.relation: // 关系树依赖 user/company deepLoad,触发 user.deepLoad(reload=true)足够 final user = _user; if (user != null) { - await user.deepLoad(reload: true).catchError((_) {}); + await user.deepLoad(reload: true).catchError((e, s) { + _logBackgroundError(dataType, e, s); + }); } break; case DataType.storage: @@ -792,7 +809,9 @@ class DataProvider with EmitterMixin, Mutex { // storage/apps 在 deepLoad 内同步加载,复用 relation 路径 final user = _user; if (user != null) { - await user.deepLoad(reload: true).catchError((_) {}); + await user.deepLoad(reload: true).catchError((e, s) { + _logBackgroundError(dataType, e, s); + }); } break; case DataType.forms: -- Gitee From 3a4673d6bdb3c0b3e3eb9c203e210604eec2a5f5 Mon Sep 17 00:00:00 2001 From: panzhaohui Date: Tue, 28 Jul 2026 07:42:47 +0800 Subject: [PATCH 37/37] =?UTF-8?q?fix:=20=E7=BB=9F=E4=B8=80=204=20Tab=20?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E5=90=8E=E5=8F=B0=E5=88=B7=E6=96=B0=E6=8C=87?= =?UTF-8?q?=E7=A4=BA=E5=99=A8=E4=B8=8E=E9=94=99=E8=AF=AF=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 修复 work_page.dart / relation_page.dart 后台刷新指示器死代码: - _isRefreshing 标志此前永远不会被设为 true,导致"数据更新中"横幅从不显示 - 新增 _triggerBgRefresh() 方法,对齐 chat_page.dart 模式 - ensureDataFresh 前设 true,完成/失败后设 false,统一 4 Tab 交互(§17.2) 2. 修复多处静默 catch 吞错误(§19.2): - chat_page.dart: catchError((_) {}) → 记录到 SystemLog - relation_page.dart: _triggerCompanyBgRefresh / loadSpaceData 的 catch (_) {} → 记录到 SystemLog 3. 统一刷新组件交互:4 Tab 页面(沟通/办事/发现/关系)现在一致地: - 首屏从 L0 内存缓存渲染(SkeletonWidget 占位) - 后台 ensureDataFresh 触发时显示 XUi.refreshingBanner() 指示器 - 后台刷新完成后通过 command 事件通知 UI 重建 - 下拉刷新 force=true 强制远端拉取 --- lib/pages/chats/chat_page.dart | 11 ++++-- lib/pages/relation/relation_page.dart | 49 +++++++++++++++++++-------- lib/pages/work/work_page.dart | 24 +++++++++++-- 3 files changed, 64 insertions(+), 20 deletions(-) diff --git a/lib/pages/chats/chat_page.dart b/lib/pages/chats/chat_page.dart index dcd062d..4bf450f 100644 --- a/lib/pages/chats/chat_page.dart +++ b/lib/pages/chats/chat_page.dart @@ -15,6 +15,8 @@ import 'package:orginone/utils/date_util.dart'; import '../../components/ListSearchWidget/ListSearchWidget.dart'; import '../../components/PortalManagerWidget/portalrefresh_event.dart'; import '../../dart/base/common/commands.dart'; +import 'package:orginone/utils/log/log_util.dart'; +import 'package:orginone/dart/base/common/systemError.dart'; /// 沟通页面 // ignore: must_be_immutable @@ -114,8 +116,11 @@ class _ChatPageState extends State load(context); setState(() => _isBgRefreshing = false); } - }).catchError((_) { + }).catchError((e, s) { if (mounted) setState(() => _isBgRefreshing = false); + final msg = '[chat] 后台刷新失败: $e\n$s'; + XLogUtil.e(msg); + SystemLog.err(msg, '后台刷新[chats]'); }); } @@ -276,8 +281,8 @@ class _ChatPageState extends State element.search(searchText)) .toList() ?? []; - filtered.sort((a, b) => - b.chatdata.lastMsgTime.compareTo(a.chatdata.lastMsgTime)); + filtered.sort( + (a, b) => b.chatdata.lastMsgTime.compareTo(a.chatdata.lastMsgTime)); // 最近 Tab 最多展示 100 条,避免列表过长 if (filtered.length > 100) { filtered = filtered.sublist(0, 100); diff --git a/lib/pages/relation/relation_page.dart b/lib/pages/relation/relation_page.dart index a7fe2f5..1e8727e 100644 --- a/lib/pages/relation/relation_page.dart +++ b/lib/pages/relation/relation_page.dart @@ -33,6 +33,8 @@ import 'package:orginone/pages/portal/create_agent_page.dart'; import 'package:orginone/routers/app_route.dart'; import 'package:orginone/routers/pages.dart'; import 'package:orginone/routers/router_const.dart'; +import 'package:orginone/utils/log/log_util.dart'; +import 'package:orginone/dart/base/common/systemError.dart'; import 'package:provider/provider.dart'; import '../../components/XConsumer/XConsumer.dart'; @@ -75,15 +77,6 @@ class _RelationState extends State super.initState(); relationModel = null; datas = null; - // 进入关系页面时按需刷新关系树(数据过期才刷新,新鲜则直接从内存渲染) - relationCtrl.provider.ensureDataFresh(DataType.relation); - // 关系页不跟随单位切换刷新数据,保持本地缓存优先策略, - // 加速页面渲染速度。单位切换只刷新门户页面数据。 - // 首次加载放到首帧后,避免在 build 中调度 setState 造成级联重建 - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - _loadFromRoute(context); - }); // 订阅 'session' flag:后台 deepLoad/ensureDataFresh 完成后会发射此事件, // 通知关系页重建 model 拉取最新关系树数据(cohorts/storages/agents/companys)。 // 对齐 AGENTS.md §18.2「事件通知:后台拉取完成后通过 command.emitter(flag) 通知订阅方刷新」 @@ -96,6 +89,7 @@ class _RelationState extends State if (!mounted) return; // 重置 model 触发 _loadFromRoute 重建,从内存读取最新数据 setState(() { + _isRefreshing = false; relationModel = null; _lastLoadedPath = null; _lastLoadedData = null; @@ -104,6 +98,28 @@ class _RelationState extends State }); }); _subscriptions.add(sessionSub); + // 触发后台刷新并显示"数据更新中"指示器 + _triggerBgRefresh(); + // 首次加载放到首帧后,避免在 build 中调度 setState 造成级联重建 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _loadFromRoute(context); + }); + } + + /// 触发后台数据刷新(带"数据更新中"指示器)。 + /// 对齐 chat_page.dart / work_page.dart 的 _triggerBgRefresh 模式,统一 4 Tab 交互。 + void _triggerBgRefresh() { + if (!mounted) return; + setState(() => _isRefreshing = true); + relationCtrl.provider.ensureDataFresh(DataType.relation).then((_) { + if (mounted) setState(() => _isRefreshing = false); + }).catchError((e, s) { + if (mounted) setState(() => _isRefreshing = false); + final msg = '[relation] 后台刷新失败: $e\n$s'; + XLogUtil.e(msg); + SystemLog.err(msg, '后台刷新[relation]'); + }); } /// 根据当前 AppRoute 上下文加载 relationModel。 @@ -159,13 +175,14 @@ class _RelationState extends State datas = ar.currPageData.data; } final showRefreshBanner = _isRefreshing; - if (_isRefreshing) _isRefreshing = false; // 统一下拉刷新交互:force=true 强制重新拉取关系树(user.deepLoad reload=true) return RefreshIndicator( onRefresh: () async { + setState(() => _isRefreshing = true); await relationCtrl.provider .ensureDataFresh(DataType.relation, force: true); if (!mounted) return; + setState(() => _isRefreshing = false); // 使用 State.context 而非 builder 闭包的 context,确保 mounted 检查正确守卫 _loadFromRoute(this.context); }, @@ -536,8 +553,10 @@ class _RelationState extends State if (mounted) { command.emitterFlag('session'); } - } catch (_) { - // 静默失败,保留本地缓存 + } catch (e, s) { + final msg = '[relation] company deepLoad 失败: $e\n$s'; + XLogUtil.e(msg); + SystemLog.err(msg, '后台加载'); } }); } @@ -689,8 +708,10 @@ class _RelationState extends State Future(() async { try { await relationCtrl.user?.loadSpaceData(company); - } catch (_) { - // 静默失败,保留本地缓存 + } catch (e, s) { + final msg = '[relation] loadSpaceData 失败: $e\n$s'; + XLogUtil.e(msg); + SystemLog.err(msg, '后台加载'); } }); } diff --git a/lib/pages/work/work_page.dart b/lib/pages/work/work_page.dart index e727787..8fb7346 100644 --- a/lib/pages/work/work_page.dart +++ b/lib/pages/work/work_page.dart @@ -16,6 +16,8 @@ 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 'package:orginone/utils/log/log_util.dart'; +import 'package:orginone/dart/base/common/systemError.dart'; ///办事页 class WorkPage extends StatefulWidget { @@ -61,7 +63,7 @@ class _WorkPageState extends State _lastTodosCount = values.length; }); // 订阅 'work' flag:后台 ensureDataFresh(DataType.work) 完成后会发射此事件 - // (provider/index.dart 第 673 行和 756 行),通知办事页重建 model。 + // (provider/index.dart),通知办事页重建 model。 final workSub = command.subscribeByFlag('work', ([dynamic args]) { if (!mounted) return; // 事件回调可能在 build 阶段被同步派发,直接 setState 会触发 @@ -70,17 +72,34 @@ class _WorkPageState extends State if (!mounted) return; // 后台办事数据刷新完成,强制重建 model 拉取最新待办/已办/已发起等 setState(() { + _isRefreshing = false; _workModel = null; _lastFingerprint = null; }); }); }); _subscriptions.add(workSub); - relationCtrl.provider.ensureDataFresh(DataType.work); + // 触发后台刷新并显示"数据更新中"指示器 + _triggerBgRefresh(); // 办事页不跟随单位切换刷新数据,保持本地缓存优先策略, // 加速页面渲染速度。单位切换只刷新门户页面数据。 } + /// 触发后台数据刷新(带"数据更新中"指示器)。 + /// 对齐 chat_page.dart 的 _triggerBgRefresh 模式,统一 4 Tab 交互。 + void _triggerBgRefresh() { + if (!mounted) return; + setState(() => _isRefreshing = true); + relationCtrl.provider.ensureDataFresh(DataType.work).then((_) { + if (mounted) setState(() => _isRefreshing = false); + }).catchError((e, s) { + if (mounted) setState(() => _isRefreshing = false); + final msg = '[work] 后台刷新失败: $e\n$s'; + XLogUtil.e(msg); + SystemLog.err(msg, '后台刷新[work]'); + }); + } + /// 计算待办列表指纹:长度 + 每项 id:updateTime /// 用于检测长度不变但内容变化的情况(如标题修改、状态流转) String? _computeTodosFingerprint(List values) { @@ -120,7 +139,6 @@ class _WorkPageState extends State _lastFingerprint = currentFingerprint; } if (_isRefreshing) { - _isRefreshing = false; return Column( children: [ XUi.refreshingBanner(), -- Gitee