76f266645d
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
399 lines
15 KiB
Dart
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 '';
|
|
}
|