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
+142
View File
@@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../theme.dart';
import '../widgets/wecom.dart';
class PickPeoplePage extends StatefulWidget {
const PickPeoplePage({
super.key,
required this.api,
required this.title,
this.multiple = true,
this.exclude = const {},
});
final OaClient api;
final String title;
final bool multiple;
final Set<String> exclude;
@override
State<PickPeoplePage> createState() => _PickPeoplePageState();
}
class _PickPeoplePageState extends State<PickPeoplePage> {
List<Map<String, dynamic>> _items = [];
final _selected = <String, String>{};
String _q = '';
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final data = await widget.api.get('/staff');
if (!mounted) return;
setState(() {
_items = asMaps(data);
_loading = false;
});
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
final shown = _items.where((e) {
final id = '${e['id']}';
if (widget.exclude.contains(id)) return false;
if (_q.isEmpty) return true;
return '${e['name']}${e['department']}${e['title']}'.contains(_q);
}).toList();
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: Text(widget.title),
leading: IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)),
actions: [
if (widget.multiple)
TextButton(
onPressed: _selected.isEmpty
? null
: () => Navigator.pop(context, _selected.entries.map((e) => {'id': e.key, 'name': e.value}).toList()),
child: Text('确定(${_selected.length})'),
),
],
),
body: Column(
children: [
WxSearchBar(hint: '搜索同事', onChanged: (v) => setState(() => _q = v.trim())),
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kBind),
Expanded(
child: ListView.builder(
itemCount: shown.length,
itemBuilder: (_, i) {
final r = shown[i];
final id = '${r['id']}';
final name = '${r['name'] ?? ''}';
final on = _selected.containsKey(id);
return InkWell(
onTap: () {
if (!widget.multiple) {
Navigator.pop(context, [
{'id': id, 'name': name},
]);
return;
}
setState(() {
if (on) {
_selected.remove(id);
} else {
_selected[id] = name;
}
});
},
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 0),
child: Row(
children: [
if (widget.multiple)
Padding(
padding: const EdgeInsets.only(right: 10),
child: Icon(on ? Icons.check_circle : Icons.circle_outlined, color: on ? kBind : kMute, size: 22),
),
SquareAvatar(
label: name,
fileId: '${r['avatarFileId'] ?? ''}',
api: widget.api,
),
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: [
Text(name, style: const TextStyle(fontSize: 16)),
Text(
[r['department'], r['title']].where((e) => e != null && '$e'.isNotEmpty).join(' · '),
style: const TextStyle(fontSize: 12, color: kMute),
),
],
),
),
),
],
),
),
);
},
),
),
],
),
);
}
}