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
+85
View File
@@ -0,0 +1,85 @@
String? fetchPathOf(Map<String, dynamic> row) {
final forced = '${row['_fetch'] ?? ''}';
if (forced.startsWith('/')) return forced;
final id = '${row['id'] ?? ''}';
final bizId = '${row['bizId'] ?? ''}';
final source = '${row['source'] ?? ''}';
final kind = '${row['kind'] ?? ''}';
final biz = '${row['bizType'] ?? ''}';
final path = '${row['path'] ?? ''}';
if (source == 'EXPENSE' && kind == 'LOAN' && id.isNotEmpty)
return '/loans/$id';
if (source == 'EXPENSE' && id.isNotEmpty) return '/expenses/$id';
if (source == 'HR' && id.isNotEmpty) return '/leave-requests/$id';
if (source == 'OFFICE' && id.isNotEmpty) return '/office/applies/$id';
if (source == 'SEAL' && id.isNotEmpty) return '/seal-requests/$id';
if (bizId.isNotEmpty) {
if (biz == 'EXPENSE_APPROVAL' ||
biz == 'EXPENSE_DRAFT' ||
biz == 'TRAVEL_EXPENSE') return '/expenses/$bizId';
if (biz == 'LOAN_APPROVAL' || biz == 'LOAN_OFFSET') return '/loans/$bizId';
if (biz == 'HR_LEAVE') return '/leave-requests/$bizId';
if (biz == 'OFFICE_APPLY') return '/office/applies/$bizId';
if (biz == 'SEAL_APPROVAL' || biz == 'SEAL_RETURN' || biz == 'SEAL_REQUEST')
return '/seal-requests/$bizId';
if (biz.startsWith('BID')) return '/bid-cases/$bizId';
if (biz.startsWith('CONTRACT')) return '/contracts/$bizId';
if (biz == 'WORK_REPORT') return '/office/reports/$bizId';
if (biz == 'WORK_ASSIGN') return null;
if (biz.startsWith('PROJECT')) return '/projects/$bizId';
}
if (row['expense'] is Map)
return '/expenses/${(row['expense'] as Map)['id']}';
if (row['loan'] is Map) return '/loans/${(row['loan'] as Map)['id']}';
if (row['leaveRequest'] is Map)
return '/leave-requests/${(row['leaveRequest'] as Map)['id']}';
if (row['officeApply'] is Map)
return '/office/applies/${(row['officeApply'] as Map)['id']}';
if (row['sealRequest'] is Map)
return '/seal-requests/${(row['sealRequest'] as Map)['id']}';
if (row['contract'] is Map)
return '/contracts/${(row['contract'] as Map)['id']}';
if (row['bidCase'] is Map)
return '/bid-cases/${(row['bidCase'] as Map)['id']}';
if (row['project'] is Map)
return '/projects/${(row['project'] as Map)['id']}';
if (path.startsWith('/office/expenses/') && path.length > 17)
return '/expenses/${path.split('/').last}';
if (path.startsWith('/office/loans/') && path.length > 14)
return '/loans/${path.split('/').last}';
if (id.length == 36) {
if (row.containsKey('claimNo') || row.containsKey('expenseCategory'))
return '/expenses/$id';
if (row.containsKey('loanNo') || kind == 'LOAN') return '/loans/$id';
if (row.containsKey('bidNo') || row.containsKey('externalNo'))
return '/bid-cases/$id';
if (row.containsKey('contractNo')) return '/contracts/$id';
if (row.containsKey('projectNo')) return '/projects/$id';
if (row.containsKey('sealType') || row.containsKey('takeOut'))
return '/seal-requests/$id';
}
return null;
}
String detailTitleOf(Map<String, dynamic> row) {
for (final k in [
'title',
'name',
'purpose',
'reason',
'claimNo',
'loanNo',
'bidNo',
'contractNo',
'projectName'
]) {
final v = row[k];
if (v != null && '$v'.trim().isNotEmpty) return '$v';
}
return '详情';
}
+45
View File
@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../pages/approvals_page.dart';
import '../pages/record_detail_page.dart';
import '../pages/todos_page.dart';
import '../pages/work_assign_page.dart';
import '../session/session.dart';
import 'biz_route.dart';
/// Opens a system notification at its business destination. System notices are
/// workflow entries, never an IM peer conversation.
void openNoticeTarget(
BuildContext context,
OaClient api, {
required SessionStore session,
required String kind,
required String bizType,
required String bizId,
String title = '系统通知',
}) {
if (bizType == 'WORK_ASSIGN') {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => WorkAssignPage(session: session, api: api),
));
return;
}
final seed = <String, dynamic>{
'bizType': bizType,
'bizId': bizId,
'id': bizId,
'title': title,
};
if (bizId.isNotEmpty && fetchPathOf(seed) != null) {
openRecord(context, api, seed, title: title);
return;
}
if (kind == 'approval' || bizType.contains('APPROVAL')) {
Navigator.of(context)
.push(MaterialPageRoute(builder: (_) => ApprovalsPage(api: api)));
return;
}
Navigator.of(context)
.push(MaterialPageRoute(builder: (_) => TodosPage(api: api)));
}
+116
View File
@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../pages/approvals_page.dart';
import '../pages/calendar_page.dart';
import '../pages/directory_page.dart';
import '../pages/flow_page.dart';
import '../pages/hr_apply_page.dart';
import '../pages/module_list_page.dart';
import '../pages/notices_page.dart';
import '../pages/todos_page.dart';
import '../pages/work_assign_page.dart';
import '../session/session.dart';
void pushPage(BuildContext context, Widget page) {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => page));
}
void openMenuNode(BuildContext context, SessionStore session, OaClient api, MenuNode n) {
if (n.code.startsWith('system') || n.name == '系统设置') return;
if (n.name == '发送短信' || n.code.contains('sms')) return;
if (n.name == '消息' || n.code.contains('office:im') || n.code == 'im') return;
if (n.name == '公司公告' || n.name == '员工通讯' || n.name == '通讯录') return;
switch (n.code) {
case 'office:overview':
return;
case 'office:todos':
pushPage(context, TodosPage(api: api));
return;
case 'office:approvals':
pushPage(context, ApprovalsPage(api: api));
return;
case 'office:flow':
pushPage(context, FlowPage(api: api));
return;
case 'office:calendar':
pushPage(context, CalendarPage(api: api));
return;
case 'office:notices':
pushPage(context, NoticesPage(api: api));
return;
case 'office:assign':
pushPage(context, WorkAssignPage(session: session, api: api));
return;
case 'office:directory':
pushPage(context, DirectoryPage(session: session, api: api));
return;
case 'hr:apply':
case 'office:leave':
pushPage(context, HrApplyPage(api: api));
return;
}
final shell = shellFor(n.code, n.path);
if (shell != null) {
pushPage(context, ModuleListPage(api: api, title: n.name, shell: shell));
return;
}
if (n.children.isNotEmpty) {
showModalBottomSheet<void>(
context: context,
builder: (ctx) => SafeArea(
child: ListView(
shrinkWrap: true,
children: [
for (final c in n.children)
ListTile(
title: Text(c.name),
onTap: () {
Navigator.pop(ctx);
openMenuNode(context, session, api, c);
},
),
],
),
),
);
}
}
IconData iconFor(String code, String name) {
final k = '$code $name';
if (k.contains('todo') || name.contains('待办')) return Icons.task_alt;
if (k.contains('approv') || name.contains('审批')) return Icons.fact_check_outlined;
if (name.contains('申请') || k.contains('flow')) return Icons.assignment_outlined;
if (name.contains('日程') || k.contains('calendar')) return Icons.calendar_month_outlined;
if (name.contains('公告') || k.contains('notice')) return Icons.campaign_outlined;
if (name.contains('安排') || k.contains('assign')) return Icons.event_note_outlined;
if (name.contains('通讯') || k.contains('directory')) return Icons.contacts_outlined;
if (name.contains('人事') || name.contains('员工') || k.contains('hr') || k.contains('employee')) return Icons.badge_outlined;
if (name.contains('投标') || k.contains('bid')) return Icons.gavel_outlined;
if (name.contains('合同') || k.contains('contract')) return Icons.description_outlined;
if (name.contains('用章') || name.contains('证照') || k.contains('seal')) return Icons.verified_outlined;
if (name.contains('资产') || k.contains('asset')) return Icons.devices_other_outlined;
if (name.contains('项目') || k.contains('project')) return Icons.account_tree_outlined;
if (name.contains('财务') || name.contains('报销') || name.contains('付款') || k.contains('finance') || k.contains('expense')) {
return Icons.account_balance_wallet_outlined;
}
if (name.contains('客户') || name.contains('往来') || k.contains('party')) return Icons.handshake_outlined;
if (name.contains('报表') || k.contains('report')) return Icons.bar_chart_outlined;
if (name.contains('请假') || name.contains('加班')) return Icons.beach_access_outlined;
return Icons.apps_outlined;
}
Color colorFor(String code, String name, [int i = 0]) {
const palette = [
Color(0xFF267EF0),
Color(0xFF07C160),
Color(0xFFFA9D3B),
Color(0xFF6267F2),
Color(0xFF10AEFF),
Color(0xFFE75D5D),
Color(0xFF00B578),
Color(0xFF8B5CF6),
];
return palette[(code.hashCode.abs() + name.hashCode.abs() + i) % palette.length];
}