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