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:
2026-09-02 10:04:03 +00:00
commit 76f266645d
507 changed files with 99891 additions and 0 deletions
+181
View File
@@ -0,0 +1,181 @@
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../session/session.dart';
import '../theme.dart';
import '../widgets/wecom.dart';
import 'chat_page.dart';
class OrgBrowsePage extends StatefulWidget {
const OrgBrowsePage({
super.key,
required this.session,
required this.api,
required this.staff,
this.parentId,
this.title = '组织架构',
this.deptName,
});
final SessionStore session;
final OaClient api;
final List<Map<String, dynamic>> staff;
final String? parentId;
final String title;
final String? deptName;
@override
State<OrgBrowsePage> createState() => _OrgBrowsePageState();
}
class _OrgBrowsePageState extends State<OrgBrowsePage> {
List<Map<String, dynamic>> _depts = [];
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final data = await widget.api.get('/departments');
if (!mounted) return;
setState(() {
_depts = asMaps(data);
_loading = false;
});
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
List<Map<String, dynamic>> get _children {
if (_depts.isEmpty) return const [];
return _depts.where((e) {
final pid = '${e['parentId'] ?? ''}';
if (widget.parentId == null || widget.parentId!.isEmpty) return pid.isEmpty || pid == 'null';
return pid == widget.parentId;
}).toList();
}
List<Map<String, dynamic>> get _people {
final name = widget.deptName;
if (name == null || name.isEmpty) return const [];
final me = widget.session.userId;
return widget.staff.where((e) => '${e['department']}' == name && '${e['id']}' != me).toList();
}
int _countOf(Map<String, dynamic> d) {
final c = d['_count'];
if (c is Map && c['employees'] != null) return (c['employees'] as num).toInt();
return widget.staff.where((e) => '${e['department']}' == '${d['name']}').length;
}
void _openPerson(Map<String, dynamic> r) {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => ChatPage(
session: widget.session,
api: widget.api,
peerId: '${r['id']}',
peerName: '${r['name'] ?? '同事'}',
peerAvatarFileId: '${r['avatarFileId'] ?? ''}',
),
));
}
@override
Widget build(BuildContext context) {
final children = _children;
final people = _people;
final fallbackDepts = <String>{};
if (_depts.isEmpty && (widget.parentId == null || widget.parentId!.isEmpty)) {
for (final r in widget.staff) {
final d = '${r['department'] ?? ''}';
if (d.isNotEmpty) fallbackDepts.add(d);
}
}
return Scaffold(
backgroundColor: kPaper,
appBar: AppBar(title: Text(widget.title), leading: const BackButton()),
body: _loading
? const Center(child: CircularProgressIndicator())
: ListView(
children: [
const SizedBox(height: 12),
if (children.isNotEmpty)
CellGroup(
children: [
for (var i = 0; i < children.length; i++)
Cell(
title: '${children[i]['name'] ?? ''}',
subtitle: '${_countOf(children[i])}',
leading: const SquareAvatar(label: '', color: kBind, icon: Icons.apartment, size: 36),
showLine: i != children.length - 1,
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => OrgBrowsePage(
session: widget.session,
api: widget.api,
staff: widget.staff,
parentId: '${children[i]['id']}',
title: '${children[i]['name'] ?? '部门'}',
deptName: '${children[i]['name'] ?? ''}',
),
)),
),
],
)
else if (fallbackDepts.isNotEmpty)
CellGroup(
children: [
for (final d in (fallbackDepts.toList()..sort()))
Cell(
title: d,
subtitle: '${widget.staff.where((e) => '${e['department']}' == d).length} ',
leading: const SquareAvatar(label: '', color: kBind, icon: Icons.apartment, size: 36),
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => OrgBrowsePage(
session: widget.session,
api: widget.api,
staff: widget.staff,
parentId: '__leaf__',
title: d,
deptName: d,
),
)),
),
],
),
if (people.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text('${widget.deptName} · ${people.length}', style: const TextStyle(fontSize: 13, color: kMute)),
),
CellGroup(
children: [
for (var i = 0; i < people.length; i++)
Cell(
title: '${people[i]['name'] ?? ''}',
subtitle: '${people[i]['title'] ?? ''}',
leading: SquareAvatar(
label: '${people[i]['name'] ?? ''}',
fileId: '${people[i]['avatarFileId'] ?? ''}',
api: widget.api,
size: 36,
),
showLine: i != people.length - 1,
onTap: () => _openPerson(people[i]),
),
],
),
],
if (children.isEmpty && fallbackDepts.isEmpty && people.isEmpty)
const Padding(
padding: EdgeInsets.only(top: 48),
child: Center(child: Text('这个部门还没有人', style: TextStyle(color: kMute))),
),
],
),
);
}
}