76f266645d
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
406 lines
18 KiB
Dart
406 lines
18 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../api/oa_client.dart';
|
|
import '../im/pinyin.dart';
|
|
import '../session/session.dart';
|
|
import '../theme.dart';
|
|
import '../widgets/common.dart';
|
|
import '../widgets/desktop_ui.dart';
|
|
import '../widgets/wecom.dart';
|
|
import 'chat_page.dart';
|
|
import 'org_browse_page.dart';
|
|
|
|
class DirectoryPage extends StatefulWidget {
|
|
const DirectoryPage({super.key, required this.session, required this.api, this.embedded = false, this.desktopStyle = false});
|
|
final SessionStore session;
|
|
final OaClient api;
|
|
final bool embedded;
|
|
final bool desktopStyle;
|
|
|
|
@override
|
|
State<DirectoryPage> createState() => _DirectoryPageState();
|
|
}
|
|
|
|
class _DirectoryPageState extends State<DirectoryPage> {
|
|
List<Map<String, dynamic>> _items = [];
|
|
Map<String, List<String>> _presence = {};
|
|
String _q = '';
|
|
bool _loading = true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_load();
|
|
}
|
|
|
|
Future<void> _load() async {
|
|
try {
|
|
final data = await widget.api.get('/staff');
|
|
Map<String, List<String>> presence = {};
|
|
try {
|
|
final ids = asMaps(data).map((e) => '${e['id']}').where((e) => e.isNotEmpty).join(',');
|
|
final raw = await widget.api.get('/im/presence', query: {'userIds': ids});
|
|
if (raw is Map) {
|
|
presence = raw.map((k, v) => MapEntry('$k', (v is List ? v : const []).map((x) => '$x').toList()));
|
|
}
|
|
} catch (_) {}
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_items = asMaps(data);
|
|
_presence = presence;
|
|
_loading = false;
|
|
});
|
|
} catch (_) {
|
|
if (mounted) setState(() => _loading = false);
|
|
}
|
|
}
|
|
|
|
void _openPerson(Map<String, dynamic> r) {
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
backgroundColor: kPaper,
|
|
builder: (ctx) => SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 20, 16, 20),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
SquareAvatar(
|
|
label: '${r['name'] ?? ''}',
|
|
size: 64,
|
|
fileId: '${r['avatarFileId'] ?? ''}',
|
|
api: widget.api,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text('${r['name'] ?? ''}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600)),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
[r['department'], r['title']].where((e) => e != null && '$e'.isNotEmpty).join(' · '),
|
|
style: const TextStyle(color: kMute),
|
|
),
|
|
const SizedBox(height: 16),
|
|
CellGroup(
|
|
children: [
|
|
Cell(title: '手机', subtitle: '${r['mobile'] ?? '未填'}', showLine: true),
|
|
Cell(title: '邮箱', subtitle: '${r['email'] ?? '未填'}', showLine: false),
|
|
],
|
|
),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: FilledButton(
|
|
onPressed: () async {
|
|
Navigator.pop(ctx);
|
|
String conversationId = '';
|
|
// 先让服务端创建/登记单聊会话,避免双方尚未发送消息时列表没有会话记录。
|
|
try {
|
|
final raw = await widget.api.post('/im/conversations/direct', {'peerId': '${r['id']}'});
|
|
if (raw is Map) conversationId = '${raw['conversationId'] ?? raw['id'] ?? ''}';
|
|
} catch (_) {}
|
|
await Navigator.of(context).push(MaterialPageRoute(
|
|
builder: (_) => ChatPage(
|
|
session: widget.session,
|
|
api: widget.api,
|
|
peerId: '${r['id']}',
|
|
conversationId: conversationId,
|
|
peerName: '${r['name'] ?? '同事'}',
|
|
peerAvatarFileId: '${r['avatarFileId'] ?? ''}',
|
|
),
|
|
));
|
|
widget.session.im.bump();
|
|
},
|
|
child: const Padding(padding: EdgeInsets.symmetric(vertical: 10), child: Text('发消息')),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _desktopBody(
|
|
List<Map<String, dynamic>> shown,
|
|
Map<String, List<Map<String, dynamic>>> groups,
|
|
List<String> letters,
|
|
Set<String> depts,
|
|
) {
|
|
if (_loading) return const LinearProgressIndicator(minHeight: 2, color: kBind);
|
|
if (shown.isEmpty) return const Center(child: EmptyHint('没有匹配的同事'));
|
|
return RefreshIndicator(
|
|
onRefresh: _load,
|
|
child: Stack(
|
|
children: [
|
|
ListView(
|
|
padding: const EdgeInsets.fromLTRB(20, 12, 28, 24),
|
|
children: [
|
|
if (_q.isEmpty)
|
|
Container(
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10)),
|
|
child: Column(
|
|
children: [
|
|
Cell(
|
|
title: '组织架构',
|
|
subtitle: '${depts.length} 个部门',
|
|
leading: const SquareAvatar(label: '组', color: kBind, icon: Icons.account_tree, size: 36),
|
|
onTap: () => Navigator.of(context).push(MaterialPageRoute(
|
|
builder: (_) => OrgBrowsePage(session: widget.session, api: widget.api, staff: _items),
|
|
)),
|
|
),
|
|
Cell(
|
|
title: '企业通讯录',
|
|
subtitle: '${_items.length} 人',
|
|
leading: const SquareAvatar(label: '通', color: Color(0xFF07C160), icon: Icons.contacts, size: 36),
|
|
showLine: false,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
for (final letter in letters) ...[
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(4, 8, 4, 6),
|
|
child: Text(letter, style: const TextStyle(fontSize: 13, color: kMute, fontWeight: FontWeight.w600)),
|
|
),
|
|
Container(
|
|
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10)),
|
|
child: Column(
|
|
children: [
|
|
for (final r in groups[letter]!)
|
|
InkWell(
|
|
onTap: () => _openPerson(r),
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(14, 12, 14, 0),
|
|
child: Row(
|
|
children: [
|
|
SquareAvatar(label: '${r['name'] ?? ''}', fileId: '${r['avatarFileId'] ?? ''}', api: widget.api),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Container(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: kLine, width: 0.5))),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Text('${r['name'] ?? ''}', style: const TextStyle(fontSize: 16, color: kInk)),
|
|
const SizedBox(width: 6),
|
|
Container(width: 6, height: 6, decoration: BoxDecoration(shape: BoxShape.circle, color: (_presence['${r['id']}'] ?? const []).isNotEmpty ? const Color(0xFF22C55E) : const Color(0xFFB8BEC8))),
|
|
const SizedBox(width: 3),
|
|
Text((_presence['${r['id']}'] ?? const []).isNotEmpty ? '在线' : '离线', style: const TextStyle(fontSize: 11, color: kMute)),
|
|
],
|
|
),
|
|
Text(
|
|
[r['department'], r['title']].where((e) => e != null && '$e'.isNotEmpty).join(' · '),
|
|
style: const TextStyle(fontSize: 12, color: kMute),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
Align(
|
|
alignment: Alignment.centerRight,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(right: 8),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [for (final l in letters) Text(l, style: const TextStyle(fontSize: 10, color: kBind, height: 1.35))],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final me = widget.session.userId;
|
|
final shown = _items.where((e) {
|
|
if ('${e['id']}' == me) return false;
|
|
if (_q.isEmpty) return true;
|
|
return '${e['name']}${e['department']}${e['mobile']}${e['email']}${e['title']}'.contains(_q);
|
|
}).toList()
|
|
..sort((a, b) {
|
|
final la = letterOf('${a['name']}');
|
|
final lb = letterOf('${b['name']}');
|
|
final c = la.compareTo(lb);
|
|
if (c != 0) return c;
|
|
return '${a['name']}'.compareTo('${b['name']}');
|
|
});
|
|
|
|
final groups = <String, List<Map<String, dynamic>>>{};
|
|
for (final r in shown) {
|
|
groups.putIfAbsent(letterOf('${r['name']}'), () => []).add(r);
|
|
}
|
|
final letters = groups.keys.toList()
|
|
..sort((a, b) {
|
|
if (a == '#') return 1;
|
|
if (b == '#') return -1;
|
|
return a.compareTo(b);
|
|
});
|
|
final depts = <String>{};
|
|
for (final r in _items) {
|
|
final d = '${r['department'] ?? ''}';
|
|
if (d.isNotEmpty) depts.add(d);
|
|
}
|
|
|
|
final body = widget.desktopStyle
|
|
? _desktopBody(shown, groups, letters, depts)
|
|
: Column(
|
|
children: [
|
|
WxSearchBar(hint: '搜索同事、部门、手机', onChanged: (v) => setState(() => _q = v.trim())),
|
|
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kBind),
|
|
Expanded(
|
|
child: shown.isEmpty
|
|
? const EmptyHint('没有匹配的同事')
|
|
: RefreshIndicator(
|
|
onRefresh: _load,
|
|
child: Stack(
|
|
children: [
|
|
ListView(
|
|
children: [
|
|
if (_q.isEmpty) ...[
|
|
CellGroup(
|
|
children: [
|
|
Cell(
|
|
title: '组织架构',
|
|
subtitle: '${depts.length} 个部门',
|
|
leading: const SquareAvatar(label: '组', color: kBind, icon: Icons.account_tree, size: 36),
|
|
onTap: () => Navigator.of(context).push(MaterialPageRoute(
|
|
builder: (_) => OrgBrowsePage(
|
|
session: widget.session,
|
|
api: widget.api,
|
|
staff: _items,
|
|
),
|
|
)),
|
|
),
|
|
Cell(
|
|
title: '企业通讯录',
|
|
subtitle: '${_items.length} 人',
|
|
leading: const SquareAvatar(label: '通', color: Color(0xFF07C160), icon: Icons.contacts, size: 36),
|
|
showLine: false,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
for (final letter in letters) ...[
|
|
Container(
|
|
width: double.infinity,
|
|
color: kPaper,
|
|
padding: const EdgeInsets.fromLTRB(16, 6, 16, 6),
|
|
child: Text(letter, style: const TextStyle(fontSize: 13, color: kMute, fontWeight: FontWeight.w600)),
|
|
),
|
|
for (final r in groups[letter]!)
|
|
InkWell(
|
|
onTap: () => _openPerson(r),
|
|
child: Container(
|
|
color: Colors.white,
|
|
padding: const EdgeInsets.fromLTRB(12, 10, 28, 0),
|
|
child: Row(
|
|
children: [
|
|
Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
SquareAvatar(label: '${r['name'] ?? ''}', fileId: '${r['avatarFileId'] ?? ''}', api: widget.api),
|
|
if ((_presence['${r['id']}'] ?? const []).isNotEmpty)
|
|
Positioned(
|
|
right: -3,
|
|
bottom: -2,
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)),
|
|
child: Icon(
|
|
(_presence['${r['id']}'] ?? const []).contains('mobile') ? Icons.phone_android : Icons.computer,
|
|
size: 14,
|
|
color: const Color(0xFF10AEFF),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Container(
|
|
padding: const EdgeInsets.only(bottom: 10),
|
|
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: kLine, width: 0.5))),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Text('${r['name'] ?? ''}', style: const TextStyle(fontSize: 16, color: kInk)),
|
|
const SizedBox(width: 6),
|
|
Container(width: 6, height: 6, decoration: BoxDecoration(shape: BoxShape.circle, color: (_presence['${r['id']}'] ?? const []).isNotEmpty ? const Color(0xFF22C55E) : const Color(0xFFB8BEC8))),
|
|
const SizedBox(width: 3),
|
|
Text((_presence['${r['id']}'] ?? const []).isNotEmpty ? '在线' : '离线', style: const TextStyle(fontSize: 11, color: kMute)),
|
|
],
|
|
),
|
|
Text(
|
|
[r['department'], r['title']].where((e) => e != null && '$e'.isNotEmpty).join(' · '),
|
|
style: const TextStyle(fontSize: 12, color: kMute),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 32),
|
|
],
|
|
),
|
|
Align(
|
|
alignment: Alignment.centerRight,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(right: 4),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
for (final l in letters)
|
|
Text(l, style: const TextStyle(fontSize: 10, color: kBind, height: 1.35)),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
|
|
if (widget.embedded) {
|
|
return ColoredBox(
|
|
color: widget.desktopStyle ? kDeskBg : kPaper,
|
|
child: Column(
|
|
children: [
|
|
widget.desktopStyle
|
|
? DesktopPaneHeader(
|
|
title: '通讯录',
|
|
bottom: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
|
child: DesktopSearchBar(hint: '搜索同事、部门、手机', onChanged: (v) => setState(() => _q = v.trim())),
|
|
),
|
|
)
|
|
: const WxHeader(title: '通讯录'),
|
|
Expanded(child: widget.desktopStyle ? _desktopBody(shown, groups, letters, depts) : body),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
return Scaffold(appBar: AppBar(title: const Text('通讯录')), body: body);
|
|
}
|
|
}
|