Files
Public/apps/native/lib/desktop/pages/desk_workbench_page.dart
daiyongkang 76f266645d Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。
排除 node_modules、构建产物、安装包与 .env 密钥。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 10:04:03 +00:00

399 lines
15 KiB
Dart

import 'package:flutter/material.dart';
import '../../api/oa_client.dart';
import '../../device/device_bridge.dart';
import '../../labels.dart';
import '../../nav/open_module.dart';
import '../../pages/module_list_page.dart' show RestShell, shellFor;
import '../../session/session.dart';
import '../desk_theme.dart';
import '../widgets/desk_data_table.dart';
import '../widgets/desk_widgets.dart';
import 'desk_attendance_page.dart';
import 'desk_hr_apply_page.dart';
import 'desk_legal_page.dart';
import 'desk_office_list_page.dart';
import 'desk_record_detail_page.dart';
import 'desk_work_assign_page.dart';
typedef DeskOpenTab = void Function(String key, String title, IconData icon, Color color, Widget page);
class DeskWorkbenchPage extends StatefulWidget {
const DeskWorkbenchPage({super.key, required this.session, required this.api, required this.onOpenTab});
final SessionStore session;
final OaClient api;
final DeskOpenTab onOpenTab;
@override
State<DeskWorkbenchPage> createState() => _DeskWorkbenchPageState();
}
class _DeskWorkbenchPageState extends State<DeskWorkbenchPage> {
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<_App> _personalApps() {
return [
if (_match('待办'))
_App('todos', '待办', '处理待办事项与提醒', Icons.task_alt, const Color(0xFFFA9D3B),
DeskOfficeListPage(api: widget.api, title: '待办', path: '/office/todos', buckets: const [('OPEN', '待办'), ('DONE', '已办'), ('', '全部')], defaultBucket: 'OPEN')),
if (_match('审批'))
_App('approvals', '待审批', '审批各类办公申请', Icons.fact_check, kDeskBind,
DeskOfficeListPage(api: widget.api, title: '待我审批', path: '/office/approvals', buckets: const [('PENDING', '待审批'), ('APPROVED', '已通过'), ('REJECTED', '已驳回'), ('', '全部')], defaultBucket: 'PENDING')),
if (_match('申请'))
_App('flow', '我的申请', '查看我发起的申请', Icons.assignment_outlined, const Color(0xFF6267F2),
DeskOfficeListPage(api: widget.api, title: '我的申请', path: '/office/flow', buckets: const [('', '全部'), ('pending', '待审批'), ('approved', '已通过'), ('rejected', '已驳回')], defaultBucket: '')),
if (_match('日程'))
_App('calendar', '日程', '会议与日程安排', Icons.calendar_month, const Color(0xFF10AEFF),
DeskOfficeListPage(
api: widget.api,
title: '我的日程',
path: '/office/calendar',
columns: const ['标题', '时间', '地点'],
rowBuilder: (r) => ['${r['title'] ?? ''}', fmtTime(r['startAt'] ?? r['createdAt']), '${r['location'] ?? ''}'],
)),
if (_match('安排'))
_App('work', '工作安排', '任务分配与跟进', Icons.event_note, const Color(0xFF00B578),
DeskWorkAssignPage(session: widget.session, api: widget.api)),
if (_match('打卡') || _match('考勤'))
_App('attendance', '考勤打卡', '上下班打卡记录', Icons.access_time_filled, const Color(0xFF267EF0),
DeskAttendancePage(api: widget.api)),
if (_match('人事'))
_App('hr', '人事申请', '请假、加班、外出等', Icons.beach_access, const Color(0xFF8B5CF6), DeskHrApplyPage(api: widget.api)),
];
}
List<_App> _menuApps() {
final out = <_App>[];
for (final m in widget.session.menus) {
if (m.name == '工作台' || m.name == '个人办公') 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 (_skip(c)) continue;
if (!(_q.isEmpty || c.name.contains(_q) || m.name.contains(_q))) continue;
out.add(_App(c.code, c.name, m.name, iconFor(c.code, c.name), colorFor(c.code, c.name, i), _pageFor(c)));
}
} else if (!_skip(m) && _match(m.name)) {
out.add(_App(m.code, m.name, '业务应用', iconFor(m.code, m.name), colorFor(m.code, m.name), _pageFor(m)));
}
}
return out;
}
bool _skip(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);
}
String _legalKind(MenuNode n) {
final p = '${n.path ?? ''} ${n.code}'.toLowerCase();
if (p.contains('privacy') || p.contains('隐私')) return 'privacy';
return 'user-agreement';
}
Widget _pageFor(MenuNode n) {
if (n.code.contains('legal') || (n.path ?? '').startsWith('/legal')) {
return DeskLegalPage(api: widget.api, kind: _legalKind(n));
}
if (n.code == 'office:assign' || n.name.contains('工作安排')) {
return DeskWorkAssignPage(session: widget.session, api: widget.api);
}
final shell = shellFor(n.code, n.path) ?? _shellFromPath(n.path);
if (shell != null) {
return DeskModuleListPage(api: widget.api, title: n.name, shell: shell);
}
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('${n.name} 暂未配置桌面入口', style: const TextStyle(color: kDeskMute)),
if ((n.path ?? '').startsWith('http'))
TextButton(
onPressed: () => DeviceBridge.openUrl(n.path!),
child: const Text('在浏览器打开'),
),
],
),
);
}
RestShell? _shellFromPath(String? path) {
final p = (path ?? '').trim();
if (p.isEmpty || !p.startsWith('/') || p.contains(':')) return null;
const blocked = [
'/office/overview',
'/office/todos',
'/office/approvals',
'/office/flow',
'/office/calendar',
'/office/weather',
'/office/geo',
'/auth',
'/im',
'/system',
'/push',
'/files',
'/health',
];
if (blocked.any((b) => p.startsWith(b))) return null;
return RestShell(p);
}
List<_App> get _shown {
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 >= 1100 ? 3 : 2;
return ColoredBox(
color: kDeskBg,
child: Column(
children: [
DeskPaneHeader(
title: '工作台',
actions: [
SizedBox(width: 200, child: DeskSearchField(hint: '搜索应用', onChanged: (v) => setState(() => _q = v.trim()))),
const SizedBox(width: 8),
TextButton(onPressed: _load, child: const Text('刷新')),
],
bottom: Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 10),
child: DeskFilterChips(
value: _cat,
onChanged: (v) => setState(() => _cat = v),
items: const [('all', '全部应用'), ('personal', '个人办公'), ('biz', '业务模块')],
),
),
),
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 24),
children: [
Container(
padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
decoration: BoxDecoration(
gradient: const LinearGradient(colors: [Color(0xFFEEF4FF), Color(0xFFF8FBFF)]),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFDCE8FF)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_greet.isEmpty ? '你好,${widget.session.displayName}' : _greet,
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700),
),
const SizedBox(height: 4),
const Text('个人办公与业务入口', style: TextStyle(color: kDeskMute, fontSize: 13)),
],
),
),
const SizedBox(height: 14),
Row(
children: [
_stat('待办', '${_ov['pendingTodos'] ?? 0}', () => _open('todos', '待办', Icons.task_alt, const Color(0xFFFA9D3B), DeskOfficeListPage(api: widget.api, title: '待办', path: '/office/todos', buckets: const [('OPEN', '待办'), ('DONE', '已办'), ('', '全部')], defaultBucket: 'OPEN'))),
const SizedBox(width: 10),
_stat('待审批', '${_ov['pendingApprovals'] ?? 0}', () => _open('approvals', '待审批', Icons.fact_check, kDeskBind, DeskOfficeListPage(api: widget.api, title: '待我审批', path: '/office/approvals', buckets: const [('PENDING', '待审批'), ('', '全部')], defaultBucket: 'PENDING'))),
const SizedBox(width: 10),
_stat('我的申请', '${_ov['myPending'] ?? 0}', () => _open('flow', '我的申请', Icons.assignment_outlined, const Color(0xFF6267F2), DeskOfficeListPage(api: widget.api, title: '我的申请', path: '/office/flow'))),
const SizedBox(width: 10),
_stat('逾期', '${_ov['overdueTodos'] ?? 0}', () => _open('todos', '待办', Icons.task_alt, const Color(0xFFFA9D3B), DeskOfficeListPage(api: widget.api, title: '待办', path: '/office/todos'))),
],
),
const SizedBox(height: 18),
if (_shown.isEmpty)
const Padding(padding: EdgeInsets.only(top: 40), child: Center(child: Text('没有匹配的应用', style: TextStyle(color: kDeskMute))))
else
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: cols,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 2.9,
),
itemCount: _shown.length,
itemBuilder: (_, i) {
final a = _shown[i];
return DeskAppCard(
title: a.title,
desc: a.desc,
icon: a.icon,
color: a.color,
onTap: () => _open(a.key, a.title, a.icon, a.color, a.page),
);
},
),
],
),
),
],
),
);
}
Widget _stat(String label, String value, VoidCallback onTap) {
return Expanded(
child: Material(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(
children: [
Text(value, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: kDeskBind)),
const SizedBox(height: 2),
Text(label, style: const TextStyle(fontSize: 12, color: kDeskMute)),
],
),
),
),
),
);
}
}
class _App {
_App(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;
}
/// 桌面端业务模块列表(表格),不沿用移动端 ModuleListPage。
class DeskModuleListPage extends StatefulWidget {
const DeskModuleListPage({super.key, required this.api, required this.title, required this.shell});
final OaClient api;
final String title;
final RestShell shell;
@override
State<DeskModuleListPage> createState() => _DeskModuleListPageState();
}
class _DeskModuleListPageState extends State<DeskModuleListPage> {
List<Map<String, dynamic>> _items = [];
bool _loading = true;
String _q = '';
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() => _loading = true);
try {
final data = await widget.api.get(widget.shell.path, query: {'page': '1', 'pageSize': '100', ...?widget.shell.query});
if (!mounted) return;
setState(() {
_items = asMaps(data);
_loading = false;
});
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
List<Map<String, dynamic>> get _shown {
if (_q.isEmpty) return _items;
return _items.where((e) => pickTitle(e).contains(_q) || pickSub(e).contains(_q)).toList();
}
@override
Widget build(BuildContext context) {
final shown = _shown;
return DeskListScaffold(
title: widget.title,
loading: _loading,
actions: [IconButton(onPressed: _load, icon: const Icon(Icons.refresh, size: 20))],
filters: DeskSearchField(hint: '搜索', onChanged: (v) => setState(() => _q = v.trim())),
body: DeskDataTable(
columns: const ['名称', '摘要', '状态'],
rows: [
for (final r in shown)
[pickTitle(r), zh(pickSub(r)), '${r['status'] ?? ''}'],
],
emptyHint: '暂无${widget.title}',
onRowTap: (i) {
final r = shown[i];
final seed = Map<String, dynamic>.from(r);
if ('${r['id']}'.length >= 8) seed['_fetch'] = '${widget.shell.path}/${r['id']}';
deskOpenRecord(context, widget.api, seed, title: pickTitle(r));
},
),
);
}
}
String pickTitle(Map<String, dynamic> r) {
for (final k in ['title', 'name', 'code', 'no']) {
final v = r[k];
if (v != null && '$v'.isNotEmpty) return '$v';
}
return '记录';
}
String pickSub(Map<String, dynamic> r) {
for (final k in ['summary', 'remark', 'description', 'partyName', 'projectName']) {
final v = r[k];
if (v != null && '$v'.isNotEmpty) return '$v';
}
return '';
}