import 'dart:async'; import 'package:flutter/material.dart'; import '../api/oa_client.dart'; import '../device/push_bridge.dart'; import '../nav/notice_route.dart'; import '../ota/updater.dart'; import '../pages/call_page.dart'; import '../session/session.dart'; import '../im/im_lifecycle.dart'; import 'desk_platform.dart'; import 'desk_theme.dart'; import 'pages/desk_chat_page.dart'; import 'pages/desk_contacts_page.dart'; import 'pages/desk_conv_list_page.dart'; import 'pages/desk_profile_page.dart'; import 'pages/desk_system_notice_page.dart'; import 'pages/desk_workbench_page.dart'; import 'widgets/desk_widgets.dart'; /// 桌面端主壳:仅引用 lib/desktop/*,不加载任何移动端页面组件。 class DeskShell extends StatefulWidget { const DeskShell({super.key, required this.session, required this.api}); final SessionStore session; final OaClient api; @override State createState() => _DeskShellState(); } class _DeskShellState extends State { int _nav = 0; int _msgBadge = 0; Timer? _badgeDebounce; Timer? _callPoll; String _ringingId = ''; bool _checkingCall = false; final _declinedCalls = {}; ImLifecycle? _imLife; String? _chatConvId; String? _chatPeerId; String _chatPeerName = ''; bool _chatGroup = false; String _chatAvatar = ''; final List<_DeskTab> _tabs = [_DeskTab.home()]; int _activeTab = 0; bool _otaOnce = false; @override void initState() { super.initState(); _imLife = ImLifecycle(widget.session)..start(); widget.session.im.onLoggedIn = () => _refreshBadge(); widget.session.im.addListener(_onIm); _refreshBadge(); PushBridge.listen(_openFromPush); _callPoll = Timer.periodic(const Duration(seconds: 8), (_) => _checkIncoming()); WidgetsBinding.instance.addPostFrameCallback((_) => _checkOta()); } Future _checkOta() async { if (_otaOnce) return; _otaOnce = true; final rel = await OtaUpdater(widget.api).fetch(); if (rel != null && rel.newer && mounted) { await OtaUpdater(widget.api).prompt(context, rel); } } @override void dispose() { _badgeDebounce?.cancel(); _callPoll?.cancel(); widget.session.im.onLoggedIn = null; _imLife?.stop(); widget.session.im.removeListener(_onIm); PushBridge.stop(); super.dispose(); } void _onIm() { final inbox = widget.session.im.inbox; if (inbox.isNotEmpty && inbox.first.kind == 'readSync') { _refreshBadge(); return; } _badgeDebounce?.cancel(); _badgeDebounce = Timer(const Duration(milliseconds: 1200), _refreshBadge); final hit = widget.session.im.inbox.where((e) => e.kind == 'call').toList(); if (hit.isNotEmpty) _checkIncoming(); } Future _refreshBadge() async { var n = 0; try { final data = await widget.api.get('/im/conversations'); for (final r in asMaps(data)) { n += (r['unread'] as num?)?.toInt() ?? 0; } } catch (_) {} if (mounted && n != _msgBadge) setState(() => _msgBadge = n); } Future _checkIncoming() async { if (!mounted || _ringingId.isNotEmpty || _checkingCall) return; _checkingCall = true; try { final rows = asMaps(await widget.api.get('/im/calls/incoming')); final me = widget.session.userId; Map? ring; for (final r in rows) { if ('${r['initiatorId']}' != me && '${r['status']}' == 'ringing') { ring = r; break; } } if (ring == null) return; final id = '${ring['id'] ?? ''}'; if (id.isEmpty || id == _ringingId || _declinedCalls.contains(id)) return; final created = DateTime.tryParse('${ring['createdAt'] ?? ''}'); if (created != null && DateTime.now().difference(created).inSeconds > 45) { _declinedCalls.add(id); return; } await _showIncoming(ring); } catch (_) { } finally { _checkingCall = false; } } Future _showIncoming(Map call, {String fallbackName = '同事'}) async { final id = '${call['id'] ?? ''}'; if (!mounted || id.isEmpty || _ringingId.isNotEmpty || _declinedCalls.contains(id)) return; _ringingId = id; await openCallPage( context, session: widget.session, api: widget.api, call: call, peerName: '${call['fromName'] ?? call['title'] ?? fallbackName}', incoming: true, ); _declinedCalls.add(id); if (_declinedCalls.length > 40) _declinedCalls.remove(_declinedCalls.first); if (mounted) _ringingId = ''; } String _openedPush = ''; void _openFromPush(Map extras) { if (extras['kind'] == 'qr-login') { // 桌面端已登录,扫码登录是手机扫电脑二维码,不应在桌面打开扫码页 return; } if (extras['kind'] == 'call') { unawaited(_openIncomingCallFromPush(extras)); return; } final cid = extras['conversationId'] ?? ''; final kind = extras['kind'] ?? ''; final key = '$cid|$kind|${extras['callId'] ?? extras['bizId'] ?? ''}'; if (key == _openedPush) return; _openedPush = key; if (kind == 'todo' || kind == 'approval' || ((extras['bizType'] ?? '').isNotEmpty && kind != 'chat')) { openNoticeTarget( context, widget.api, session: widget.session, kind: kind, bizType: extras['bizType'] ?? '', bizId: extras['bizId'] ?? '', title: extras['title'] ?? '系统通知', ); return; } if (cid.isEmpty) { setState(() => _nav = 0); return; } _pickChat({ 'id': cid, 'peerId': extras['peerId'] ?? '', 'name': extras['fromName'] ?? extras['title'] ?? '聊天', 'type': extras['isGroup'] == '1' ? 'group' : 'direct', 'avatarFileId': extras['avatarFileId'] ?? '', }); } Future _openIncomingCallFromPush(Map extras) async { final callId = (extras['callId'] ?? '').isNotEmpty ? extras['callId']! : (extras['bizId'] ?? ''); try { Map? call; if (callId.isNotEmpty) { final raw = await widget.api.get('/im/calls/$callId'); if (raw is Map) { final row = Map.from(raw); if ('${row['status'] ?? ''}' == 'ringing' && '${row['memberStatus'] ?? ''}' == 'invited') call = row; } } if (call != null && mounted) { call['fromName'] ??= extras['fromName']; await _showIncoming(call, fallbackName: extras['fromName'] ?? '同事'); return; } } catch (_) {} final cid = extras['conversationId'] ?? ''; if (mounted && cid.isNotEmpty) { _pickChat({ 'id': cid, 'peerId': extras['peerId'] ?? '', 'name': extras['fromName'] ?? extras['title'] ?? '聊天', }); } } void _pickChat(Map row) { setState(() { _nav = 0; _chatConvId = '${row['id'] ?? row['conversationId'] ?? ''}'; _chatPeerId = '${row['peerId'] ?? row['id'] ?? ''}'; _chatPeerName = '${row['name'] ?? row['peerName'] ?? '同事'}'; _chatGroup = row['type'] == 'group'; _chatAvatar = '${row['avatarFileId'] ?? ''}'; }); } void _openTab(String key, String title, IconData icon, Color color, Widget page) { final idx = _tabs.indexWhere((t) => t.key == key); setState(() { _nav = 1; if (idx >= 0) { _activeTab = idx; } else { _tabs.add(_DeskTab(key: key, title: title, icon: icon, color: color, page: page)); _activeTab = _tabs.length - 1; } }); } void _closeTab(int i) { if (i <= 0 || i >= _tabs.length) return; setState(() { _tabs.removeAt(i); if (_activeTab >= _tabs.length) _activeTab = _tabs.length - 1; }); } Widget _messagesPane() { return Row( children: [ SizedBox( width: kDeskConvWidth, child: DeskConvListPage( session: widget.session, api: widget.api, selectedId: _chatConvId, onSelect: _pickChat, ), ), const VerticalDivider(width: 1, color: kDeskLine), Expanded( child: _chatConvId == null || _chatConvId!.isEmpty ? const DeskEmptyChat() : _chatPeerId == '0' ? DeskSystemNoticePage(session: widget.session, api: widget.api, conversationId: _chatConvId!) : DeskChatPage( session: widget.session, api: widget.api, conversationId: _chatConvId!, peerId: _chatPeerId ?? '', peerName: _chatPeerName, isGroup: _chatGroup, peerAvatarFileId: _chatAvatar, ), ), ], ); } Widget _workbenchPane() { return Column( children: [ DeskTabStrip( tabs: [for (final t in _tabs) (t.key, t.title, t.icon, t.color)], active: _activeTab, onSelect: (i) => setState(() => _activeTab = i), onClose: _closeTab, ), Expanded( child: IndexedStack( index: _activeTab, children: [ for (final t in _tabs) t.key == 'home' ? DeskWorkbenchPage(session: widget.session, api: widget.api, onOpenTab: _openTab) : t.page, ], ), ), ], ); } @override Widget build(BuildContext context) { final navItems = <(IconData, IconData, String, int)>[ (Icons.chat_bubble_outline, Icons.chat_bubble, '消息', _msgBadge), (Icons.apps_outlined, Icons.apps, '工作台', 0), (Icons.account_tree_outlined, Icons.account_tree, '通讯录', 0), (Icons.person_outline, Icons.person, '我', 0), ]; Widget body; switch (_nav) { case 0: body = _messagesPane(); case 1: body = _workbenchPane(); case 2: body = DeskContactsPage(session: widget.session, api: widget.api, onMessage: (r) { _pickChat({ 'id': '${r['conversationId'] ?? ''}', 'peerId': '${r['id'] ?? r['peerId'] ?? ''}', 'name': '${r['name'] ?? '同事'}', 'avatarFileId': '${r['avatarFileId'] ?? ''}', 'type': 'direct', }); setState(() => _nav = 0); }); default: body = DeskProfilePage(session: widget.session, api: widget.api); } return Scaffold( backgroundColor: kDeskBg, body: Row( children: [ DeskNavRail( session: widget.session, api: widget.api, index: _nav, onChanged: (i) => setState(() => _nav = i), items: navItems, ), const VerticalDivider(width: 1, color: kDeskLine), Expanded(child: body), ], ), ); } } class _DeskTab { _DeskTab({required this.key, required this.title, required this.icon, required this.color, required this.page}); factory _DeskTab.home() => _DeskTab(key: 'home', title: '工作台', icon: Icons.apps, color: kDeskBind, page: const SizedBox.shrink()); final String key; final String title; final IconData icon; final Color color; final Widget page; } bool get isDesktopShell => isDesktopPlatform;