Files
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

282 lines
11 KiB
Dart

import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../nav/open_module.dart';
import '../ota/updater.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/todos_page.dart';
import '../pages/work_assign_page.dart';
import '../session/session.dart';
import '../theme.dart';
import '../widgets/wecom.dart';
class WorkbenchPage extends StatefulWidget {
const WorkbenchPage({super.key, required this.session, required this.api, this.embedded = true});
final SessionStore session;
final OaClient api;
final bool embedded;
@override
State<WorkbenchPage> createState() => _WorkbenchPageState();
}
class _WorkbenchPageState extends State<WorkbenchPage> {
Map<String, dynamic> _ov = {};
String _greet = '';
AppRelease? _rel;
bool _loading = true;
String _q = '';
@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 (_) {}
AppRelease? rel;
try {
rel = await OtaUpdater(widget.api).fetch();
} catch (_) {}
if (!mounted) return;
setState(() {
_ov = Map<String, dynamic>.from(ov as Map? ?? {});
_greet = greet;
_rel = rel;
_loading = false;
});
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
bool _match(String name) => _q.isEmpty || name.contains(_q);
bool _skipGroup(MenuNode m) {
if (m.name == '工作台' || m.name == '个人办公' || m.code == 'office:overview') return true;
if (m.name == '系统设置' || m.code.startsWith('system')) return true;
return false;
}
bool _skipChild(MenuNode n) {
const names = {
'待办',
'待审批',
'我的申请',
'日程',
'公告',
'公司公告',
'工作安排',
'人事申请',
'工作台',
'个人办公',
'消息',
'通讯录',
'员工通讯',
'发送短信',
'人事设置',
'数据字典',
'权限设置',
'编号规则',
'短信服务',
'消息通道',
'手机端升级',
'操作日志',
'系统设置',
};
if (n.code.startsWith('office:')) return true;
if (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);
}
@override
Widget build(BuildContext context) {
final body = RefreshIndicator(
onRefresh: _load,
child: ListView(
padding: const EdgeInsets.only(bottom: 24),
children: [
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kBind),
if (_rel != null && _rel!.newer)
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
child: Material(
color: const Color(0xFFE8F3FF),
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: () => OtaUpdater(widget.api).prompt(context, _rel!, manual: true),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
child: Row(
children: [
const Icon(Icons.system_update_alt, color: kBind),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('发现新版本 ${_rel!.version}', style: const TextStyle(fontWeight: FontWeight.w600, color: kInk)),
Text(_rel!.changelog.isEmpty ? '点击升级到最新手机版' : _rel!.changelog, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: kMute)),
],
),
),
const Text('升级', style: TextStyle(color: kBind, fontWeight: FontWeight.w600)),
],
),
),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
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: 4),
const Text('工作台', style: TextStyle(color: kMute, fontSize: 13)),
],
),
),
GroupCard(
title: '待处理',
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 14),
child: Row(
children: [
_stat('待办', '${_ov['pendingTodos'] ?? 0}', () => pushPage(context, TodosPage(api: widget.api))),
_stat('待审批', '${_ov['pendingApprovals'] ?? 0}', () => pushPage(context, ApprovalsPage(api: widget.api))),
_stat('我的申请', '${_ov['myPending'] ?? 0}', () => pushPage(context, FlowPage(api: widget.api))),
_stat('逾期', '${_ov['overdueTodos'] ?? 0}', () => pushPage(context, TodosPage(api: widget.api))),
],
),
),
),
if (_match('待办') || _match('审批') || _match('申请') || _match('日程') || _match('人事'))
GroupCard(
title: '个人办公',
child: AppGrid(
items: [
if (_match('待办'))
AppGridItem(label: '待办', icon: Icons.task_alt, color: const Color(0xFFFA9D3B), onTap: () => pushPage(context, TodosPage(api: widget.api))),
if (_match('审批'))
AppGridItem(label: '待审批', icon: Icons.fact_check, color: kBind, onTap: () => pushPage(context, ApprovalsPage(api: widget.api))),
if (_match('申请'))
AppGridItem(label: '我的申请', icon: Icons.assignment_outlined, color: const Color(0xFF6267F2), onTap: () => pushPage(context, FlowPage(api: widget.api))),
if (_match('日程'))
AppGridItem(label: '日程', icon: Icons.calendar_month, color: const Color(0xFF10AEFF), onTap: () => pushPage(context, CalendarPage(api: widget.api))),
if (_match('安排'))
AppGridItem(label: '工作安排', icon: Icons.event_note, color: const Color(0xFF00B578), onTap: () => pushPage(context, WorkAssignPage(session: widget.session, api: widget.api))),
if (_match('打卡') || _match('考勤'))
AppGridItem(label: '考勤打卡', icon: Icons.access_time_filled, color: const Color(0xFF267EF0), onTap: () => pushPage(context, AttendancePage(api: widget.api))),
if (_match('人事') || _match('请假') || _match('加班'))
AppGridItem(label: '人事申请', icon: Icons.beach_access, color: const Color(0xFF8B5CF6), onTap: () => pushPage(context, HrApplyPage(api: widget.api))),
],
),
),
..._menuGroups(),
],
),
);
if (!widget.embedded) {
return Scaffold(appBar: AppBar(title: const Text('工作台')), body: body);
}
return ColoredBox(
color: kPaper,
child: Column(
children: [
WxHeader(
title: '工作台',
actions: [
IconButton(
onPressed: () async {
final c = TextEditingController(text: _q);
final v = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('搜索应用'),
content: TextField(controller: c, autofocus: true, decoration: const InputDecoration(hintText: '待办、投标、报销…')),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, ''), child: const Text('清除')),
FilledButton(onPressed: () => Navigator.pop(ctx, c.text.trim()), child: const Text('搜索')),
],
),
);
if (v != null) setState(() => _q = v);
},
icon: const Icon(Icons.search, color: kInk),
),
],
),
Expanded(child: body),
],
),
);
}
List<Widget> _menuGroups() {
final out = <Widget>[];
for (final m in widget.session.menus) {
if (_skipGroup(m)) continue;
if (m.children.isNotEmpty) {
final items = <AppGridItem>[];
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;
items.add(AppGridItem(
label: c.name,
icon: iconFor(c.code, c.name),
color: colorFor(c.code, c.name, i),
onTap: () => openMenuNode(context, widget.session, widget.api, c),
));
}
if (items.isEmpty) continue;
out.add(GroupCard(title: m.name, child: AppGrid(items: items)));
} else if (!_skipChild(m) && _match(m.name)) {
out.add(GroupCard(
title: m.name,
child: AppGrid(items: [
AppGridItem(
label: m.name,
icon: iconFor(m.code, m.name),
color: colorFor(m.code, m.name),
onTap: () => openMenuNode(context, widget.session, widget.api, m),
),
]),
));
}
}
return out;
}
Widget _stat(String label, String value, VoidCallback onTap) {
return Expanded(
child: InkWell(
onTap: onTap,
child: Column(
children: [
Text(value, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: kBind)),
const SizedBox(height: 2),
Text(label, style: const TextStyle(fontSize: 12, color: kMute)),
],
),
),
);
}
}