Files
Public/apps/native/lib/pages/directory_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

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);
}
}