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
@@ -0,0 +1,295 @@
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../nav/open_module.dart';
import '../pages/approvals_page.dart';
import '../pages/attendance_page.dart';
import '../pages/calendar_page.dart';
import '../pages/flow_page.dart';
import '../pages/hr_apply_page.dart';
import '../pages/module_list_page.dart';
import '../pages/todos_page.dart';
import '../pages/work_assign_page.dart';
import '../session/session.dart';
import '../theme.dart';
import '../widgets/desktop_ui.dart';
import '../widgets/wecom.dart';
typedef DesktopOpenTab = void Function(String key, String title, IconData icon, Color color, Widget page);
class DesktopWorkbenchPage extends StatefulWidget {
const DesktopWorkbenchPage({
super.key,
required this.session,
required this.api,
required this.onOpenTab,
});
final SessionStore session;
final OaClient api;
final DesktopOpenTab onOpenTab;
@override
State<DesktopWorkbenchPage> createState() => _DesktopWorkbenchPageState();
}
class _DesktopWorkbenchPageState extends State<DesktopWorkbenchPage> {
Map<String, dynamic> _ov = {};
String _greet = '';
bool _loading = true;
String _q = '';
String _cat = 'all';
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final ov = await widget.api.get('/office/overview');
String greet = '';
try {
final w = await widget.api.get('/office/weather');
if (w is Map) greet = '${w['greeting'] ?? w['text'] ?? ''}';
} catch (_) {}
if (!mounted) return;
setState(() {
_ov = Map<String, dynamic>.from(ov as Map? ?? {});
_greet = greet;
_loading = false;
});
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
bool _match(String name) => _q.isEmpty || name.contains(_q);
void _open(String key, String title, IconData icon, Color color, Widget page) {
widget.onOpenTab(key, title, icon, color, page);
}
List<_DeskApp> _personalApps() {
return [
if (_match('待办'))
_DeskApp('todos', '待办', '处理待办事项与提醒', Icons.task_alt, const Color(0xFFFA9D3B), TodosPage(api: widget.api)),
if (_match('审批'))
_DeskApp('approvals', '待审批', '审批各类办公申请', Icons.fact_check, kBind, ApprovalsPage(api: widget.api)),
if (_match('申请'))
_DeskApp('flow', '我的申请', '查看我发起的申请', Icons.assignment_outlined, const Color(0xFF6267F2), FlowPage(api: widget.api)),
if (_match('日程'))
_DeskApp('calendar', '日程', '会议与日程安排', Icons.calendar_month, const Color(0xFF10AEFF), CalendarPage(api: widget.api)),
if (_match('安排'))
_DeskApp('work', '工作安排', '任务分配与跟进', Icons.event_note, const Color(0xFF00B578), WorkAssignPage(session: widget.session, api: widget.api)),
if (_match('打卡') || _match('考勤'))
_DeskApp('attendance', '考勤打卡', '上下班打卡记录', Icons.access_time_filled, const Color(0xFF267EF0), AttendancePage(api: widget.api)),
if (_match('人事'))
_DeskApp('hr', '人事申请', '请假、加班、外出等', Icons.beach_access, const Color(0xFF8B5CF6), HrApplyPage(api: widget.api)),
];
}
List<_DeskApp> _menuApps() {
final out = <_DeskApp>[];
for (final m in widget.session.menus) {
if (m.name == '工作台' || m.name == '个人办公' || m.code == 'office:overview') continue;
if (m.name == '系统设置' || m.code.startsWith('system')) continue;
if (m.children.isNotEmpty) {
for (var i = 0; i < m.children.length; i++) {
final c = m.children[i];
if (_skipChild(c)) continue;
if (!(_q.isEmpty || c.name.contains(_q) || m.name.contains(_q))) continue;
out.add(_DeskApp(
c.code,
c.name,
m.name,
iconFor(c.code, c.name),
colorFor(c.code, c.name, i),
_pageFor(c),
));
}
} else if (!_skipChild(m) && _match(m.name)) {
out.add(_DeskApp(m.code, m.name, '业务应用', iconFor(m.code, m.name), colorFor(m.code, m.name), _pageFor(m)));
}
}
return out;
}
bool _skipChild(MenuNode n) {
const names = {'待办', '待审批', '我的申请', '日程', '公告', '公司公告', '工作安排', '人事申请', '工作台', '个人办公', '消息', '通讯录', '员工通讯', '发送短信', '系统设置'};
if (n.code.startsWith('office:') || n.code.startsWith('system')) return true;
if (n.code.contains('sms') || n.name.contains('短信')) return true;
if (n.name.contains('公告') || n.name == '员工通讯') return true;
return names.contains(n.name);
}
Widget _pageFor(MenuNode n) {
final shell = shellFor(n.code, n.path);
if (shell != null) {
return ModuleListPage(api: widget.api, title: n.name, shell: shell);
}
return Center(child: Text('${n.name} 暂未配置桌面入口', style: const TextStyle(color: kMute)));
}
List<_DeskApp> get _shownApps {
final personal = _personalApps();
final menu = _menuApps();
if (_cat == 'personal') return personal;
if (_cat == 'biz') return menu;
return [...personal, ...menu];
}
@override
Widget build(BuildContext context) {
final width = MediaQuery.sizeOf(context).width;
final cols = width >= 1200 ? 3 : 2;
return ColoredBox(
color: kDeskBg,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DesktopPaneHeader(
title: '工作台',
actions: [
SizedBox(width: 220, child: DesktopSearchBar(hint: '搜索应用', onChanged: (v) => setState(() => _q = v.trim()))),
const SizedBox(width: 8),
OutlinedButton(onPressed: _load, child: const Text('刷新')),
],
bottom: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 10),
child: Row(
children: [
_catChip('all', '全部应用'),
const SizedBox(width: 8),
_catChip('personal', '个人办公'),
const SizedBox(width: 8),
_catChip('biz', '业务模块'),
],
),
),
),
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kBind),
Expanded(
child: RefreshIndicator(
onRefresh: _load,
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
children: [
Container(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 18),
decoration: BoxDecoration(
gradient: const LinearGradient(colors: [Color(0xFFEEF4FF), Color(0xFFF8FBFF)]),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFDCE8FF)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_greet.isEmpty ? '你好,${widget.session.displayName}' : _greet,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: kInk),
),
const SizedBox(height: 6),
const Text('工作台 · 个人办公与业务入口', style: TextStyle(color: kMute, fontSize: 13)),
],
),
),
const SizedBox(height: 16),
Row(
children: [
_statCard('待办', '${_ov['pendingTodos'] ?? 0}', () => _open('todos', '待办', Icons.task_alt, const Color(0xFFFA9D3B), TodosPage(api: widget.api))),
const SizedBox(width: 12),
_statCard('待审批', '${_ov['pendingApprovals'] ?? 0}', () => _open('approvals', '待审批', Icons.fact_check, kBind, ApprovalsPage(api: widget.api))),
const SizedBox(width: 12),
_statCard('我的申请', '${_ov['myPending'] ?? 0}', () => _open('flow', '我的申请', Icons.assignment_outlined, const Color(0xFF6267F2), FlowPage(api: widget.api))),
const SizedBox(width: 12),
_statCard('逾期', '${_ov['overdueTodos'] ?? 0}', () => _open('todos', '待办', Icons.task_alt, const Color(0xFFFA9D3B), TodosPage(api: widget.api))),
],
),
const SizedBox(height: 20),
if (_shownApps.isEmpty)
const Padding(
padding: EdgeInsets.only(top: 48),
child: Center(child: Text('没有匹配的应用', style: TextStyle(color: kMute))),
)
else
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: cols,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 2.8,
),
itemCount: _shownApps.length,
itemBuilder: (_, i) {
final a = _shownApps[i];
return DesktopAppCard(
label: a.title,
desc: a.desc,
icon: a.icon,
color: a.color,
onTap: () => _open(a.key, a.title, a.icon, a.color, a.page),
);
},
),
],
),
),
),
],
),
);
}
Widget _catChip(String id, String label) {
final on = _cat == id;
return GestureDetector(
onTap: () => setState(() => _cat = id),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: on ? kDeskActive : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: on ? const Color(0xFFBFD7FF) : kLine),
),
child: Text(label, style: TextStyle(fontSize: 13, color: on ? kBind : kMute, fontWeight: on ? FontWeight.w600 : FontWeight.w400)),
),
);
}
Widget _statCard(String label, String value, VoidCallback onTap) {
return Expanded(
child: Material(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Column(
children: [
Text(value, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: kBind)),
const SizedBox(height: 4),
Text(label, style: const TextStyle(fontSize: 12, color: kMute)),
],
),
),
),
),
);
}
}
class _DeskApp {
_DeskApp(this.key, this.title, this.desc, this.icon, this.color, this.page);
final String key;
final String title;
final String desc;
final IconData icon;
final Color color;
final Widget page;
}