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 createState() => _DirectoryPageState(); } class _DirectoryPageState extends State { List> _items = []; Map> _presence = {}; String _q = ''; bool _loading = true; @override void initState() { super.initState(); _load(); } Future _load() async { try { final data = await widget.api.get('/staff'); Map> 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 r) { showModalBottomSheet( 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> shown, Map>> groups, List letters, Set 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 = >>{}; 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 = {}; 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); } }