76f266645d
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
280 lines
9.7 KiB
Dart
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))),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|