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,405 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user