Files
Public/apps/native/lib/pages/messages_page.dart
T
daiyongkang 76f266645d Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。
排除 node_modules、构建产物、安装包与 .env 密钥。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 10:04:03 +00:00

576 lines
22 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../im/chat_prefs.dart';
import '../pages/chat_page.dart';
import '../pages/notices_page.dart';
import '../pages/pick_people_page.dart';
import '../pages/scan_login_page.dart';
import '../pages/system_notice_page.dart';
import '../session/session.dart';
import '../theme.dart';
import '../widgets/common.dart';
import '../widgets/desktop_ui.dart';
import '../widgets/wecom.dart';
class MessagesPage extends StatefulWidget {
const MessagesPage({
super.key,
required this.session,
required this.api,
this.desktopPane = false,
this.desktopStyle = false,
this.selectedId,
this.onSelectConv,
});
final SessionStore session;
final OaClient api;
final bool desktopPane;
final bool desktopStyle;
final String? selectedId;
final void Function(Map<String, dynamic> row)? onSelectConv;
@override
State<MessagesPage> createState() => _MessagesPageState();
}
class _MessagesPageState extends State<MessagesPage> {
List<Map<String, dynamic>> _items = [];
bool _loading = true;
String _err = '';
String _filter = 'all';
String _q = '';
bool _showSearch = false;
Timer? _imDebounce;
@override
void initState() {
super.initState();
widget.session.im.addListener(_onIm);
ChatPrefs.ensure().then((_) {
if (mounted) setState(() {});
});
_load();
}
@override
void dispose() {
_imDebounce?.cancel();
widget.session.im.removeListener(_onIm);
super.dispose();
}
void _onIm() {
_imDebounce?.cancel();
_imDebounce = Timer(const Duration(milliseconds: 250), () {
if (mounted) _load();
});
}
Future<void> _load() async {
try {
final data = await widget.api.get('/im/conversations');
if (!mounted) return;
setState(() {
_items = asMaps(data);
_loading = false;
_err = '';
});
} catch (e) {
if (!mounted) return;
setState(() {
_loading = false;
if (_items.isEmpty) _err = '$e';
});
}
}
int get _unread {
var n = 0;
for (final r in _items) {
if (ChatPrefs.muted('${r['id'] ?? ''}')) continue;
final id = '${r['id'] ?? ''}';
final fake = ChatPrefs.fakeUnread(id);
if (fake > 0) {
n += fake;
continue;
}
n += (r['unread'] as num?)?.toInt() ?? 0;
}
return n;
}
Future<void> _showDesktopConvMenu(Map<String, dynamic> r, Offset pos) async {
final id = '${r['id'] ?? ''}';
if (id.isEmpty) return;
final muted = ChatPrefs.muted(id);
final pinned = ChatPrefs.pinned(id);
final unread = (r['unread'] as num?)?.toInt() ?? 0;
final fake = ChatPrefs.fakeUnread(id);
final selected = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(pos.dx, pos.dy, pos.dx + 1, pos.dy + 1),
items: [
if (unread == 0 && fake == 0)
const PopupMenuItem(value: 'unread', child: Text('标为未读')),
PopupMenuItem(value: 'mute', child: Text(muted ? '取消免打扰' : '消息免打扰')),
PopupMenuItem(value: 'pin', child: Text(pinned ? '取消置顶' : '置顶')),
const PopupMenuItem(value: 'hide', child: Text('不显示')),
const PopupMenuDivider(),
const PopupMenuItem(value: 'clear', child: Text('清空聊天记录')),
const PopupMenuItem(value: 'delete', child: Text('删除')),
],
);
if (!mounted || selected == null) return;
switch (selected) {
case 'unread':
await ChatPrefs.setFakeUnread(id, 1);
case 'mute':
await ChatPrefs.setMuted(id, !muted);
case 'pin':
await ChatPrefs.setPinned(id, !pinned);
case 'hide':
await ChatPrefs.setHidden(id, true);
case 'clear':
await ChatPrefs.clearHistory(id);
case 'delete':
await ChatPrefs.setHidden(id, true);
}
if (mounted) {
setState(() {});
await _load();
}
}
Widget _wrapConv(Map<String, dynamic> r, Widget child) {
if (!widget.desktopStyle) return child;
return GestureDetector(
onSecondaryTapDown: (d) => _showDesktopConvMenu(r, d.globalPosition),
child: child,
);
}
Future<void> _newChat({required bool group}) async {
final picked = await Navigator.of(context).push<List<Map<String, dynamic>>>(
MaterialPageRoute(
builder: (_) => PickPeoplePage(
api: widget.api,
title: group ? '选择联系人' : '发起单聊',
multiple: group,
exclude: {widget.session.userId},
),
),
);
if (picked == null || picked.isEmpty) return;
if (!group) {
final p = picked.first;
if (!mounted) return;
await Navigator.of(context).push(MaterialPageRoute(
builder: (_) => ChatPage(
session: widget.session,
api: widget.api,
peerId: '${p['id']}',
peerName: '${p['name'] ?? '同事'}',
peerAvatarFileId: '${p['avatarFileId'] ?? ''}',
),
));
_load();
return;
}
final name = TextEditingController();
if (!mounted) return;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('群名称'),
content: TextField(
controller: name,
decoration: const InputDecoration(hintText: '例如:三维项目组')),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('取消')),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('创建')),
],
),
);
if (ok == true) {
await widget.api.post('/im/groups', {
'name': name.text.trim().isEmpty ? '群聊' : name.text.trim(),
'memberIds': picked.map((e) => '${e['id']}').toList(),
});
await _load();
}
}
List<Map<String, dynamic>> get _shown {
var list = _items;
// 只有用户明确执行“不显示/删除”后才隐藏;阅读状态不会改变会话是否存在。
list = list.where((e) {
final id = '${e['id'] ?? ''}';
return !ChatPrefs.hidden(id);
}).toList();
if (_filter == 'unread')
list =
list.where((e) => ((e['unread'] as num?)?.toInt() ?? 0) > 0).toList();
if (_filter == 'dm')
list = list.where((e) => e['type'] != 'group').toList();
if (_filter == 'group')
list = list.where((e) => e['type'] == 'group').toList();
if (_q.isNotEmpty) {
list = list
.where((e) =>
'${e['name']}${e['peerName']}${e['lastText']}'.contains(_q))
.toList();
}
list = [...list]..sort((a, b) {
final ap = ChatPrefs.pinned('${a['id'] ?? ''}');
final bp = ChatPrefs.pinned('${b['id'] ?? ''}');
if (ap == bp) return 0;
return ap ? -1 : 1;
});
return list;
}
@override
Widget build(BuildContext context) {
final header = widget.desktopStyle
? DesktopPaneHeader(
title: _unread > 0 ? '消息($_unread)' : '消息',
actions: [
IconButton(
onPressed: () => setState(() => _showSearch = !_showSearch),
icon: const Icon(Icons.search, color: kInk, size: 20),
),
Builder(
builder: (ctx) => IconButton(
onPressed: () => showPlusMenu(ctx, [
(Icons.qr_code_scanner_outlined, '扫一扫登录电脑', () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => ScanLoginPage(api: widget.api)))),
(Icons.chat_bubble_outline, '发起单聊', () => _newChat(group: false)),
(Icons.group_outlined, '发起群聊', () => _newChat(group: true)),
]),
icon: const Icon(Icons.add, color: kInk, size: 22),
),
),
],
bottom: Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 10),
child: Column(
children: [
if (_showSearch) ...[
DesktopSearchBar(hint: '搜索', onChanged: (v) => setState(() => _q = v.trim())),
const SizedBox(height: 8),
],
Row(
children: [
_filterChip('all', '全部'),
const SizedBox(width: 8),
_filterChip('unread', '未读'),
const SizedBox(width: 8),
_filterChip('dm', '单聊'),
const SizedBox(width: 8),
_filterChip('group', '群聊'),
],
),
],
),
),
)
: Column(
children: [
WxHeader(
title: _unread > 0 ? '消息($_unread)' : '消息',
actions: [
IconButton(
onPressed: () => setState(() => _showSearch = !_showSearch),
icon: const Icon(Icons.search, color: kInk),
),
Builder(
builder: (ctx) => IconButton(
onPressed: () => showPlusMenu(ctx, [
(Icons.qr_code_scanner_outlined, '扫一扫登录电脑', () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => ScanLoginPage(api: widget.api)))),
(Icons.chat_bubble_outline, '发起单聊', () => _newChat(group: false)),
(Icons.group_outlined, '发起群聊', () => _newChat(group: true)),
]),
icon: const Icon(Icons.add_circle_outline, color: kInk),
),
),
],
),
if (_showSearch) WxSearchBar(hint: '搜索', onChanged: (v) => setState(() => _q = v.trim())),
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 4),
child: Row(
children: [
_filterChip('all', '全部'),
const SizedBox(width: 8),
_filterChip('unread', '未读'),
const SizedBox(width: 8),
_filterChip('dm', '单聊'),
const SizedBox(width: 8),
_filterChip('group', '群聊'),
],
),
),
],
);
return ColoredBox(
color: Colors.white,
child: Column(
children: [
header,
if (_loading)
const LinearProgressIndicator(minHeight: 2, color: kBind),
if (_err.isNotEmpty)
Padding(
padding: const EdgeInsets.all(16),
child: Text(_err, style: const TextStyle(color: kDanger))),
Expanded(
child: RefreshIndicator(
onRefresh: _load,
child: ListView(
padding: EdgeInsets.zero,
children: [
ConvTile(
title: '公告',
preview: '公司通知与制度',
avatarIcon: Icons.campaign,
avatarColor: const Color(0xFFE75D5D),
avatarLabel: '告',
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => NoticesPage(api: widget.api),
)),
),
if (_shown.isEmpty && !_loading)
const Padding(
padding: EdgeInsets.only(top: 48),
child: EmptyHint('还没有会话。点右上角 + 或到通讯录找同事。'),
),
for (final r in _shown)
_wrapConv(
r,
widget.desktopStyle
? ConvTile(
title: '${r['name'] ?? r['peerName'] ?? '同事'}',
preview: '${r['lastText'] ?? ''}',
time: shortTime(r['lastAt']),
muted: ChatPrefs.muted('${r['id'] ?? ''}'),
unread: ChatPrefs.muted('${r['id'] ?? ''}')
? 0
: (ChatPrefs.fakeUnread('${r['id'] ?? ''}') > 0
? ChatPrefs.fakeUnread('${r['id'] ?? ''}')
: ((r['unread'] as num?)?.toInt() ?? 0)),
avatarLabel: '${r['peerId']}' == '0' ? '?' : '${r['name'] ?? r['peerName'] ?? '同'}',
avatarIcon: '${r['peerId']}' == '0' ? Icons.notifications : (r['type'] == 'group' ? Icons.groups : null),
avatarColor: '${r['peerId']}' == '0' ? const Color(0xFF07C160) : (r['type'] == 'group' ? const Color(0xFF07C160) : null),
avatarFileId: r['type'] == 'group' ? null : '${r['avatarFileId'] ?? ''}',
api: widget.api,
selected: widget.desktopPane && widget.selectedId == '${r['id'] ?? ''}',
onTap: () async {
final id = '${r['id'] ?? ''}';
if (id.isNotEmpty) await ChatPrefs.setFakeUnread(id, 0);
if (widget.desktopPane && widget.onSelectConv != null) {
widget.onSelectConv!(r);
return;
}
await Navigator.of(context).push(MaterialPageRoute(
builder: (_) => '${r['peerId']}' == '0'
? SystemNoticePage(session: widget.session, api: widget.api, conversationId: id)
: ChatPage(
session: widget.session,
api: widget.api,
peerId: '${r['peerId'] ?? ''}',
conversationId: id,
peerName: '${r['name'] ?? r['peerName'] ?? '同事'}',
isGroup: r['type'] == 'group',
peerAvatarFileId: '${r['avatarFileId'] ?? ''}',
),
));
_load();
},
)
: _SwipeConversation(
id: '${r['id'] ?? ''}',
api: widget.api,
onChanged: _load,
child: ConvTile(
title: '${r['name'] ?? r['peerName'] ?? '同事'}',
preview: '${r['lastText'] ?? ''}',
time: shortTime(r['lastAt']),
muted: ChatPrefs.muted('${r['id'] ?? ''}'),
unread: ChatPrefs.muted('${r['id'] ?? ''}')
? 0
: ((r['unread'] as num?)?.toInt() ?? 0),
avatarLabel: '${r['peerId']}' == '0'
? '?'
: '${r['name'] ?? r['peerName'] ?? '同'}',
avatarIcon: '${r['peerId']}' == '0'
? Icons.notifications
: (r['type'] == 'group' ? Icons.groups : null),
avatarColor: '${r['peerId']}' == '0'
? const Color(0xFF07C160)
: (r['type'] == 'group'
? const Color(0xFF07C160)
: null),
avatarFileId: r['type'] == 'group'
? null
: '${r['avatarFileId'] ?? ''}',
api: widget.api,
selected: widget.desktopPane && widget.selectedId == '${r['id'] ?? ''}',
onTap: () async {
if (widget.desktopPane && widget.onSelectConv != null) {
widget.onSelectConv!(r);
return;
}
await Navigator.of(context).push(MaterialPageRoute(
builder: (_) => '${r['peerId']}' == '0'
? SystemNoticePage(session: widget.session, api: widget.api, conversationId: '${r['id'] ?? ''}')
: ChatPage(
session: widget.session,
api: widget.api,
peerId: '${r['peerId'] ?? ''}',
conversationId: '${r['id'] ?? ''}',
peerName: '${r['name'] ?? r['peerName'] ?? '同事'}',
isGroup: r['type'] == 'group',
peerAvatarFileId: '${r['avatarFileId'] ?? ''}',
),
));
_load();
},
),
),
),
const SizedBox(height: 24),
],
),
),
),
],
),
);
}
Widget _filterChip(String id, String label) {
final on = _filter == id;
return GestureDetector(
onTap: () => setState(() => _filter = id),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: on ? const Color(0xFFE8F3FF) : Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
child: Text(label,
style: TextStyle(
fontSize: 13,
color: on ? kBind : kMute,
fontWeight: on ? FontWeight.w600 : FontWeight.w400)),
),
);
}
}
/// 微信式左滑操作栏:操作不会误删服务器会话,删除仅隐藏本机列表。
class _SwipeConversation extends StatefulWidget {
const _SwipeConversation(
{required this.id,
required this.api,
required this.child,
required this.onChanged});
final String id;
final OaClient api;
final Widget child;
final Future<void> Function() onChanged;
@override
State<_SwipeConversation> createState() => _SwipeConversationState();
}
class _SwipeConversationState extends State<_SwipeConversation> {
double _offset = 0;
static const _width = 216.0;
Future<void> _hide() async {
if (!mounted) return;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('隐藏会话'),
content: const Text('仅从消息列表隐藏,不会删除聊天记录。确定继续吗?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定')),
],
),
);
if (ok != true) {
if (mounted) setState(() => _offset = 0);
return;
}
await ChatPrefs.setHidden(widget.id, true);
await widget.onChanged();
}
Future<void> _toggleMute() async {
await ChatPrefs.setMuted(widget.id, !ChatPrefs.muted(widget.id));
if (mounted) setState(() => _offset = 0);
await widget.onChanged();
}
@override
Widget build(BuildContext context) {
return SizedBox(
height: 76,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragUpdate: (d) => setState(() => _offset = (_offset + d.delta.dx).clamp(-_width, 0)),
onHorizontalDragEnd: (_) => setState(() => _offset = _offset < -80 ? -_width : 0),
child: Stack(
fit: StackFit.expand,
children: [
Align(
alignment: Alignment.centerRight,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_action('免打扰', const Color(0xFFFFA940), _toggleMute),
_action('不显示', const Color(0xFF8C8C8C), _hide),
_action('删除', const Color(0xFFF5222D), _hide),
],
),
),
AnimatedContainer(
duration: const Duration(milliseconds: 160),
curve: Curves.easeOut,
transform: Matrix4.translationValues(_offset, 0, 0),
color: Colors.white,
child: widget.child,
),
],
),
),
);
}
Widget _action(String label, Color color, Future<void> Function() onTap) {
return SizedBox(
width: 72,
height: double.infinity,
child: Material(
color: color,
child: InkWell(
onTap: () async => onTap(),
child: Center(
child: Text(label,
style: const TextStyle(color: Colors.white, fontSize: 13))),
),
),
);
}
}