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,238 @@
import 'package:flutter/material.dart';
import '../../api/oa_client.dart';
import '../../device/device_bridge.dart';
import '../desk_theme.dart';
import '../widgets/desk_widgets.dart';
class DeskAttendancePage extends StatefulWidget {
const DeskAttendancePage({super.key, required this.api});
final OaClient api;
@override
State<DeskAttendancePage> createState() => _DeskAttendancePageState();
}
class _DeskAttendancePageState extends State<DeskAttendancePage> {
Map<String, dynamic> _status = {};
Map<String, double>? _location;
bool _loading = true;
bool _sending = false;
String _mode = 'OFFICE';
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() => _loading = true);
try {
_location = await DeviceBridge.getLocation();
final q = <String, String>{
if (_location != null) 'lat': '${_location!['lat']}',
if (_location != null) 'lng': '${_location!['lng']}',
'mode': _mode,
};
final data = Map<String, dynamic>.from(await widget.api.get('/attendance/punch-status', query: q) as Map);
if (mounted) setState(() {
_status = data;
_loading = false;
});
if (mounted && _location == null && _mode == 'OFFICE') {
deskToast(context, '未获取到定位,请允许位置权限后点刷新', error: true);
}
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _punch(String kind) async {
if (_mode == 'OFFICE' && _status['tripActive'] == true) return;
if (_mode == 'FIELD' && _status['fieldApproved'] != true) return;
setState(() => _sending = true);
try {
await widget.api.post('/attendance/punch', {'kind': kind, 'mode': _mode, if (_location != null) ..._location!});
if (mounted) deskToast(context, '打卡成功');
await _load();
} catch (e) {
if (mounted) deskToast(context, '$e', error: true);
} finally {
if (mounted) setState(() => _sending = false);
}
}
String _time(dynamic value) {
if (value == null || '$value' == 'null' || '$value'.isEmpty) return '未打卡';
final d = DateTime.tryParse('$value')?.toLocal();
return d == null ? '$value' : '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
final inKey = _mode == 'FIELD' ? 'fieldIn' : 'clockIn';
final outKey = _mode == 'FIELD' ? 'fieldOut' : 'clockOut';
final inAt = _time(_status[inKey]);
final outAt = _time(_status[outKey]);
final hasIn = inAt != '未打卡';
final hasOut = outAt != '未打卡';
final trip = _status['tripActive'] == true;
final fieldOk = _status['fieldApproved'] == true;
final canPunch = _mode == 'FIELD' ? fieldOk : !trip && _status['inRange'] != false;
return ColoredBox(
color: kDeskBg,
child: Column(
children: [
DeskPaneHeader(
title: '考勤打卡',
actions: [IconButton(onPressed: _load, icon: const Icon(Icons.refresh, size: 20))],
),
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
Expanded(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
SegmentedButton<String>(
segments: const [
ButtonSegment(value: 'OFFICE', label: Text('上下班打卡'), icon: Icon(Icons.access_time)),
ButtonSegment(value: 'FIELD', label: Text('外勤打卡'), icon: Icon(Icons.location_on_outlined)),
],
selected: {_mode},
onSelectionChanged: (v) async {
setState(() => _mode = v.first);
await _load();
},
),
const SizedBox(height: 20),
Material(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
Text(_today(), style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700)),
const SizedBox(height: 12),
_banner(canPunch, trip, fieldOk),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_punchBtn(
enabled: canPunch && !hasIn && !_sending,
label: _mode == 'FIELD' ? '外勤上班' : '上班打卡',
onTap: () => _punch('IN'),
),
const SizedBox(width: 16),
_punchBtn(
enabled: canPunch && hasIn && !hasOut && !_sending,
label: '下班打卡',
color: kDeskGreen,
onTap: () => _punch('OUT'),
),
],
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_timeBox('上班', inAt, _status['late'] == true),
_timeBox('下班', outAt, _status['early'] == true),
],
),
],
),
),
),
const SizedBox(height: 12),
Material(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
child: ListTile(
leading: Icon(Icons.place, color: _status['inRange'] == true ? kDeskGreen : const Color(0xFFFA5151)),
title: Text('${_status['rangeText'] ?? '正在获取考勤范围'}'),
subtitle: Text(_status['distanceMeters'] == null ? '请开启定位后刷新' : '距离考勤点约 ${_status['distanceMeters']} 米'),
),
),
],
),
),
),
),
),
],
),
);
}
String _today() {
final d = DateTime.now();
return '${d.year}年${d.month}月${d.day}日';
}
Widget _banner(bool canPunch, bool trip, bool fieldOk) {
final text = _mode == 'FIELD'
? (fieldOk ? '外出申请已通过,可进行外勤打卡' : '需先申请并审批通过外出')
: (trip ? '出差期间无需打卡' : (canPunch ? '当前可进行上下班打卡' : '未进入考勤范围'));
final color = trip || fieldOk || canPunch ? kDeskGreen : const Color(0xFFFA9D3B);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(color: color.withValues(alpha: .1), borderRadius: BorderRadius.circular(8)),
child: Text(text, textAlign: TextAlign.center, style: TextStyle(color: color, fontWeight: FontWeight.w600, fontSize: 13)),
);
}
Widget _punchBtn({required bool enabled, required String label, required VoidCallback onTap, Color? color}) {
final c = color ?? kDeskBind;
return SizedBox(
width: 140,
height: 140,
child: Material(
color: enabled ? c : const Color(0xFFE8E8E8),
shape: const CircleBorder(),
child: InkWell(
onTap: enabled ? onTap : null,
customBorder: const CircleBorder(),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: TextStyle(color: enabled ? Colors.white : kDeskMute, fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Text(_nowTime(), style: TextStyle(color: enabled ? Colors.white : kDeskMute, fontSize: 18, fontWeight: FontWeight.w700)),
],
),
),
),
),
);
}
Widget _timeBox(String label, String value, bool flag) {
return Column(
children: [
Text(label, style: const TextStyle(color: kDeskMute, fontSize: 12)),
const SizedBox(height: 4),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(value, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
if (flag) const Padding(padding: EdgeInsets.only(left: 4), child: Icon(Icons.error, color: Color(0xFFFA9D3B), size: 16)),
],
),
],
);
}
String _nowTime() {
final d = DateTime.now();
return '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}';
}
}
@@ -0,0 +1,233 @@
import 'package:flutter/material.dart';
import '../../api/oa_client.dart';
import '../../im/chat_prefs.dart';
import '../../session/session.dart';
import '../desk_theme.dart';
import '../widgets/desk_widgets.dart';
import 'desk_pick_people_page.dart';
class DeskChatDetailPage extends StatefulWidget {
const DeskChatDetailPage({
super.key,
required this.session,
required this.api,
required this.conversationId,
required this.peerId,
required this.peerName,
required this.isGroup,
this.peerAvatarFileId,
this.onCall,
});
final SessionStore session;
final OaClient api;
final String conversationId;
final String peerId;
final String peerName;
final bool isGroup;
final String? peerAvatarFileId;
final void Function(String kind)? onCall;
@override
State<DeskChatDetailPage> createState() => _DeskChatDetailPageState();
}
class _DeskChatDetailPageState extends State<DeskChatDetailPage> {
List<Map<String, dynamic>> _members = [];
bool _mute = false;
bool _pin = false;
@override
void initState() {
super.initState();
_mute = ChatPrefs.muted(widget.conversationId);
_pin = ChatPrefs.pinned(widget.conversationId);
_loadMembers();
}
Future<void> _loadMembers() async {
if (widget.isGroup && widget.conversationId.isNotEmpty) {
try {
final data = await widget.api.get('/im/groups/${widget.conversationId}/members');
if (!mounted) return;
setState(() => _members = asMaps(data));
return;
} catch (_) {}
}
setState(() {
_members = [
{'id': widget.peerId, 'name': widget.peerName, 'avatarFileId': widget.peerAvatarFileId},
{'id': widget.session.userId, 'name': widget.session.displayName, 'avatarFileId': widget.session.user['avatarFileId']},
];
});
}
Future<void> _addMembers() async {
final exclude = {widget.session.userId, ..._members.map((e) => '${e['id'] ?? e['userId'] ?? ''}')};
final picked = await Navigator.of(context).push<List<Map<String, dynamic>>>(
MaterialPageRoute(builder: (_) => DeskPickPeoplePage(api: widget.api, title: '选择同事', exclude: exclude)),
);
if (picked == null || picked.isEmpty) return;
try {
if (widget.isGroup && widget.conversationId.isNotEmpty) {
await widget.api.post('/im/groups/${widget.conversationId}/members', {
'memberIds': picked.map((e) => '${e['id']}').toList(),
});
await _loadMembers();
} else {
await widget.api.post('/im/groups', {
'name': '${widget.peerName}的群聊',
'memberIds': [widget.peerId, ...picked.map((e) => '${e['id']}')],
});
if (mounted) {
deskToast(context, '已创建群聊');
Navigator.pop(context);
}
}
} catch (e) {
if (mounted) deskToast(context, '$e', error: true);
}
}
Future<void> _clear() async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('删除聊天记录'),
content: const Text('将从本机清空当前会话的聊天记录,对方不受影响。'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('删除')),
],
),
);
if (ok != true) return;
await ChatPrefs.clearHistory(widget.conversationId);
if (mounted) {
deskToast(context, '已删除本机聊天记录');
Navigator.pop(context, 'cleared');
}
}
@override
Widget build(BuildContext context) {
return ColoredBox(
color: kDeskBg,
child: Column(
children: [
DeskPaneHeader(
title: '聊天详情',
leading: IconButton(icon: const Icon(Icons.arrow_back, size: 20), onPressed: () => Navigator.pop(context)),
),
Expanded(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: ListView(
padding: const EdgeInsets.all(20),
children: [
Material(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Wrap(
spacing: 16,
runSpacing: 12,
children: [
for (final m in _members)
SizedBox(
width: 56,
child: Column(
children: [
DeskAvatar(
label: '${m['name'] ?? m['displayName'] ?? ''}',
fileId: '${m['avatarFileId'] ?? ''}',
api: widget.api,
size: 44,
),
const SizedBox(height: 4),
Text('${m['name'] ?? m['displayName'] ?? ''}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11)),
],
),
),
InkWell(
onTap: _addMembers,
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(6), border: Border.all(color: kDeskLine)),
child: const Icon(Icons.add, color: kDeskMute),
),
),
],
),
),
),
const SizedBox(height: 12),
_section([
_switchRow('消息免打扰', _mute, (v) async {
await ChatPrefs.setMuted(widget.conversationId, v);
setState(() => _mute = v);
}),
_switchRow('置顶聊天', _pin, (v) async {
await ChatPrefs.setPinned(widget.conversationId, v);
setState(() => _pin = v);
}),
]),
const SizedBox(height: 12),
_section([
_actionRow('语音通话', () => widget.onCall?.call('voice')),
_actionRow('视频通话', () => widget.onCall?.call(widget.isGroup ? 'meeting' : 'video')),
_actionRow('删除聊天记录', _clear, danger: true),
]),
],
),
),
),
),
],
),
);
}
Widget _section(List<Widget> children) {
return Material(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
clipBehavior: Clip.antiAlias,
child: Column(children: children),
);
}
Widget _switchRow(String title, bool value, ValueChanged<bool> onChanged) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
Expanded(child: Text(title, style: const TextStyle(fontSize: 14))),
Switch(value: value, activeTrackColor: kDeskGreen, onChanged: onChanged),
],
),
);
}
Widget _actionRow(String title, VoidCallback onTap, {bool danger = false}) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
Expanded(child: Text(title, style: TextStyle(fontSize: 14, color: danger ? const Color(0xFFFA5151) : kDeskInk))),
if (!danger) const Icon(Icons.chevron_right, size: 18, color: kDeskMute),
],
),
),
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,279 @@
import 'package:flutter/material.dart';
import '../../api/oa_client.dart';
import '../../im/pinyin.dart';
import 'desk_org_browse_page.dart';
import '../../session/session.dart';
import '../desk_theme.dart';
import '../widgets/desk_widgets.dart';
class DeskContactsPage extends StatefulWidget {
const DeskContactsPage({super.key, required this.session, required this.api, this.onMessage});
final SessionStore session;
final OaClient api;
final void Function(Map<String, dynamic> person)? onMessage;
@override
State<DeskContactsPage> createState() => _DeskContactsPageState();
}
class _DeskContactsPageState extends State<DeskContactsPage> {
List<Map<String, dynamic>> _items = [];
Map<String, List<String>> _presence = {};
String _q = '';
bool _loading = true;
String? _selectedId;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final data = await widget.api.get('/staff');
Map<String, List<String>> presence = {};
try {
final ids = asMaps(data).map((e) => '${e['id']}').where((e) => e.isNotEmpty).join(',');
final raw = await widget.api.get('/im/presence', query: {'userIds': ids});
if (raw is Map) {
presence = raw.map((k, v) => MapEntry('$k', (v is List ? v : const []).map((x) => '$x').toList()));
}
} catch (_) {}
if (!mounted) return;
setState(() {
_items = asMaps(data);
_presence = presence;
_loading = false;
});
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
List<Map<String, dynamic>> get _shown {
if (_q.isEmpty) return _items;
return _items.where((e) => '${e['name']}${e['mobile']}${e['department']}'.contains(_q)).toList();
}
Map<String, List<Map<String, dynamic>>> get _groups {
final g = <String, List<Map<String, dynamic>>>{};
for (final r in _shown) {
final letter = letterOf('${r['name'] ?? ''}');
g.putIfAbsent(letter, () => []).add(r);
}
for (final k in g.keys) {
g[k]!.sort((a, b) => '${a['name']}'.compareTo('${b['name']}'));
}
return g;
}
Future<void> _message(Map<String, dynamic> r) async {
if (widget.onMessage != null) {
widget.onMessage!(r);
return;
}
String conversationId = '';
try {
final raw = await widget.api.post('/im/conversations/direct', {'peerId': '${r['id']}'});
if (raw is Map) conversationId = '${raw['conversationId'] ?? raw['id'] ?? ''}';
} catch (_) {}
if (!mounted) return;
widget.onMessage?.call({
...r,
'conversationId': conversationId,
'peerId': '${r['id']}',
'name': '${r['name'] ?? '同事'}',
'type': 'direct',
});
}
@override
Widget build(BuildContext context) {
final groups = _groups;
final letters = groups.keys.toList()..sort();
final depts = _items.map((e) => '${e['department'] ?? ''}').where((e) => e.isNotEmpty).toSet();
final selected = _selectedId == null ? null : _items.cast<Map<String, dynamic>?>().firstWhere(
(e) => '${e?['id']}' == _selectedId,
orElse: () => null,
);
return ColoredBox(
color: kDeskBg,
child: Row(
children: [
SizedBox(
width: 300,
child: ColoredBox(
color: kDeskListBg,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
child: DeskSearchField(hint: '搜索同事', onChanged: (v) => setState(() => _q = v.trim())),
),
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
Expanded(
child: ListView(
children: [
if (_q.isEmpty) ...[
_entry(Icons.account_tree, '组织架构', '${depts.length} 个部门', () {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => DeskOrgBrowsePage(
session: widget.session,
api: widget.api,
staff: _items,
onMessage: widget.onMessage,
),
));
}),
const Divider(height: 1, color: kDeskLine),
],
for (final letter in letters) ...[
Padding(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 4),
child: Text(letter, style: const TextStyle(fontSize: 12, color: kDeskMute, fontWeight: FontWeight.w600)),
),
for (final r in groups[letter]!)
_personTile(r),
],
],
),
),
],
),
),
),
const VerticalDivider(width: 1, color: kDeskLine),
Expanded(
child: selected == null
? const Center(child: Text('选择联系人查看详情', style: TextStyle(color: kDeskMute)))
: _detail(selected),
),
],
),
);
}
Widget _entry(IconData icon, String title, String sub, VoidCallback onTap) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
child: Row(
children: [
DeskAvatar(label: title, api: widget.api, size: 40, color: kDeskBind, icon: icon),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
Text(sub, style: const TextStyle(fontSize: 12, color: kDeskMute)),
],
),
),
],
),
),
),
);
}
Widget _personTile(Map<String, dynamic> r) {
final id = '${r['id']}';
final online = (_presence[id] ?? const []).contains('online');
return Material(
color: _selectedId == id ? kDeskActive : Colors.transparent,
child: InkWell(
onTap: () => setState(() => _selectedId = id),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
child: Row(
children: [
DeskAvatar(label: '${r['name']}', fileId: '${r['avatarFileId'] ?? ''}', api: widget.api, size: 36),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${r['name']}', style: const TextStyle(fontSize: 14)),
Text(
[r['department'], r['title']].where((e) => e != null && '$e'.isNotEmpty).join(' · '),
style: const TextStyle(fontSize: 12, color: kDeskMute),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: online ? kDeskGreen : const Color(0xFFD0D0D0),
shape: BoxShape.circle,
),
),
],
),
),
),
);
}
Widget _detail(Map<String, dynamic> r) {
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
DeskAvatar(label: '${r['name']}', fileId: '${r['avatarFileId'] ?? ''}', api: widget.api, size: 80),
const SizedBox(height: 16),
Text('${r['name']}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600)),
const SizedBox(height: 6),
Text(
[r['department'], r['title']].where((e) => e != null && '$e'.isNotEmpty).join(' · '),
style: const TextStyle(color: kDeskMute),
),
const SizedBox(height: 24),
_infoRow('手机', '${r['mobile'] ?? '未填写'}'),
_infoRow('邮箱', '${r['email'] ?? '未填写'}'),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
style: FilledButton.styleFrom(backgroundColor: kDeskBind, minimumSize: const Size.fromHeight(40)),
onPressed: () => _message(r),
child: const Text('发消息'),
),
),
],
),
),
),
);
}
Widget _infoRow(String k, String v) {
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8), border: Border.all(color: kDeskLine)),
child: Row(
children: [
SizedBox(width: 56, child: Text(k, style: const TextStyle(color: kDeskMute, fontSize: 13))),
Expanded(child: Text(v, style: const TextStyle(fontSize: 14))),
],
),
);
}
}
@@ -0,0 +1,256 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../api/oa_client.dart';
import '../../im/chat_prefs.dart';
import 'desk_pick_people_page.dart';
import '../../session/session.dart';
import '../desk_theme.dart';
import '../widgets/desk_widgets.dart';
class DeskConvListPage extends StatefulWidget {
const DeskConvListPage({
super.key,
required this.session,
required this.api,
this.selectedId,
required this.onSelect,
});
final SessionStore session;
final OaClient api;
final String? selectedId;
final void Function(Map<String, dynamic> row) onSelect;
@override
State<DeskConvListPage> createState() => _DeskConvListPageState();
}
class _DeskConvListPageState extends State<DeskConvListPage> {
List<Map<String, dynamic>> _items = [];
bool _loading = true;
String _filter = 'all';
String _q = '';
Timer? _debounce;
@override
void initState() {
super.initState();
widget.session.im.addListener(_onIm);
ChatPrefs.ensure().then((_) {
if (mounted) setState(() {});
});
_load();
}
@override
void dispose() {
_debounce?.cancel();
widget.session.im.removeListener(_onIm);
super.dispose();
}
void _onIm() {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 300), () {
if (mounted) _load();
});
}
Future<void> _load() async {
try {
final data = await widget.api.get('/im/conversations');
if (!mounted) return;
setState(() {
_items = asMaps(data);
_loading = false;
});
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
List<Map<String, dynamic>> get _shown {
var list = _items.where((e) => !ChatPrefs.hidden('${e['id'] ?? ''}')).toList();
if (_filter == 'unread') list = list.where((e) => ((e['unread'] as num?)?.toInt() ?? 0) > 0).toList();
if (_filter == 'dm') list = list.where((e) => e['type'] != 'group').toList();
if (_filter == 'group') list = list.where((e) => e['type'] == 'group').toList();
if (_q.isNotEmpty) {
list = list.where((e) => '${e['name']}${e['peerName']}${e['lastText']}'.contains(_q)).toList();
}
list.sort((a, b) {
final ap = ChatPrefs.pinned('${a['id'] ?? ''}');
final bp = ChatPrefs.pinned('${b['id'] ?? ''}');
if (ap == bp) return 0;
return ap ? -1 : 1;
});
return list;
}
Future<void> _menu(Map<String, dynamic> r, Offset pos) async {
final id = '${r['id'] ?? ''}';
if (id.isEmpty) return;
final muted = ChatPrefs.muted(id);
final pinned = ChatPrefs.pinned(id);
final unread = (r['unread'] as num?)?.toInt() ?? 0;
final fake = ChatPrefs.fakeUnread(id);
final selected = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(pos.dx, pos.dy, pos.dx + 1, pos.dy + 1),
items: [
if (unread == 0 && fake == 0) const PopupMenuItem(value: 'unread', child: Text('标为未读')),
PopupMenuItem(value: 'mute', child: Text(muted ? '取消免打扰' : '消息免打扰')),
PopupMenuItem(value: 'pin', child: Text(pinned ? '取消置顶' : '置顶')),
const PopupMenuItem(value: 'hide', child: Text('不显示')),
const PopupMenuDivider(),
const PopupMenuItem(value: 'clear', child: Text('清空聊天记录')),
const PopupMenuItem(value: 'delete', child: Text('删除')),
],
);
if (!mounted || selected == null) return;
switch (selected) {
case 'unread':
await ChatPrefs.setFakeUnread(id, 1);
case 'mute':
await ChatPrefs.setMuted(id, !muted);
case 'pin':
await ChatPrefs.setPinned(id, !pinned);
case 'hide':
case 'delete':
await ChatPrefs.setHidden(id, true);
case 'clear':
await ChatPrefs.clearHistory(id);
}
if (mounted) {
setState(() {});
await _load();
}
}
Future<void> _newChat({required bool group}) async {
final picked = await Navigator.of(context).push<List<Map<String, dynamic>>>(
MaterialPageRoute(
builder: (_) => DeskPickPeoplePage(
api: widget.api,
title: group ? '选择联系人' : '发起单聊',
multiple: group,
exclude: {widget.session.userId},
),
),
);
if (picked == null || picked.isEmpty) return;
if (!group) {
final p = picked.first;
widget.onSelect({
'id': '',
'peerId': '${p['id']}',
'name': '${p['name'] ?? '同事'}',
'avatarFileId': '${p['avatarFileId'] ?? ''}',
'type': 'direct',
});
return;
}
final name = TextEditingController();
if (!mounted) return;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('群名称'),
content: TextField(controller: name, decoration: const InputDecoration(hintText: '例如:项目组')),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('创建')),
],
),
);
if (ok == true) {
await widget.api.post('/im/groups', {
'name': name.text.trim().isEmpty ? '群聊' : name.text.trim(),
'memberIds': picked.map((e) => '${e['id']}').toList(),
});
await _load();
}
}
void _plusMenu(BuildContext ctx) {
final box = ctx.findRenderObject() as RenderBox?;
final pos = box?.localToGlobal(Offset.zero) ?? Offset.zero;
showMenu<void>(
context: context,
position: RelativeRect.fromLTRB(pos.dx, pos.dy + 32, pos.dx + 200, pos.dy),
items: [
PopupMenuItem(onTap: () => Future.microtask(() => _newChat(group: false)), child: const Text('发起单聊')),
PopupMenuItem(onTap: () => Future.microtask(() => _newChat(group: true)), child: const Text('发起群聊')),
],
);
}
@override
Widget build(BuildContext context) {
final shown = _shown;
return ColoredBox(
color: kDeskListBg,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
child: Row(
children: [
Expanded(child: DeskSearchField(hint: '搜索', onChanged: (v) => setState(() => _q = v.trim()))),
const SizedBox(width: 8),
Material(
color: const Color(0xFFEFEFEF),
borderRadius: BorderRadius.circular(6),
child: InkWell(
onTap: () {
final rb = context.findRenderObject() as RenderBox?;
if (rb != null) _plusMenu(context);
},
borderRadius: BorderRadius.circular(6),
child: const SizedBox(width: 32, height: 32, child: Icon(Icons.add, size: 18, color: kDeskInk)),
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: DeskFilterChips(
value: _filter,
onChanged: (v) => setState(() => _filter = v),
items: const [('all', '全部'), ('unread', '未读'), ('dm', '单聊'), ('group', '群聊')],
),
),
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
Expanded(
child: shown.isEmpty
? const Center(child: Text('暂无会话', style: TextStyle(color: kDeskMute)))
: ListView.builder(
itemCount: shown.length,
itemBuilder: (_, i) {
final r = shown[i];
final id = '${r['id'] ?? ''}';
final fake = ChatPrefs.fakeUnread(id);
final unread = fake > 0 ? fake : ((r['unread'] as num?)?.toInt() ?? 0);
return DeskConvRow(
name: '${r['name'] ?? r['peerName'] ?? '会话'}',
preview: '${r['lastText'] ?? ''}',
time: shortTime(r['lastAt'] ?? r['updatedAt']),
unread: unread,
selected: widget.selectedId == id,
muted: ChatPrefs.muted(id),
pinned: ChatPrefs.pinned(id),
avatarId: '${r['avatarFileId'] ?? ''}',
api: widget.api,
onTap: () => widget.onSelect(r),
onSecondaryTap: (d) => _menu(r, d.globalPosition),
);
},
),
),
],
),
);
}
}
@@ -0,0 +1,169 @@
import 'package:flutter/material.dart';
import '../../api/oa_client.dart';
import '../../device/device_bridge.dart';
import '../../labels.dart';
import '../desk_theme.dart';
import '../widgets/desk_data_table.dart';
import '../widgets/desk_widgets.dart';
import 'desk_record_detail_page.dart';
const _kinds = [
('PERSONAL', '请假'),
('OVERTIME', '加班'),
('BUSINESS', '出差'),
('OUT', '外出'),
('REGULARIZE', '转正'),
('RESIGN', '离职'),
];
class DeskHrApplyPage extends StatefulWidget {
const DeskHrApplyPage({super.key, required this.api});
final OaClient api;
@override
State<DeskHrApplyPage> createState() => _DeskHrApplyPageState();
}
class _DeskHrApplyPageState extends State<DeskHrApplyPage> {
List<Map<String, dynamic>> _items = [];
bool _loading = true;
String _bucket = '';
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() => _loading = true);
try {
final q = _bucket.isEmpty ? null : {'bucket': _bucket};
final data = await widget.api.get('/leave-requests', query: q);
if (!mounted) return;
setState(() {
_items = asMaps(data);
_loading = false;
});
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _create() async {
var kind = 'LEAVE';
final reason = TextEditingController();
final days = TextEditingController(text: '1');
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('发起人事申请'),
content: SizedBox(
width: 420,
child: StatefulBuilder(
builder: (ctx, setSt) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (final k in _kinds)
FilterChip(
label: Text(k.$2),
selected: kind == k.$1,
onSelected: (_) => setSt(() => kind = k.$1),
),
],
),
const SizedBox(height: 12),
TextField(controller: days, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: '天数')),
const SizedBox(height: 8),
TextField(controller: reason, maxLines: 3, decoration: const InputDecoration(labelText: '事由')),
],
),
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('提交')),
],
),
);
if (ok != true) return;
try {
final created = Map<String, dynamic>.from(await widget.api.post('/leave-requests', {
'kind': kind,
'reason': reason.text.trim(),
'days': num.tryParse(days.text) ?? 1,
}) as Map);
final id = '${created['id'] ?? ''}';
if (id.isNotEmpty && mounted) {
final add = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('添加申请材料'),
content: const Text('可以上传病假证明、行程单等附件。'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('暂不添加')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('选择附件')),
],
),
);
if (add == true) {
final picked = await DeviceBridge.pickFile();
if (picked != null && picked['path'] != null) {
await widget.api.uploadFile(
filePath: picked['path']!,
filename: picked['name'] ?? '申请材料',
bizType: 'HR_LEAVE',
bizId: id,
mime: picked['mime'],
);
}
}
}
if (mounted) {
deskToast(context, '已提交,等待审批');
_load();
}
} catch (e) {
if (mounted) deskToast(context, '$e', error: true);
}
}
@override
Widget build(BuildContext context) {
return DeskListScaffold(
title: '人事申请',
loading: _loading,
actions: [
FilledButton.icon(
style: FilledButton.styleFrom(backgroundColor: kDeskBind),
onPressed: _create,
icon: const Icon(Icons.add, size: 18),
label: const Text('发起申请'),
),
],
filters: DeskFilterChips(
value: _bucket,
onChanged: (v) {
setState(() => _bucket = v);
_load();
},
items: const [('', '全部'), ('pending', '待审批'), ('approved', '已通过'), ('rejected', '已驳回')],
),
body: DeskDataTable(
columns: const ['类型', '事由', '状态', '时间'],
rows: [
for (final r in _items)
[zh(r['kind']), '${r['reason'] ?? ''}', zh(r['status']), fmtTime(r['createdAt'])],
],
emptyHint: '还没有人事申请',
onRowTap: (i) => deskOpenRecord(context, widget.api, {..._items[i], 'source': 'HR'}),
),
);
}
}
@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import '../../api/oa_client.dart';
import '../desk_theme.dart';
import '../widgets/desk_widgets.dart';
class DeskLegalPage extends StatefulWidget {
const DeskLegalPage({super.key, required this.api, required this.kind});
final OaClient api;
final String kind;
@override
State<DeskLegalPage> createState() => _DeskLegalPageState();
}
class _DeskLegalPageState extends State<DeskLegalPage> {
String _title = '';
List<Map<String, String>> _sections = [];
String _error = '';
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final raw = await widget.api.get('/legal/${widget.kind}');
if (!mounted) return;
if (raw is Map) {
final data = Map<String, dynamic>.from(raw);
final paras = asMaps(data['paragraphs']);
setState(() {
_title = '${data['title'] ?? ''}';
_sections = paras.isNotEmpty
? paras.map((e) => {'title': '${e['heading'] ?? e['title'] ?? ''}', 'body': '${e['body'] ?? ''}'}).toList()
: [{'title': '', 'body': '${data['body'] ?? data['content'] ?? ''}'}];
_loading = false;
});
}
} catch (e) {
if (mounted) setState(() {
_error = '$e';
_loading = false;
});
}
}
@override
Widget build(BuildContext context) {
return ColoredBox(
color: kDeskBg,
child: Column(
children: [
DeskPaneHeader(
title: _title.isEmpty ? (widget.kind == 'privacy' ? '隐私政策' : '用户协议') : _title,
leading: IconButton(icon: const Icon(Icons.arrow_back, size: 20), onPressed: () => Navigator.pop(context)),
),
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
Expanded(
child: _error.isNotEmpty
? Center(child: Text(_error, style: const TextStyle(color: kDeskMute)))
: ListView(
padding: const EdgeInsets.all(24),
children: [
for (final s in _sections) ...[
Text(s['title'] ?? '', style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
Text(s['body'] ?? '', style: const TextStyle(fontSize: 14, height: 1.6, color: kDeskInk)),
const SizedBox(height: 20),
],
],
),
),
],
),
);
}
}
@@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import '../../api/oa_client.dart';
import '../../labels.dart';
import 'desk_record_detail_page.dart';
import '../widgets/desk_data_table.dart';
import '../widgets/desk_widgets.dart';
/// 桌面端办公列表(待办、审批、申请等),表格布局。
class DeskOfficeListPage extends StatefulWidget {
const DeskOfficeListPage({
super.key,
required this.api,
required this.title,
required this.path,
this.query,
this.buckets = const [],
this.defaultBucket = '',
this.columns = const ['标题', '类型', '时间', '状态'],
this.rowBuilder,
});
final OaClient api;
final String title;
final String path;
final Map<String, String>? query;
final List<(String, String)> buckets;
final String defaultBucket;
final List<String> columns;
final List<String> Function(Map<String, dynamic> row)? rowBuilder;
@override
State<DeskOfficeListPage> createState() => _DeskOfficeListPageState();
}
class _DeskOfficeListPageState extends State<DeskOfficeListPage> {
List<Map<String, dynamic>> _items = [];
String _bucket = '';
bool _loading = true;
String _q = '';
@override
void initState() {
super.initState();
_bucket = widget.defaultBucket;
_load();
}
Future<void> _load() async {
setState(() => _loading = true);
try {
final q = <String, String>{...?widget.query};
if (_bucket.isNotEmpty) q['bucket'] = _bucket;
if (_bucket.isNotEmpty && widget.buckets.any((b) => b.$1 == _bucket && b.$1 == 'PENDING')) {
q.remove('bucket');
}
final data = await widget.api.get(widget.path, query: q.isEmpty ? null : q);
if (!mounted) return;
setState(() {
_items = asMaps(data);
_loading = false;
});
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
List<Map<String, dynamic>> get _shown {
var list = _bucket.isEmpty || widget.buckets.isEmpty
? _items
: _items.where((e) {
if (widget.path.contains('approvals')) return '${e['status']}' == _bucket;
if (widget.path.contains('todos')) return '${e['status']}' == _bucket;
return true;
}).toList();
if (_q.isNotEmpty) {
list = list.where((e) => '${e['title']}${e['bizType']}'.contains(_q)).toList();
}
return list;
}
List<String> _row(Map<String, dynamic> r) {
if (widget.rowBuilder != null) return widget.rowBuilder!(r);
return [
'${r['title'] ?? ''}',
zh(r['bizType'] ?? r['type'] ?? ''),
fmtTime(r['dueAt'] ?? r['createdAt'] ?? ''),
zhStatus(r['status']),
];
}
String zhStatus(dynamic s) {
final v = '$s';
const m = {'PENDING': '待处理', 'OPEN': '待办', 'DONE': '已完成', 'APPROVED': '已通过', 'REJECTED': '已驳回'};
return m[v] ?? v;
}
@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: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DeskSearchField(hint: '搜索', onChanged: (v) => setState(() => _q = v.trim())),
if (widget.buckets.isNotEmpty) ...[
const SizedBox(height: 8),
DeskFilterChips(
value: _bucket,
onChanged: (v) {
setState(() => _bucket = v);
_load();
},
items: widget.buckets,
),
],
],
),
body: DeskDataTable(
columns: widget.columns,
rows: [for (final r in shown) _row(r)],
emptyHint: '暂无${widget.title}',
onRowTap: (i) => deskOpenRecord(context, widget.api, shown[i]),
),
);
}
}
@@ -0,0 +1,183 @@
import 'package:flutter/material.dart';
import '../../api/oa_client.dart';
import '../../session/session.dart';
import '../desk_theme.dart';
import '../widgets/desk_widgets.dart';
class DeskOrgBrowsePage extends StatefulWidget {
const DeskOrgBrowsePage({
super.key,
required this.session,
required this.api,
required this.staff,
this.parentId,
this.title = '组织架构',
this.deptName,
this.onMessage,
});
final SessionStore session;
final OaClient api;
final List<Map<String, dynamic>> staff;
final String? parentId;
final String title;
final String? deptName;
final void Function(Map<String, dynamic> person)? onMessage;
@override
State<DeskOrgBrowsePage> createState() => _DeskOrgBrowsePageState();
}
class _DeskOrgBrowsePageState extends State<DeskOrgBrowsePage> {
List<Map<String, dynamic>> _depts = [];
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final data = await widget.api.get('/departments');
if (!mounted) return;
setState(() {
_depts = asMaps(data);
_loading = false;
});
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
List<Map<String, dynamic>> get _children {
if (_depts.isEmpty) return const [];
return _depts.where((e) {
final pid = '${e['parentId'] ?? ''}';
if (widget.parentId == null || widget.parentId!.isEmpty) return pid.isEmpty || pid == 'null';
return pid == widget.parentId;
}).toList();
}
List<Map<String, dynamic>> get _people {
final name = widget.deptName;
if (name == null || name.isEmpty) return const [];
final me = widget.session.userId;
return widget.staff.where((e) => '${e['department']}' == name && '${e['id']}' != me).toList();
}
int _countOf(Map<String, dynamic> d) {
final c = d['_count'];
if (c is Map && c['employees'] != null) return (c['employees'] as num).toInt();
return widget.staff.where((e) => '${e['department']}' == '${d['name']}').length;
}
void _openDept(Map<String, dynamic> d) {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => DeskOrgBrowsePage(
session: widget.session,
api: widget.api,
staff: widget.staff,
parentId: '${d['id']}',
title: '${d['name']}',
deptName: '${d['name']}',
onMessage: widget.onMessage,
),
));
}
Future<void> _message(Map<String, dynamic> r) async {
if (widget.onMessage != null) {
widget.onMessage!(r);
return;
}
String conversationId = '';
try {
final raw = await widget.api.post('/im/conversations/direct', {'peerId': '${r['id']}'});
if (raw is Map) conversationId = '${raw['conversationId'] ?? raw['id'] ?? ''}';
} catch (_) {}
if (mounted) Navigator.pop(context, {...r, 'conversationId': conversationId, 'peerId': '${r['id']}', 'name': '${r['name']}', 'type': 'direct'});
}
@override
Widget build(BuildContext context) {
return ColoredBox(
color: kDeskBg,
child: Column(
children: [
DeskPaneHeader(
title: widget.title,
leading: IconButton(icon: const Icon(Icons.arrow_back, size: 20), onPressed: () => Navigator.pop(context)),
),
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
Expanded(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
for (final d in _children)
Material(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: () => _openDept(d),
borderRadius: BorderRadius.circular(8),
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(14),
child: Row(
children: [
DeskAvatar(label: '${d['name']}', api: widget.api, size: 40, color: kDeskBind, icon: Icons.folder_outlined),
const SizedBox(width: 12),
Expanded(child: Text('${d['name']}', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500))),
Text('${_countOf(d)} 人', style: const TextStyle(fontSize: 12, color: kDeskMute)),
const Icon(Icons.chevron_right, size: 18, color: kDeskMute),
],
),
),
),
),
if (_people.isNotEmpty) ...[
const Padding(
padding: EdgeInsets.fromLTRB(4, 12, 4, 8),
child: Text('部门成员', style: TextStyle(fontSize: 12, color: kDeskMute, fontWeight: FontWeight.w600)),
),
for (final r in _people)
Material(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: () => _message(r),
borderRadius: BorderRadius.circular(8),
child: Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.all(12),
child: Row(
children: [
DeskAvatar(label: '${r['name']}', fileId: '${r['avatarFileId'] ?? ''}', api: widget.api, size: 36),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${r['name']}', style: const TextStyle(fontSize: 14)),
Text('${r['title'] ?? ''}', style: const TextStyle(fontSize: 12, color: kDeskMute)),
],
),
),
const Text('发消息', style: TextStyle(fontSize: 12, color: kDeskBind)),
],
),
),
),
),
],
],
),
),
],
),
);
}
}
@@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import '../../api/oa_client.dart';
import '../desk_theme.dart';
import '../widgets/desk_widgets.dart';
class DeskPickPeoplePage extends StatefulWidget {
const DeskPickPeoplePage({
super.key,
required this.api,
required this.title,
this.multiple = true,
this.exclude = const {},
});
final OaClient api;
final String title;
final bool multiple;
final Set<String> exclude;
@override
State<DeskPickPeoplePage> createState() => _DeskPickPeoplePageState();
}
class _DeskPickPeoplePageState extends State<DeskPickPeoplePage> {
List<Map<String, dynamic>> _items = [];
final _selected = <String, String>{};
String _q = '';
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final data = await widget.api.get('/staff');
if (!mounted) return;
setState(() {
_items = asMaps(data);
_loading = false;
});
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
void _confirm() {
Navigator.pop(
context,
_selected.entries.map((e) => {'id': e.key, 'name': e.value}).toList(),
);
}
@override
Widget build(BuildContext context) {
final shown = _items.where((e) {
final id = '${e['id']}';
if (widget.exclude.contains(id)) return false;
if (_q.isEmpty) return true;
return '${e['name']}${e['department']}${e['title']}'.contains(_q);
}).toList();
return Scaffold(
backgroundColor: kDeskBg,
body: Column(
children: [
DeskPaneHeader(
title: widget.title,
leading: IconButton(icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(context)),
actions: [
if (widget.multiple)
TextButton(onPressed: _selected.isEmpty ? null : _confirm, child: Text('确定(${_selected.length})')),
],
bottom: Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 10),
child: DeskSearchField(hint: '搜索同事', onChanged: (v) => setState(() => _q = v.trim())),
),
),
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
Expanded(
child: ListView.builder(
itemCount: shown.length,
itemBuilder: (_, i) {
final r = shown[i];
final id = '${r['id']}';
final name = '${r['name'] ?? ''}';
final on = _selected.containsKey(id);
return Material(
color: on ? kDeskActive : Colors.white,
child: InkWell(
onTap: () {
if (!widget.multiple) {
Navigator.pop(context, [
{'id': id, 'name': name, 'avatarFileId': '${r['avatarFileId'] ?? ''}'},
]);
return;
}
setState(() {
if (on) {
_selected.remove(id);
} else {
_selected[id] = name;
}
});
},
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 10),
child: Row(
children: [
DeskAvatar(label: name, fileId: '${r['avatarFileId'] ?? ''}', api: widget.api, size: 36),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
Text('${r['department'] ?? ''} ${r['title'] ?? ''}'.trim(),
style: const TextStyle(fontSize: 12, color: kDeskMute)),
],
),
),
if (widget.multiple)
Icon(on ? Icons.check_circle : Icons.circle_outlined, color: on ? kDeskBind : kDeskMute, size: 20),
],
),
),
),
);
},
),
),
],
),
);
}
}
@@ -0,0 +1,177 @@
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import '../../api/oa_client.dart';
import '../../session/session.dart';
import '../desk_theme.dart';
import '../widgets/desk_widgets.dart';
class DeskProfileEditPage extends StatefulWidget {
const DeskProfileEditPage({super.key, required this.session, required this.api});
final SessionStore session;
final OaClient api;
@override
State<DeskProfileEditPage> createState() => _DeskProfileEditPageState();
}
class _DeskProfileEditPageState extends State<DeskProfileEditPage> {
bool _busy = false;
Future<void> _refreshMe() async {
final me = await widget.api.get('/auth/me');
if (me is Map) await widget.session.patchUser(Map<String, dynamic>.from(me));
if (mounted) setState(() {});
}
Future<void> _pickAvatar() async {
final x = await ImagePicker().pickImage(source: ImageSource.gallery, imageQuality: 85, maxWidth: 800);
if (x == null) return;
setState(() => _busy = true);
try {
await widget.api.uploadFile(
filePath: x.path,
filename: x.name.isEmpty ? 'avatar.jpg' : x.name,
bizType: 'USER_AVATAR',
bizId: widget.session.userId,
);
await _refreshMe();
if (mounted) deskToast(context, '头像已更新');
} catch (e) {
if (mounted) deskToast(context, '$e', error: true);
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _editName() async {
final c = TextEditingController(text: widget.session.displayName);
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('修改姓名'),
content: TextField(controller: c, decoration: const InputDecoration(labelText: '显示名')),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('保存')),
],
),
);
if (ok != true || c.text.trim().isEmpty) return;
try {
final me = await widget.api.patch('/auth/profile', {'displayName': c.text.trim()});
if (me is Map) await widget.session.patchUser(Map<String, dynamic>.from(me));
if (mounted) {
setState(() {});
deskToast(context, '已保存');
}
} catch (e) {
if (mounted) deskToast(context, '$e', error: true);
}
}
Future<void> _editPassword() async {
final oldC = TextEditingController();
final newC = TextEditingController();
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('修改密码'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: oldC, obscureText: true, decoration: const InputDecoration(labelText: '当前密码')),
const SizedBox(height: 8),
TextField(controller: newC, obscureText: true, decoration: const InputDecoration(labelText: '新密码')),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('保存')),
],
),
);
if (ok != true) return;
try {
await widget.api.post('/auth/change-password', {'oldPassword': oldC.text, 'newPassword': newC.text});
if (mounted) deskToast(context, '密码已修改');
} catch (e) {
if (mounted) deskToast(context, '$e', error: true);
}
}
@override
Widget build(BuildContext context) {
final s = widget.session;
final u = s.user;
return ColoredBox(
color: kDeskBg,
child: Column(
children: [
DeskPaneHeader(
title: '个人资料',
leading: IconButton(icon: const Icon(Icons.arrow_back, size: 20), onPressed: () => Navigator.pop(context)),
),
Expanded(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: ListView(
padding: const EdgeInsets.all(24),
children: [
Center(
child: Stack(
children: [
DeskAvatar(label: s.displayName, fileId: '${u['avatarFileId'] ?? ''}', api: widget.api, size: 80),
if (_busy)
const Positioned.fill(child: CircularProgressIndicator(strokeWidth: 2, color: kDeskBind)),
],
),
),
const SizedBox(height: 12),
Center(
child: TextButton(onPressed: _busy ? null : _pickAvatar, child: const Text('更换头像')),
),
const SizedBox(height: 16),
_row('姓名', s.displayName, onTap: _editName),
_row('手机', '${u['mobile'] ?? '未填写'}'),
_row('邮箱', '${u['email'] ?? '未填写'}'),
_row('部门', '${u['department'] ?? ''}'),
_row('职位', '${u['title'] ?? ''}'),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton(onPressed: _editPassword, child: const Text('修改密码')),
),
],
),
),
),
),
],
),
);
}
Widget _row(String k, String v, {VoidCallback? onTap}) {
return Material(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
children: [
SizedBox(width: 64, child: Text(k, style: const TextStyle(color: kDeskMute, fontSize: 13))),
Expanded(child: Text(v, style: const TextStyle(fontSize: 14))),
if (onTap != null) const Icon(Icons.chevron_right, size: 18, color: kDeskMute),
],
),
),
),
);
}
}
@@ -0,0 +1,166 @@
import 'package:flutter/material.dart';
import '../../api/oa_client.dart';
import '../../app_config.dart';
import '../../ota/updater.dart';
import 'desk_legal_page.dart';
import 'desk_profile_edit_page.dart';
import '../../session/session.dart';
import '../desk_theme.dart';
import '../widgets/desk_widgets.dart';
class DeskProfilePage extends StatefulWidget {
const DeskProfilePage({super.key, required this.session, required this.api});
final SessionStore session;
final OaClient api;
@override
State<DeskProfilePage> createState() => _DeskProfilePageState();
}
class _DeskProfilePageState extends State<DeskProfilePage> {
AppRelease? _rel;
bool _checking = false;
@override
void initState() {
super.initState();
widget.session.addListener(() {
if (mounted) setState(() {});
});
_peek();
}
Future<void> _peek() async {
final rel = await OtaUpdater(widget.api).fetch();
if (mounted) setState(() => _rel = rel);
}
Future<void> _logout() async {
try {
await widget.api.post('/auth/logout', {'refreshToken': widget.session.refreshToken});
} catch (_) {}
await widget.session.clear();
}
Future<void> _checkUpdate() async {
setState(() => _checking = true);
try {
final rel = await OtaUpdater(widget.api).fetch();
if (!mounted) return;
setState(() => _rel = rel);
if (rel != null) await OtaUpdater(widget.api).prompt(context, rel, manual: true);
} finally {
if (mounted) setState(() => _checking = false);
}
}
@override
Widget build(BuildContext context) {
final s = widget.session;
return ColoredBox(
color: kDeskBg,
child: Column(
children: [
const DeskPaneHeader(title: '我'),
Expanded(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: ListView(
padding: const EdgeInsets.all(24),
children: [
Material(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => DeskProfileEditPage(session: s, api: widget.api),
)),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
DeskAvatar(
label: s.displayName,
fileId: '${s.user['avatarFileId'] ?? ''}',
api: widget.api,
size: 56,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(s.displayName, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Text('${s.user['department'] ?? ''} ${s.user['title'] ?? ''}'.trim(),
style: const TextStyle(color: kDeskMute, fontSize: 13)),
],
),
),
const Icon(Icons.chevron_right, color: kDeskMute),
],
),
),
),
),
const SizedBox(height: 12),
_section([
_row('检查更新', trailing: _rel?.newer == true ? '有新版本' : '已是最新', onTap: _checking ? null : _checkUpdate),
_row('用户协议', onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => DeskLegalPage(api: widget.api, kind: 'terms')))),
_row('隐私政策', onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => DeskLegalPage(api: widget.api, kind: 'privacy')))),
]),
const SizedBox(height: 12),
_section([
_row('版本', trailing: 'v${AppConfig.version}'),
]),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: OutlinedButton(
style: OutlinedButton.styleFrom(minimumSize: const Size.fromHeight(40)),
onPressed: _logout,
child: const Text('退出登录', style: TextStyle(color: Color(0xFFFA5151))),
),
),
],
),
),
),
),
],
),
);
}
Widget _section(List<Widget> children) {
return Material(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
clipBehavior: Clip.antiAlias,
child: Column(children: children),
);
}
Widget _row(String title, {String? trailing, VoidCallback? onTap}) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
Expanded(child: Text(title, style: const TextStyle(fontSize: 14))),
if (trailing != null) Text(trailing, style: const TextStyle(fontSize: 13, color: kDeskMute)),
if (onTap != null) const Icon(Icons.chevron_right, size: 18, color: kDeskMute),
],
),
),
),
);
}
}
@@ -0,0 +1,235 @@
import 'package:flutter/material.dart';
import '../../api/oa_client.dart';
import '../../nav/biz_route.dart';
import '../../pages/record_detail_page.dart' show flattenRecord;
import '../desk_theme.dart';
import '../widgets/desk_widgets.dart';
import 'desk_pick_people_page.dart';
void deskOpenRecord(BuildContext context, OaClient api, Map<String, dynamic> row, {String? title}) {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => DeskRecordDetailPage(api: api, seed: row, title: title ?? detailTitleOf(row)),
));
}
class DeskRecordDetailPage extends StatefulWidget {
const DeskRecordDetailPage({super.key, required this.api, required this.seed, required this.title});
final OaClient api;
final Map<String, dynamic> seed;
final String title;
@override
State<DeskRecordDetailPage> createState() => _DeskRecordDetailPageState();
}
class _DeskRecordDetailPageState extends State<DeskRecordDetailPage> {
Map<String, dynamic> _row = {};
bool _loading = true;
String _err = '';
@override
void initState() {
super.initState();
_row = Map<String, dynamic>.from(widget.seed);
_load();
}
Future<void> _load() async {
final path = fetchPathOf(widget.seed);
if (path == null) {
setState(() => _loading = false);
return;
}
try {
final data = await widget.api.get(path);
if (!mounted) return;
if (data is Map) {
setState(() {
_row = {...widget.seed, ...Map<String, dynamic>.from(data)};
_loading = false;
_err = '';
});
} else {
setState(() => _loading = false);
}
} catch (e) {
if (!mounted) return;
setState(() {
_loading = false;
_err = '$e';
});
}
}
Future<void> _forward() async {
final combined = <String, dynamic>{...widget.seed, ..._row};
final path = fetchPathOf(combined);
if (path == null || !path.startsWith('/')) {
deskToast(context, '当前信息暂不支持转发', error: true);
return;
}
final picked = await Navigator.of(context).push<List<Map<String, dynamic>>>(
MaterialPageRoute(
builder: (_) => DeskPickPeoplePage(api: widget.api, title: '转发给', multiple: true, exclude: {widget.api.session.userId}),
),
);
if (picked == null || picked.isEmpty || !mounted) return;
final visible = flattenRecord(combined).where((e) => e.$2.trim().isNotEmpty).take(2).map((e) => '${e.$1}:${e.$2}').join(' · ');
try {
for (final person in picked) {
await widget.api.post('/im/messages', {
'peerId': '${person['id'] ?? ''}',
'body': widget.title,
'contentType': 'business',
'meta': {
'title': widget.title,
'summary': visible,
'fetchPath': path,
'recordId': '${combined['id'] ?? combined['bizId'] ?? ''}',
'bizType': '${combined['bizType'] ?? combined['source'] ?? ''}',
},
});
}
if (mounted) deskToast(context, '已转发给 ${picked.length} 人');
} catch (e) {
if (mounted) deskToast(context, '$e', error: true);
}
}
Future<void> _act(String label, Future<void> Function() run) async {
try {
await run();
if (mounted) {
deskToast(context, label);
await _load();
}
} catch (e) {
if (mounted) deskToast(context, '$e', error: true);
}
}
Future<void> _decide(String path, String result) async {
final c = TextEditingController();
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(result == 'APPROVED' ? '通过' : '驳回'),
content: TextField(controller: c, decoration: const InputDecoration(hintText: '意见(可选)')),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确认')),
],
),
);
if (ok != true) return;
await _act('已提交', () async {
await widget.api.post(path, {'result': result, if (c.text.trim().isNotEmpty) 'comment': c.text.trim()});
});
}
List<Widget> get _actions {
final id = '${_row['id'] ?? widget.seed['id'] ?? ''}';
final bizId = '${_row['bizId'] ?? ''}';
final status = '${_row['status'] ?? ''}';
final source = '${_row['source'] ?? ''}';
final kind = '${_row['kind'] ?? ''}';
final biz = '${_row['bizType'] ?? ''}';
final actions = _row['myActions'] is Map ? Map<String, dynamic>.from(_row['myActions'] as Map) : <String, dynamic>{};
final out = <Widget>[];
if (status == 'DRAFT' && (source == 'EXPENSE' && kind != 'LOAN' || _row.containsKey('claimNo'))) {
out.add(FilledButton(onPressed: () => _act('已提交审批', () => widget.api.post('/expenses/$id/submit')), child: const Text('提交报销')));
}
if (status == 'DRAFT' && (kind == 'LOAN' || _row.containsKey('loanNo'))) {
out.add(FilledButton(onPressed: () => _act('已提交审批', () => widget.api.post('/loans/$id/submit')), child: const Text('提交借款')));
}
if (biz.isNotEmpty && status == 'PENDING' && id.isNotEmpty && widget.seed['assigneeId'] != null) {
out.add(FilledButton(onPressed: () => _decide('/office/approvals/$id/decide', 'APPROVED'), child: const Text('通过')));
out.add(OutlinedButton(onPressed: () => _decide('/office/approvals/$id/decide', 'REJECTED'), child: const Text('驳回')));
}
if (source == 'OFFICE' && status == 'PENDING') {
out.add(FilledButton(onPressed: () => _decide('/office/applies/$id/decide', 'APPROVED'), child: const Text('通过')));
out.add(OutlinedButton(onPressed: () => _decide('/office/applies/$id/decide', 'REJECTED'), child: const Text('驳回')));
}
if ((biz == 'HR_LEAVE' || source == 'HR') && status == 'PENDING' && (bizId.isNotEmpty || id.isNotEmpty)) {
final hid = bizId.isNotEmpty ? bizId : id;
out.add(FilledButton(onPressed: () => _decide('/leave-requests/$hid/review', 'APPROVED'), child: const Text('通过')));
out.add(OutlinedButton(onPressed: () => _decide('/leave-requests/$hid/review', 'REJECTED'), child: const Text('驳回')));
}
if (actions['canReview'] == true && id.isNotEmpty) {
out.add(FilledButton(onPressed: () => _decide('/bid-cases/$id/reviews', 'APPROVED'), child: const Text('审批通过')));
out.add(OutlinedButton(onPressed: () => _decide('/bid-cases/$id/reviews', 'REJECTED'), child: const Text('审批驳回')));
}
if ('${_row['status']}' == 'OPEN' && _row['assigneeId'] != null && !_row.containsKey('source')) {
out.add(FilledButton(
onPressed: () => _act('已办结', () => widget.api.patch('/office/todos/$id', {'status': 'DONE'})),
child: const Text('标为已办'),
));
}
return out;
}
@override
Widget build(BuildContext context) {
final pairs = flattenRecord(_row);
return ColoredBox(
color: kDeskBg,
child: Column(
children: [
DeskPaneHeader(
title: widget.title,
actions: [
IconButton(tooltip: '转发', onPressed: _loading ? null : _forward, icon: const Icon(Icons.forward_to_inbox_outlined, size: 20)),
IconButton(tooltip: '刷新', onPressed: _load, icon: const Icon(Icons.refresh, size: 20)),
],
),
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
Expanded(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: ListView(
padding: const EdgeInsets.all(20),
children: [
if (_err.isNotEmpty) Padding(padding: const EdgeInsets.only(bottom: 8), child: Text(_err, style: const TextStyle(color: kDeskMute, fontSize: 12))),
Material(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
for (var i = 0; i < pairs.length; i++)
_kvRow(pairs[i].$1, pairs[i].$2, i == pairs.length - 1),
],
),
),
if (_actions.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 16),
child: Wrap(spacing: 8, runSpacing: 8, children: _actions),
),
],
),
),
),
),
],
),
);
}
Widget _kvRow(String k, String v, bool last) {
return Container(
decoration: BoxDecoration(border: last ? null : const Border(bottom: BorderSide(color: kDeskLine))),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 100, child: Text(k, style: const TextStyle(fontSize: 13, color: kDeskMute))),
Expanded(child: Text(v, style: const TextStyle(fontSize: 14))),
],
),
);
}
}
@@ -0,0 +1,125 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import '../../api/oa_client.dart';
import '../../nav/notice_route.dart';
import '../../session/session.dart';
import '../desk_theme.dart';
import '../widgets/desk_widgets.dart';
class DeskSystemNoticePage extends StatefulWidget {
const DeskSystemNoticePage({super.key, required this.session, required this.api, required this.conversationId});
final SessionStore session;
final OaClient api;
final String conversationId;
@override
State<DeskSystemNoticePage> createState() => _DeskSystemNoticePageState();
}
class _DeskSystemNoticePageState extends State<DeskSystemNoticePage> {
List<Map<String, dynamic>> _items = [];
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final data = await widget.api.get('/im/messages', query: {'conversationId': widget.conversationId});
if (!mounted) return;
setState(() {
_items = data is Map ? asMaps(data['items'] ?? data) : asMaps(data);
_loading = false;
});
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
Map<String, dynamic> _meta(Map<String, dynamic> row) {
final raw = row['meta'];
if (raw is Map) return Map<String, dynamic>.from(raw);
if (raw is String && raw.isNotEmpty) {
try {
final d = jsonDecode(raw);
if (d is Map) return Map<String, dynamic>.from(d);
} catch (_) {}
}
return {};
}
void _open(Map<String, dynamic> row) {
final meta = _meta(row);
openNoticeTarget(
context,
widget.api,
session: widget.session,
kind: '${meta['kind'] ?? row['kind'] ?? ''}',
bizType: '${meta['bizType'] ?? ''}',
bizId: '${meta['bizId'] ?? ''}',
title: '${row['body'] ?? meta['title'] ?? '系统通知'}',
);
}
@override
Widget build(BuildContext context) {
return ColoredBox(
color: const Color(0xFFF5F5F5),
child: Column(
children: [
const DeskPaneHeader(title: '系统通知'),
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
Expanded(
child: _items.isEmpty
? const Center(child: Text('暂无通知', style: TextStyle(color: kDeskMute)))
: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _items.length,
itemBuilder: (_, i) {
final r = _items[i];
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Material(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: () => _open(r),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
const Icon(Icons.notifications_none, color: kDeskBind),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${r['body'] ?? '通知'}', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
const SizedBox(height: 4),
Text(shortTime(r['createdAt']), style: const TextStyle(fontSize: 12, color: kDeskMute)),
],
),
),
const Icon(Icons.chevron_right, color: kDeskMute, size: 18),
],
),
),
),
),
);
},
),
),
],
),
);
}
}
@@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import '../../api/oa_client.dart';
import '../../session/session.dart';
import '../desk_theme.dart';
import '../widgets/desk_data_table.dart';
import '../widgets/desk_widgets.dart';
import 'desk_record_detail_page.dart';
class DeskWorkAssignPage extends StatefulWidget {
const DeskWorkAssignPage({super.key, required this.session, required this.api});
final SessionStore session;
final OaClient api;
@override
State<DeskWorkAssignPage> createState() => _DeskWorkAssignPageState();
}
class _DeskWorkAssignPageState extends State<DeskWorkAssignPage> {
List<Map<String, dynamic>> _items = [];
bool _loading = true;
bool get _canAssign {
const codes = ['admin', 'owner', 'biz_director', 'tech_director', 'rd_director', '3d_director', 'video_director', 'material_director', 'pm'];
return widget.session.roles.any(codes.contains);
}
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() => _loading = true);
try {
final data = await widget.api.get('/office/work-assignments');
if (!mounted) return;
setState(() {
_items = asMaps(data);
_loading = false;
});
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _create() async {
final staff = asMaps(await widget.api.get('/staff'));
if (!mounted) return;
final title = TextEditingController();
final content = TextEditingController();
String? assignee;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setSt) => AlertDialog(
title: const Text('安排工作'),
content: SizedBox(
width: 440,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: title, decoration: const InputDecoration(labelText: '标题')),
const SizedBox(height: 8),
TextField(controller: content, decoration: const InputDecoration(labelText: '内容'), maxLines: 3),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
decoration: const InputDecoration(labelText: '执行人'),
items: [
for (final s in staff)
DropdownMenuItem(value: '${s['id']}', child: Text('${s['name'] ?? ''} · ${s['department'] ?? ''}')),
],
onChanged: (v) => setSt(() => assignee = v),
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('下达')),
],
),
),
);
if (ok != true) return;
try {
await widget.api.post('/office/work-assignments', {
'title': title.text.trim(),
'content': content.text.trim(),
'assigneeId': assignee,
});
if (mounted) {
deskToast(context, '已下达');
_load();
}
} catch (e) {
if (mounted) deskToast(context, '$e', error: true);
}
}
@override
Widget build(BuildContext context) {
return DeskListScaffold(
title: '工作安排',
loading: _loading,
actions: [
if (_canAssign)
FilledButton.icon(
style: FilledButton.styleFrom(backgroundColor: kDeskBind),
onPressed: _create,
icon: const Icon(Icons.add, size: 18),
label: const Text('安排工作'),
),
IconButton(onPressed: _load, icon: const Icon(Icons.refresh, size: 20)),
],
body: DeskDataTable(
columns: const ['标题', '执行人', '时间', '状态'],
rows: [
for (final r in _items)
[
'${r['title'] ?? ''}',
'${r['assignee'] is Map ? r['assignee']['displayName'] : ''}',
fmtTime(r['dueAt'] ?? r['createdAt']),
'${r['status'] ?? ''}',
],
],
emptyHint: '没有工作安排',
onRowTap: (i) => deskOpenRecord(context, widget.api, _items[i]),
),
);
}
}
@@ -0,0 +1,398 @@
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 '';
}