76f266645d
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
370 lines
11 KiB
Dart
370 lines
11 KiB
Dart
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<DeskShell> createState() => _DeskShellState();
|
|
}
|
|
|
|
class _DeskShellState extends State<DeskShell> {
|
|
int _nav = 0;
|
|
int _msgBadge = 0;
|
|
Timer? _badgeDebounce;
|
|
Timer? _callPoll;
|
|
String _ringingId = '';
|
|
bool _checkingCall = false;
|
|
final _declinedCalls = <String>{};
|
|
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<void> _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<void> _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<void> _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<String, dynamic>? 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<void> _showIncoming(Map<String, dynamic> 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<String, String> 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<void> _openIncomingCallFromPush(Map<String, String> extras) async {
|
|
final callId = (extras['callId'] ?? '').isNotEmpty ? extras['callId']! : (extras['bizId'] ?? '');
|
|
try {
|
|
Map<String, dynamic>? call;
|
|
if (callId.isNotEmpty) {
|
|
final raw = await widget.api.get('/im/calls/$callId');
|
|
if (raw is Map) {
|
|
final row = Map<String, dynamic>.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<String, dynamic> 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;
|