Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)

含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。
排除 node_modules、构建产物、安装包与 .env 密钥。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-02 10:04:03 +00:00
commit 76f266645d
507 changed files with 99891 additions and 0 deletions
+575
View File
@@ -0,0 +1,575 @@
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))),
),
),
);
}
}