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:
@@ -0,0 +1,344 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/oa_client.dart';
|
||||
import '../im/chat_prefs.dart';
|
||||
import '../session/session.dart';
|
||||
import '../theme.dart';
|
||||
import '../widgets/wecom.dart';
|
||||
import 'pick_people_page.dart';
|
||||
|
||||
class ChatDetailPage extends StatefulWidget {
|
||||
const ChatDetailPage({
|
||||
super.key,
|
||||
required this.session,
|
||||
required this.api,
|
||||
required this.conversationId,
|
||||
required this.peerId,
|
||||
required this.peerName,
|
||||
required this.isGroup,
|
||||
this.peerAvatarFileId,
|
||||
this.messages = const [],
|
||||
this.onCall,
|
||||
});
|
||||
final SessionStore session;
|
||||
final OaClient api;
|
||||
final String conversationId;
|
||||
final String peerId;
|
||||
final String peerName;
|
||||
final bool isGroup;
|
||||
final String? peerAvatarFileId;
|
||||
final List<Map<String, dynamic>> messages;
|
||||
final void Function(String kind)? onCall;
|
||||
|
||||
@override
|
||||
State<ChatDetailPage> createState() => _ChatDetailPageState();
|
||||
}
|
||||
|
||||
class _ChatDetailPageState extends State<ChatDetailPage> {
|
||||
List<Map<String, dynamic>> _members = [];
|
||||
bool _mute = false;
|
||||
bool _pin = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_mute = ChatPrefs.muted(widget.conversationId);
|
||||
_pin = ChatPrefs.pinned(widget.conversationId);
|
||||
_loadMembers();
|
||||
}
|
||||
|
||||
Future<void> _loadMembers() async {
|
||||
if (widget.isGroup && widget.conversationId.isNotEmpty) {
|
||||
try {
|
||||
final data = await widget.api.get('/im/groups/${widget.conversationId}/members');
|
||||
if (!mounted) return;
|
||||
setState(() => _members = asMaps(data));
|
||||
return;
|
||||
} catch (_) {}
|
||||
}
|
||||
setState(() {
|
||||
_members = [
|
||||
{
|
||||
'id': widget.peerId,
|
||||
'name': widget.peerName,
|
||||
'avatarFileId': widget.peerAvatarFileId,
|
||||
},
|
||||
{
|
||||
'id': widget.session.userId,
|
||||
'name': widget.session.displayName,
|
||||
'avatarFileId': widget.session.user['avatarFileId'],
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _addMembers() async {
|
||||
final exclude = {
|
||||
widget.session.userId,
|
||||
..._members.map((e) => '${e['id'] ?? e['userId'] ?? ''}'),
|
||||
};
|
||||
final picked = await Navigator.of(context).push<List<Map<String, dynamic>>>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PickPeoplePage(api: widget.api, title: '选择同事', exclude: exclude),
|
||||
),
|
||||
);
|
||||
if (picked == null || picked.isEmpty) return;
|
||||
try {
|
||||
if (widget.isGroup && widget.conversationId.isNotEmpty) {
|
||||
await widget.api.post('/im/groups/${widget.conversationId}/members', {
|
||||
'memberIds': picked.map((e) => '${e['id']}').toList(),
|
||||
});
|
||||
await _loadMembers();
|
||||
} else {
|
||||
await widget.api.post('/im/groups', {
|
||||
'name': '${widget.peerName}的群聊',
|
||||
'memberIds': [widget.peerId, ...picked.map((e) => '${e['id']}')],
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已创建群聊')));
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _search() async {
|
||||
await Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => _ChatSearchPage(messages: widget.messages, peerName: widget.peerName),
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> _pickBg() async {
|
||||
const colors = [
|
||||
0xFFF5F5F5,
|
||||
0xFFE7F0E4,
|
||||
0xFFE8EEF6,
|
||||
0xFFF6EFE6,
|
||||
0xFFF3E8EE,
|
||||
];
|
||||
final picked = await showModalBottomSheet<int>(
|
||||
context: context,
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
for (final c in colors)
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(ctx, c),
|
||||
child: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: Color(c),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFFDDDDDD)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (picked == null) return;
|
||||
await ChatPrefs.setBg(widget.conversationId, picked);
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _clear() async {
|
||||
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) return;
|
||||
await ChatPrefs.clearHistory(widget.conversationId);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已删除本机聊天记录')));
|
||||
Navigator.pop(context, 'cleared');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: kPaper,
|
||||
appBar: AppBar(title: const Text('聊天详情')),
|
||||
body: ListView(
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
color: Colors.white,
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
for (final m in _members)
|
||||
SizedBox(
|
||||
width: 56,
|
||||
child: Column(
|
||||
children: [
|
||||
SquareAvatar(
|
||||
label: '${m['name'] ?? m['displayName'] ?? ''}',
|
||||
size: 48,
|
||||
fileId: '${m['avatarFileId'] ?? ''}',
|
||||
api: widget.api,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${m['name'] ?? m['displayName'] ?? ''}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12, color: kInk),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 56,
|
||||
child: Column(
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: _addMembers,
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: const Color(0xFFC8C8C8), style: BorderStyle.solid),
|
||||
),
|
||||
child: const Icon(Icons.add, color: kMute),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(' ', style: TextStyle(fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
CellGroup(
|
||||
children: [
|
||||
Cell(title: '查找聊天内容', onTap: _search, showLine: false),
|
||||
],
|
||||
),
|
||||
CellGroup(
|
||||
children: [
|
||||
Cell(
|
||||
title: '消息免打扰',
|
||||
trailing: Switch(
|
||||
value: _mute,
|
||||
activeTrackColor: kWeGreen,
|
||||
onChanged: (v) async {
|
||||
await ChatPrefs.setMuted(widget.conversationId, v);
|
||||
setState(() => _mute = v);
|
||||
},
|
||||
),
|
||||
),
|
||||
Cell(
|
||||
title: '置顶聊天',
|
||||
trailing: Switch(
|
||||
value: _pin,
|
||||
activeTrackColor: kWeGreen,
|
||||
onChanged: (v) async {
|
||||
await ChatPrefs.setPinned(widget.conversationId, v);
|
||||
setState(() => _pin = v);
|
||||
},
|
||||
),
|
||||
showLine: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
CellGroup(
|
||||
children: [
|
||||
Cell(title: '设置当前聊天背景', onTap: _pickBg),
|
||||
Cell(title: '语音通话', onTap: () => widget.onCall?.call('audio')),
|
||||
Cell(
|
||||
title: '视频通话',
|
||||
onTap: () => widget.onCall?.call(widget.isGroup ? 'meeting' : 'video'),
|
||||
showLine: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
CellGroup(
|
||||
children: [
|
||||
Cell(title: '删除聊天记录', onTap: _clear, showLine: false),
|
||||
],
|
||||
),
|
||||
CellGroup(
|
||||
children: [
|
||||
Cell(
|
||||
title: '投诉',
|
||||
onTap: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已记录投诉,我们会尽快处理')));
|
||||
},
|
||||
showLine: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatSearchPage extends StatefulWidget {
|
||||
const _ChatSearchPage({required this.messages, required this.peerName});
|
||||
final List<Map<String, dynamic>> messages;
|
||||
final String peerName;
|
||||
|
||||
@override
|
||||
State<_ChatSearchPage> createState() => _ChatSearchPageState();
|
||||
}
|
||||
|
||||
class _ChatSearchPageState extends State<_ChatSearchPage> {
|
||||
String _q = '';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final q = _q.trim();
|
||||
final hits = q.isEmpty
|
||||
? const <Map<String, dynamic>>[]
|
||||
: widget.messages.where((e) => '${e['body'] ?? ''}'.contains(q)).toList();
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(title: const Text('查找聊天内容')),
|
||||
body: Column(
|
||||
children: [
|
||||
WxSearchBar(hint: '搜索', onChanged: (v) => setState(() => _q = v)),
|
||||
Expanded(
|
||||
child: q.isEmpty
|
||||
? const Center(child: Text('输入关键词查找聊天内容', style: TextStyle(color: kMute)))
|
||||
: hits.isEmpty
|
||||
? const Center(child: Text('没有找到相关内容', style: TextStyle(color: kMute)))
|
||||
: ListView(
|
||||
children: [
|
||||
for (final r in hits)
|
||||
Cell(
|
||||
title: '${r['body']}',
|
||||
subtitle: '${r['fromName'] ?? widget.peerName} ${r['createdAt'] ?? ''}',
|
||||
showLine: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user