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:
@@ -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 '';
|
||||
}
|
||||
Reference in New Issue
Block a user