Files
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

280 lines
9.7 KiB
Dart

import 'package:flutter/material.dart';
import '../../api/oa_client.dart';
import '../../im/pinyin.dart';
import 'desk_org_browse_page.dart';
import '../../session/session.dart';
import '../desk_theme.dart';
import '../widgets/desk_widgets.dart';
class DeskContactsPage extends StatefulWidget {
const DeskContactsPage({super.key, required this.session, required this.api, this.onMessage});
final SessionStore session;
final OaClient api;
final void Function(Map<String, dynamic> person)? onMessage;
@override
State<DeskContactsPage> createState() => _DeskContactsPageState();
}
class _DeskContactsPageState extends State<DeskContactsPage> {
List<Map<String, dynamic>> _items = [];
Map<String, List<String>> _presence = {};
String _q = '';
bool _loading = true;
String? _selectedId;
@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);
}
}
List<Map<String, dynamic>> get _shown {
if (_q.isEmpty) return _items;
return _items.where((e) => '${e['name']}${e['mobile']}${e['department']}'.contains(_q)).toList();
}
Map<String, List<Map<String, dynamic>>> get _groups {
final g = <String, List<Map<String, dynamic>>>{};
for (final r in _shown) {
final letter = letterOf('${r['name'] ?? ''}');
g.putIfAbsent(letter, () => []).add(r);
}
for (final k in g.keys) {
g[k]!.sort((a, b) => '${a['name']}'.compareTo('${b['name']}'));
}
return g;
}
Future<void> _message(Map<String, dynamic> r) async {
if (widget.onMessage != null) {
widget.onMessage!(r);
return;
}
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 (_) {}
if (!mounted) return;
widget.onMessage?.call({
...r,
'conversationId': conversationId,
'peerId': '${r['id']}',
'name': '${r['name'] ?? '同事'}',
'type': 'direct',
});
}
@override
Widget build(BuildContext context) {
final groups = _groups;
final letters = groups.keys.toList()..sort();
final depts = _items.map((e) => '${e['department'] ?? ''}').where((e) => e.isNotEmpty).toSet();
final selected = _selectedId == null ? null : _items.cast<Map<String, dynamic>?>().firstWhere(
(e) => '${e?['id']}' == _selectedId,
orElse: () => null,
);
return ColoredBox(
color: kDeskBg,
child: Row(
children: [
SizedBox(
width: 300,
child: ColoredBox(
color: kDeskListBg,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
child: DeskSearchField(hint: '搜索同事', onChanged: (v) => setState(() => _q = v.trim())),
),
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
Expanded(
child: ListView(
children: [
if (_q.isEmpty) ...[
_entry(Icons.account_tree, '组织架构', '${depts.length} 个部门', () {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => DeskOrgBrowsePage(
session: widget.session,
api: widget.api,
staff: _items,
onMessage: widget.onMessage,
),
));
}),
const Divider(height: 1, color: kDeskLine),
],
for (final letter in letters) ...[
Padding(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 4),
child: Text(letter, style: const TextStyle(fontSize: 12, color: kDeskMute, fontWeight: FontWeight.w600)),
),
for (final r in groups[letter]!)
_personTile(r),
],
],
),
),
],
),
),
),
const VerticalDivider(width: 1, color: kDeskLine),
Expanded(
child: selected == null
? const Center(child: Text('选择联系人查看详情', style: TextStyle(color: kDeskMute)))
: _detail(selected),
),
],
),
);
}
Widget _entry(IconData icon, String title, String sub, VoidCallback onTap) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
child: Row(
children: [
DeskAvatar(label: title, api: widget.api, size: 40, color: kDeskBind, icon: icon),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
Text(sub, style: const TextStyle(fontSize: 12, color: kDeskMute)),
],
),
),
],
),
),
),
);
}
Widget _personTile(Map<String, dynamic> r) {
final id = '${r['id']}';
final online = (_presence[id] ?? const []).contains('online');
return Material(
color: _selectedId == id ? kDeskActive : Colors.transparent,
child: InkWell(
onTap: () => setState(() => _selectedId = id),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
child: Row(
children: [
DeskAvatar(label: '${r['name']}', fileId: '${r['avatarFileId'] ?? ''}', api: widget.api, size: 36),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${r['name']}', style: const TextStyle(fontSize: 14)),
Text(
[r['department'], r['title']].where((e) => e != null && '$e'.isNotEmpty).join(' · '),
style: const TextStyle(fontSize: 12, color: kDeskMute),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: online ? kDeskGreen : const Color(0xFFD0D0D0),
shape: BoxShape.circle,
),
),
],
),
),
),
);
}
Widget _detail(Map<String, dynamic> r) {
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
DeskAvatar(label: '${r['name']}', fileId: '${r['avatarFileId'] ?? ''}', api: widget.api, size: 80),
const SizedBox(height: 16),
Text('${r['name']}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600)),
const SizedBox(height: 6),
Text(
[r['department'], r['title']].where((e) => e != null && '$e'.isNotEmpty).join(' · '),
style: const TextStyle(color: kDeskMute),
),
const SizedBox(height: 24),
_infoRow('手机', '${r['mobile'] ?? '未填写'}'),
_infoRow('邮箱', '${r['email'] ?? '未填写'}'),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
style: FilledButton.styleFrom(backgroundColor: kDeskBind, minimumSize: const Size.fromHeight(40)),
onPressed: () => _message(r),
child: const Text('发消息'),
),
),
],
),
),
),
);
}
Widget _infoRow(String k, String v) {
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8), border: Border.all(color: kDeskLine)),
child: Row(
children: [
SizedBox(width: 56, child: Text(k, style: const TextStyle(color: kDeskMute, fontSize: 13))),
Expanded(child: Text(v, style: const TextStyle(fontSize: 14))),
],
),
);
}
}