Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)

含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。
排除 node_modules、构建产物、安装包与 .env 密钥。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-02 10:04:03 +00:00
commit 76f266645d
507 changed files with 99891 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../labels.dart';
import '../pages/record_detail_page.dart';
import '../widgets/common.dart';
class ApprovalsPage extends StatefulWidget {
const ApprovalsPage({super.key, required this.api});
final OaClient api;
@override
State<ApprovalsPage> createState() => _ApprovalsPageState();
}
class _ApprovalsPageState extends State<ApprovalsPage> {
List<Map<String, dynamic>> _items = [];
String _bucket = 'PENDING';
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final data = await widget.api.get('/office/approvals');
if (!mounted) return;
setState(() {
_items = asMaps(data);
_loading = false;
});
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
final shown = _bucket.isEmpty ? _items : _items.where((e) => '${e['status']}' == _bucket).toList();
return Scaffold(
appBar: AppBar(title: const Text('待我审批')),
body: Column(
children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Row(
children: [
for (final it in [('PENDING', '待审批'), ('APPROVED', '已通过'), ('REJECTED', '已驳回'), ('', '全部')])
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(it.$2),
selected: _bucket == it.$1,
showCheckmark: false,
visualDensity: VisualDensity.compact,
onSelected: (_) => setState(() => _bucket = it.$1),
),
),
],
),
),
if (_loading) const LinearProgressIndicator(minHeight: 2),
Expanded(
child: shown.isEmpty
? const EmptyHint('没有审批任务')
: RefreshIndicator(
onRefresh: _load,
child: ListView.builder(
itemCount: shown.length,
itemBuilder: (_, i) {
final r = shown[i];
return KvTile(
title: '${r['title'] ?? ''}',
subtitle: '${zh(r['bizType'])} ${fmtTime(r['createdAt'])}',
status: r['status'],
onTap: () async {
await Navigator.of(context).push(MaterialPageRoute(
builder: (_) => RecordDetailPage(api: widget.api, seed: r, title: '${r['title'] ?? '审批'}'),
));
_load();
},
);
},
),
),
),
],
),
);
}
}
+169
View File
@@ -0,0 +1,169 @@
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../device/device_bridge.dart';
import '../theme.dart';
import '../widgets/wecom.dart';
class AttendancePage extends StatefulWidget {
const AttendancePage({super.key, required this.api, this.embedded = false});
final OaClient api;
final bool embedded;
@override
State<AttendancePage> createState() => _AttendancePageState();
}
class _AttendancePageState extends State<AttendancePage> {
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 {
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; });
} 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) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('打卡成功')));
await _load();
} catch (e) { if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e'))); }
finally { if (mounted) setState(() => _sending = false); }
}
@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;
final body = _loading
? const Center(child: CircularProgressIndicator())
: RefreshIndicator(
onRefresh: _load,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 28),
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: 14),
Card(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
child: Padding(
padding: const EdgeInsets.fromLTRB(18, 20, 18, 24),
child: Column(
children: [
Text(_today(), style: const TextStyle(fontSize: 25, fontWeight: FontWeight.w800)),
const SizedBox(height: 8),
_rangeBanner(canPunch, trip, fieldOk),
const SizedBox(height: 18),
_punchCircle(
enabled: canPunch && !hasIn && !_sending,
label: _mode == 'FIELD' ? '外勤打卡' : '上班打卡',
color: const Color(0xFF2575EA),
onTap: () => _punch('IN'),
),
const SizedBox(height: 18),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_timeBox('上班', inAt, _status['late'] == true),
_timeBox('下班', outAt, _status['early'] == true),
],
),
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: canPunch && hasIn && !hasOut && !_sending ? () => _punch('OUT') : null,
icon: const Icon(Icons.logout),
label: Text(hasOut ? '已打下班卡' : '打下班卡'),
),
),
if (_mode == 'OFFICE' && _status['late'] == true) const _Flag(text: '迟到', color: Colors.orange),
if (_mode == 'OFFICE' && _status['early'] == true) const _Flag(text: '早退', color: Colors.deepOrange),
],
),
),
),
const SizedBox(height: 12),
Card(
elevation: 0,
child: ListTile(
leading: Icon(Icons.place, color: _status['inRange'] == true ? Colors.green : Colors.red),
title: Text('${_status['rangeText'] ?? '正在获取考勤范围'}', style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text(_status['distanceMeters'] == null ? '请开启定位后刷新' : '距离考勤点约 ${_status['distanceMeters']}'),
trailing: IconButton(onPressed: _load, icon: const Icon(Icons.refresh)),
),
),
],
),
);
if (widget.embedded) {
return ColoredBox(
color: const Color(0xFFF4F6FA),
child: Column(
children: [
const WxHeader(title: '打卡'),
Expanded(child: body),
],
),
);
}
return Scaffold(
backgroundColor: const Color(0xFFF4F6FA),
appBar: AppBar(title: const Text('打卡'), backgroundColor: Colors.transparent),
body: body,
);
}
Widget _rangeBanner(bool canPunch, bool trip, bool fieldOk) {
final text = _mode == 'FIELD' ? (fieldOk ? '外出申请已通过,可进行外勤打卡' : '需先申请并审批通过外出') : (trip ? '出差期间无需打卡' : (canPunch ? '当前可进行上下班打卡' : '未进入考勤范围'));
final color = trip || fieldOk || canPunch ? Colors.green : Colors.orange;
return Container(width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: color.withValues(alpha: .1), borderRadius: BorderRadius.circular(12)), child: Row(children: [Icon(Icons.info_outline, color: color), const SizedBox(width: 8), Expanded(child: Text(text, style: TextStyle(color: color, fontWeight: FontWeight.w600)))]));
}
Widget _punchCircle({required bool enabled, required String label, required Color color, required VoidCallback onTap}) => GestureDetector(onTap: enabled ? onTap : null, child: AnimatedContainer(duration: const Duration(milliseconds: 180), width: 190, height: 190, decoration: BoxDecoration(shape: BoxShape.circle, border: Border.all(color: enabled ? color : Colors.grey.shade400, width: 10), color: Colors.white), child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Text(label, style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: enabled ? kInk : kMute)), const SizedBox(height: 6), Text(_nowTime(), style: TextStyle(fontSize: 24, fontWeight: FontWeight.w800, color: enabled ? color : kMute))])));
Widget _timeBox(String label, String value, bool flag) => Column(children: [Text(label, style: const TextStyle(color: kMute)), const SizedBox(height: 4), Row(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: Colors.orange, size: 16))])]);
String _today() { final d = DateTime.now(); return '${d.year}${d.month}${d.day}'; }
String _nowTime() { final d = DateTime.now(); return '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; }
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')}'; }
}
class _Flag extends StatelessWidget {
const _Flag({required this.text, required this.color});
final String text; final Color color;
@override Widget build(BuildContext context) => Padding(padding: const EdgeInsets.only(top: 8), child: Text(text, style: TextStyle(color: color, fontWeight: FontWeight.w700)));
}
+62
View File
@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../labels.dart';
import '../widgets/common.dart';
import 'record_detail_page.dart';
class CalendarPage extends StatefulWidget {
const CalendarPage({super.key, required this.api});
final OaClient api;
@override
State<CalendarPage> createState() => _CalendarPageState();
}
class _CalendarPageState extends State<CalendarPage> {
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('/office/calendar');
if (!mounted) return;
setState(() {
_items = asMaps(data);
_loading = false;
});
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('我的日程')),
body: _loading
? const Center(child: CircularProgressIndicator())
: _items.isEmpty
? const EmptyHint('近期没有日程')
: RefreshIndicator(
onRefresh: _load,
child: ListView(
children: [
for (final r in _items)
KvTile(
title: '${r['title'] ?? r['name'] ?? '日程'}',
subtitle: '${fmtTime(r['start'] ?? r['beginAt'] ?? r['createdAt'])} ${zh(r['type'] ?? r['kind'])}',
onTap: () => openRecord(context, widget.api, r, title: '${r['title'] ?? r['name'] ?? '日程'}'),
),
],
),
),
);
}
}
+436
View File
@@ -0,0 +1,436 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import '../api/oa_client.dart';
import '../device/device_bridge.dart';
import '../session/session.dart';
import '../theme.dart';
/// LiveKit 音视频房间页面。信令和媒体连接均由 LiveKit 负责,OA 只签发短时房间 Token。
class CallPage extends StatefulWidget {
const CallPage({
super.key,
required this.session,
required this.api,
required this.callId,
required this.kind,
required this.title,
required this.initiatorId,
required this.memberIds,
this.peerName = '同事',
this.incoming = false,
});
final SessionStore session;
final OaClient api;
final String callId;
final String kind;
final String title;
final String initiatorId;
final List<String> memberIds;
final String peerName;
final bool incoming;
@override
State<CallPage> createState() => _CallPageState();
}
class _CallPageState extends State<CallPage> {
Room? _room;
VideoTrack? _remoteVideo;
VideoTrack? _localVideo;
String _status = '正在连接音视频服务…';
bool _ready = false;
bool _muted = false;
bool _camOff = false;
bool _ending = false;
bool _remoteEnded = false;
bool _tone = false;
DateTime? _connectedAt;
Duration _elapsed = Duration.zero;
Timer? _durationTimer;
Timer? _ringTimeoutTimer;
Timer? _statePollTimer;
bool get _video => widget.kind != 'audio';
@override
void initState() {
super.initState();
widget.session.im.addListener(_onIm);
_statePollTimer = Timer.periodic(
const Duration(seconds: 2), (_) => unawaited(_pollCallState()));
_boot();
}
void _onIm() {
if (_ending || _remoteEnded || widget.session.im.inbox.isEmpty) return;
final matches = widget.session.im.inbox.where((item) =>
item.kind == 'call' && '${item.raw['callId'] ?? ''}' == widget.callId);
if (matches.isEmpty) return;
final last = matches.first;
final raw = last.raw;
if (last.kind != 'call' || '${raw['callId'] ?? ''}' != widget.callId)
return;
final action = '${raw['action'] ?? ''}';
if (action != 'end' && action != 'reject' && action != 'timeout') return;
_finishRemote(action == 'timeout'
? '无人接听'
: action == 'reject'
? '对方拒绝接听'
: '通话已结束');
}
Future<void> _pollCallState() async {
if (_ending || _remoteEnded) return;
try {
final raw = await widget.api.get('/im/calls/${widget.callId}');
if (raw is! Map || !mounted) return;
final data = Map<String, dynamic>.from(raw);
final status = '${data['status'] ?? ''}';
if (status == 'ended') {
_finishRemote(data['peerRejected'] == true ? '对方拒绝接听' : '通话已结束');
return;
}
if (data['peerJoined'] == true && _ready) {
_stopTone();
_startDuration();
if (mounted && _status != '通话中') setState(() => _status = '通话中');
}
} catch (_) {
// 短时断网由 LiveKit 重连处理,状态轮询下一轮继续。
}
}
void _finishRemote(String text) {
if (_remoteEnded || _ending) return;
_remoteEnded = true;
_ending = true;
_stopTone();
_stopDuration();
_ringTimeoutTimer?.cancel();
_statePollTimer?.cancel();
unawaited(_room?.disconnect());
if (!mounted) return;
setState(() => _status = text);
Future<void>.delayed(const Duration(milliseconds: 850), () {
if (mounted) Navigator.of(context).pop();
});
}
Future<void> _boot() async {
if (widget.incoming) {
if (mounted) setState(() => _status = '对方邀请你${_video ? '视频' : '语音'}通话');
_startTone();
_startRingTimeout();
return;
}
await _start();
}
Future<void> _start() async {
if (_ready) return;
if (widget.incoming) _stopTone();
if (mounted)
setState(() {
_ready = true;
_status = '正在接通…';
});
_startTone();
_startRingTimeout();
try {
await DeviceBridge.requestCallMedia();
await widget.api.post('/im/calls/${widget.callId}/join');
final auth =
await widget.api.post('/im/calls/${widget.callId}/livekit-token');
final data =
auth is Map ? Map<String, dynamic>.from(auth) : <String, dynamic>{};
final url = '${data['url'] ?? ''}';
final token = '${data['token'] ?? ''}';
if (url.isEmpty || token.isEmpty) throw Exception('音视频服务未配置');
final room = Room();
_room = room;
room.events
..on<ParticipantConnectedEvent>((_) {
_stopTone();
_startDuration();
if (mounted) setState(() => _status = '通话中');
})
..on<ParticipantDisconnectedEvent>((_) {
if (!_ending) _finishRemote('通话已结束');
})
..on<TrackSubscribedEvent>((event) {
_stopTone();
_startDuration();
if (event.track is VideoTrack && mounted)
setState(() => _remoteVideo = event.track as VideoTrack);
if (mounted) setState(() => _status = '通话中');
})
..on<TrackUnsubscribedEvent>((event) {
if (event.track is VideoTrack && mounted)
setState(() => _remoteVideo = null);
})
..on<RoomReconnectingEvent>((_) {
if (mounted) setState(() => _status = '网络恢复中…');
})
..on<RoomReconnectedEvent>((_) {
if (mounted) setState(() => _status = '通话中');
})
..on<RoomDisconnectedEvent>((_) {
if (!_ending) _finishRemote('通话已断开');
});
await room.connect(url, token);
// 接听方完成信令连接后即可停止响铃;拨打方继续响铃直到对方加入房间。
if (widget.incoming) _stopTone();
await room.localParticipant?.setMicrophoneEnabled(true);
if (_video) {
final pub = await room.localParticipant?.setCameraEnabled(true);
if (mounted && pub?.track is VideoTrack)
setState(() => _localVideo = pub!.track as VideoTrack);
}
if (room.remoteParticipants.isNotEmpty) {
_stopTone();
_startDuration();
}
if (mounted) {
setState(() => _status = room.remoteParticipants.isNotEmpty
? '通话中'
: widget.initiatorId == widget.session.userId
? '等待对方接听…'
: '正在建立通话…');
}
} catch (e) {
if (mounted) setState(() => _status = '$e');
}
}
Future<void> _hangup() async {
if (_ending) return;
_ending = true;
_stopTone();
_stopDuration();
_ringTimeoutTimer?.cancel();
_statePollTimer?.cancel();
try {
await widget.api.post(
'/im/calls/${widget.callId}/${(!_ready && widget.incoming) ? 'reject' : 'end'}');
} catch (_) {}
await _room?.disconnect();
if (mounted) Navigator.of(context).pop();
}
Future<void> _toggleMute() async {
final next = !_muted;
await _room?.localParticipant?.setMicrophoneEnabled(!next);
if (mounted) setState(() => _muted = next);
}
Future<void> _toggleCam() async {
final next = !_camOff;
final pub = await _room?.localParticipant?.setCameraEnabled(!next);
if (mounted)
setState(() {
_camOff = next;
if (!next && pub?.track is VideoTrack)
_localVideo = pub!.track as VideoTrack;
});
}
@override
void dispose() {
widget.session.im.removeListener(_onIm);
_stopTone();
_stopDuration();
_ringTimeoutTimer?.cancel();
_statePollTimer?.cancel();
_room?.disconnect();
super.dispose();
}
void _startTone() {
if (_tone) return;
_tone = true;
DeviceBridge.startCallTone(incoming: widget.incoming);
}
void _stopTone() {
if (!_tone) return;
_tone = false;
DeviceBridge.stopCallTone();
}
void _startDuration() {
if (_connectedAt != null) return;
_ringTimeoutTimer?.cancel();
_ringTimeoutTimer = null;
_connectedAt = DateTime.now();
_durationTimer?.cancel();
_durationTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted || _connectedAt == null) return;
setState(() => _elapsed = DateTime.now().difference(_connectedAt!));
});
if (mounted) setState(() => _elapsed = Duration.zero);
}
void _stopDuration() {
_durationTimer?.cancel();
_durationTimer = null;
}
void _startRingTimeout() {
_ringTimeoutTimer?.cancel();
_ringTimeoutTimer = Timer(const Duration(seconds: 45), () {
if (!mounted || _ending || _connectedAt != null) return;
_ending = true;
_remoteEnded = true;
_stopTone();
unawaited(widget.api
.post(
'/im/calls/${widget.callId}/${widget.incoming ? 'reject' : 'end'}')
.catchError((_) {}));
setState(() => _status = '无人接听');
Future<void>.delayed(const Duration(milliseconds: 650), () {
if (mounted) Navigator.of(context).pop();
});
});
}
String get _durationText {
final h = _elapsed.inHours;
final m = _elapsed.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = _elapsed.inSeconds.remainder(60).toString().padLeft(2, '0');
return h > 0 ? '${h.toString().padLeft(2, '0')}:$m:$s' : '$m:$s';
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _hangup();
},
child: Scaffold(
backgroundColor: const Color(0xFF1C1C1E),
body: SafeArea(
child: Stack(children: [
if (_video && _remoteVideo != null)
Positioned.fill(
child: VideoTrackRenderer(_remoteVideo!,
fit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitCover))
else
Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [
CircleAvatar(
radius: 48,
backgroundColor: kBind,
child: Text(
widget.peerName.trim().isEmpty
? ''
: String.fromCharCodes(widget.peerName.runes.take(1)),
style:
const TextStyle(color: Colors.white, fontSize: 32))),
const SizedBox(height: 16),
Text(widget.peerName,
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
Text(_status,
style:
const TextStyle(color: Color(0xFFBBBBBB), fontSize: 14)),
if (_connectedAt != null) ...[
const SizedBox(height: 6),
Text(_durationText,
style: const TextStyle(
color: Color(0xFFBBBBBB), fontSize: 14)),
],
])),
if (_video && _localVideo != null)
Positioned(
right: 16,
top: 16,
width: 110,
height: 160,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: VideoTrackRenderer(_localVideo!,
mirrorMode: VideoViewMirrorMode.mirror))),
if (_video && _remoteVideo != null)
Positioned(
left: 0,
right: 0,
top: 24,
child: Text(_status,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70))),
Positioned(
left: 0,
right: 0,
bottom: 36,
child:
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
if (!_ready) ...[
_round(
Icons.call_end, const Color(0xFFE64340), _hangup, '拒绝'),
const SizedBox(width: 48),
_round(Icons.call, const Color(0xFF07C160), _start, '接听'),
] else ...[
_round(
_muted ? Icons.mic_off : Icons.mic,
const Color(0xFF3A3A3C),
_toggleMute,
_muted ? '开麦' : '静音'),
if (_video) ...[
const SizedBox(width: 28),
_round(_camOff ? Icons.videocam_off : Icons.videocam,
const Color(0xFF3A3A3C), _toggleCam, '摄像头')
],
const SizedBox(width: 28),
_round(
Icons.call_end, const Color(0xFFE64340), _hangup, '挂断'),
],
])),
])),
),
);
}
Widget _round(IconData icon, Color color, VoidCallback onTap, String label) =>
Column(mainAxisSize: MainAxisSize.min, children: [
InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: CircleAvatar(
radius: 28,
backgroundColor: color,
child: Icon(icon, color: Colors.white, size: 26))),
const SizedBox(height: 8),
Text(label,
style: const TextStyle(color: Colors.white70, fontSize: 12)),
]);
}
Future<void> openCallPage(BuildContext context,
{required SessionStore session,
required OaClient api,
required Map<String, dynamic> call,
String peerName = '同事',
bool incoming = false}) async {
final id = '${call['id'] ?? ''}';
if (id.isEmpty) return;
final members =
((call['memberIds'] as List?) ?? []).map((e) => '$e').toList();
await Navigator.of(context).push(MaterialPageRoute(
builder: (_) => CallPage(
session: session,
api: api,
callId: id,
kind: '${call['kind'] ?? 'audio'}',
title: '${call['title'] ?? '通话'}',
initiatorId: '${call['initiatorId'] ?? ''}',
memberIds: members,
peerName: peerName,
incoming: incoming)));
}
+344
View File
@@ -0,0 +1,344 @@
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../im/chat_prefs.dart';
import '../session/session.dart';
import '../theme.dart';
import '../widgets/wecom.dart';
import 'pick_people_page.dart';
class ChatDetailPage extends StatefulWidget {
const ChatDetailPage({
super.key,
required this.session,
required this.api,
required this.conversationId,
required this.peerId,
required this.peerName,
required this.isGroup,
this.peerAvatarFileId,
this.messages = const [],
this.onCall,
});
final SessionStore session;
final OaClient api;
final String conversationId;
final String peerId;
final String peerName;
final bool isGroup;
final String? peerAvatarFileId;
final List<Map<String, dynamic>> messages;
final void Function(String kind)? onCall;
@override
State<ChatDetailPage> createState() => _ChatDetailPageState();
}
class _ChatDetailPageState extends State<ChatDetailPage> {
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: (_) => PickPeoplePage(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) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已创建群聊')));
Navigator.pop(context);
}
}
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e')));
}
}
Future<void> _search() async {
await Navigator.of(context).push(MaterialPageRoute(
builder: (_) => _ChatSearchPage(messages: widget.messages, peerName: widget.peerName),
));
}
Future<void> _pickBg() async {
const colors = [
0xFFF5F5F5,
0xFFE7F0E4,
0xFFE8EEF6,
0xFFF6EFE6,
0xFFF3E8EE,
];
final picked = await showModalBottomSheet<int>(
context: context,
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Wrap(
spacing: 12,
runSpacing: 12,
children: [
for (final c in colors)
GestureDetector(
onTap: () => Navigator.pop(ctx, c),
child: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Color(c),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFDDDDDD)),
),
),
),
],
),
),
),
);
if (picked == null) return;
await ChatPrefs.setBg(widget.conversationId, picked);
if (mounted) setState(() {});
}
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) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已删除本机聊天记录')));
Navigator.pop(context, 'cleared');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: kPaper,
appBar: AppBar(title: const Text('聊天详情')),
body: ListView(
children: [
const SizedBox(height: 12),
Container(
color: Colors.white,
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Wrap(
spacing: 16,
runSpacing: 12,
children: [
for (final m in _members)
SizedBox(
width: 56,
child: Column(
children: [
SquareAvatar(
label: '${m['name'] ?? m['displayName'] ?? ''}',
size: 48,
fileId: '${m['avatarFileId'] ?? ''}',
api: widget.api,
),
const SizedBox(height: 4),
Text(
'${m['name'] ?? m['displayName'] ?? ''}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, color: kInk),
),
],
),
),
SizedBox(
width: 56,
child: Column(
children: [
InkWell(
onTap: _addMembers,
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
border: Border.all(color: const Color(0xFFC8C8C8), style: BorderStyle.solid),
),
child: const Icon(Icons.add, color: kMute),
),
),
const SizedBox(height: 4),
const Text(' ', style: TextStyle(fontSize: 12)),
],
),
),
],
),
),
const SizedBox(height: 12),
CellGroup(
children: [
Cell(title: '查找聊天内容', onTap: _search, showLine: false),
],
),
CellGroup(
children: [
Cell(
title: '消息免打扰',
trailing: Switch(
value: _mute,
activeTrackColor: kWeGreen,
onChanged: (v) async {
await ChatPrefs.setMuted(widget.conversationId, v);
setState(() => _mute = v);
},
),
),
Cell(
title: '置顶聊天',
trailing: Switch(
value: _pin,
activeTrackColor: kWeGreen,
onChanged: (v) async {
await ChatPrefs.setPinned(widget.conversationId, v);
setState(() => _pin = v);
},
),
showLine: false,
),
],
),
CellGroup(
children: [
Cell(title: '设置当前聊天背景', onTap: _pickBg),
Cell(title: '语音通话', onTap: () => widget.onCall?.call('audio')),
Cell(
title: '视频通话',
onTap: () => widget.onCall?.call(widget.isGroup ? 'meeting' : 'video'),
showLine: false,
),
],
),
CellGroup(
children: [
Cell(title: '删除聊天记录', onTap: _clear, showLine: false),
],
),
CellGroup(
children: [
Cell(
title: '投诉',
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已记录投诉,我们会尽快处理')));
},
showLine: false,
),
],
),
const SizedBox(height: 24),
],
),
);
}
}
class _ChatSearchPage extends StatefulWidget {
const _ChatSearchPage({required this.messages, required this.peerName});
final List<Map<String, dynamic>> messages;
final String peerName;
@override
State<_ChatSearchPage> createState() => _ChatSearchPageState();
}
class _ChatSearchPageState extends State<_ChatSearchPage> {
String _q = '';
@override
Widget build(BuildContext context) {
final q = _q.trim();
final hits = q.isEmpty
? const <Map<String, dynamic>>[]
: widget.messages.where((e) => '${e['body'] ?? ''}'.contains(q)).toList();
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(title: const Text('查找聊天内容')),
body: Column(
children: [
WxSearchBar(hint: '搜索', onChanged: (v) => setState(() => _q = v)),
Expanded(
child: q.isEmpty
? const Center(child: Text('输入关键词查找聊天内容', style: TextStyle(color: kMute)))
: hits.isEmpty
? const Center(child: Text('没有找到相关内容', style: TextStyle(color: kMute)))
: ListView(
children: [
for (final r in hits)
Cell(
title: '${r['body']}',
subtitle: '${r['fromName'] ?? widget.peerName} ${r['createdAt'] ?? ''}',
showLine: true,
),
],
),
),
],
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,218 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:qr_flutter/qr_flutter.dart';
import '../api/oa_client.dart';
import '../session/session.dart';
import '../desktop/desk_theme.dart';
import '../desktop/widgets/desk_widgets.dart';
import '../widgets/beian_footer.dart';
class DesktopLoginPage extends StatefulWidget {
const DesktopLoginPage({super.key, required this.session, required this.api});
final SessionStore session;
final OaClient api;
@override
State<DesktopLoginPage> createState() => _DesktopLoginPageState();
}
class _DesktopLoginPageState extends State<DesktopLoginPage> {
final _codeCtrl = TextEditingController();
String _ticket = '';
String _payload = '';
String _hint = '打开手机端「消息」右上角 + → 扫一扫,扫描此二维码';
String _error = '';
bool _submitting = false;
bool _starting = true;
Timer? _poll;
@override
void initState() {
super.initState();
unawaited(_start());
}
@override
void dispose() {
_poll?.cancel();
_codeCtrl.dispose();
super.dispose();
}
Future<void> _start() async {
setState(() {
_starting = true;
_error = '';
_codeCtrl.clear();
_hint = '打开手机端「消息」右上角 + → 扫一扫,扫描此二维码';
});
_poll?.cancel();
try {
final raw = await widget.api.post('/auth/qr/start', {});
final data = raw is Map ? Map<String, dynamic>.from(raw) : <String, dynamic>{};
final ticket = '${data['ticket'] ?? ''}'.trim();
if (ticket.isEmpty) throw Exception('无法生成登录码');
if (!mounted) return;
setState(() {
_ticket = ticket;
_payload = '${data['payload'] ?? 'fengyingoa://qr?ticket=$ticket'}';
_starting = false;
});
_poll = Timer.periodic(const Duration(milliseconds: 1600), (_) => _pollStatus());
} catch (e) {
if (!mounted) return;
setState(() {
_starting = false;
_error = '$e';
});
}
}
Future<void> _pollStatus() async {
if (_ticket.isEmpty || _submitting) return;
try {
final raw = await widget.api.get('/auth/qr/status', query: {'ticket': _ticket});
final data = raw is Map ? Map<String, dynamic>.from(raw) : <String, dynamic>{};
final status = '${data['status'] ?? ''}';
if (!mounted) return;
if (status == 'code_required') {
setState(() => _hint = '手机已确认,请输入手机屏幕显示的六位登录码');
return;
}
if (status == 'expired' || status == 'consumed') {
_poll?.cancel();
setState(() => _hint = '登录码已过期,正在刷新…');
await _start();
}
} catch (e) {
if (!mounted) return;
setState(() => _error = '$e');
}
}
Future<void> _complete() async {
final code = _codeCtrl.text.trim();
if (!RegExp(r'^\d{6}$').hasMatch(code)) {
setState(() => _error = '请输入手机端显示的六位登录码');
return;
}
setState(() {
_submitting = true;
_error = '';
});
try {
final raw = await widget.api.post('/auth/qr/complete', {'ticket': _ticket, 'code': code});
final data = raw is Map ? Map<String, dynamic>.from(raw) : <String, dynamic>{};
if ('${data['accessToken'] ?? ''}'.isEmpty) throw Exception('电脑登录未完成');
await widget.session.applyLogin(data);
try {
final menus = await widget.api.get('/system/menus');
await widget.session.setMenus(asMaps(menus).map(MenuNode.fromJson).toList());
} catch (_) {}
} catch (e) {
if (mounted) setState(() => _error = '$e');
} finally {
if (mounted) setState(() => _submitting = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: kDeskBg,
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Material(
color: kDeskPane,
borderRadius: BorderRadius.circular(12),
elevation: 0,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: kDeskLine),
),
padding: const EdgeInsets.fromLTRB(36, 40, 36, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
DeskAvatar(label: '', api: widget.api, size: 64, color: kDeskBind, icon: Icons.apartment),
const SizedBox(height: 14),
const Text('风影办公', style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: kDeskInk)),
const SizedBox(height: 6),
const Text(
'请使用手机 App 扫码,并输入六位登录码',
textAlign: TextAlign.center,
style: TextStyle(color: kDeskMute, fontSize: 13, height: 1.5),
),
const SizedBox(height: 20),
if (_error.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(_error, textAlign: TextAlign.center, style: const TextStyle(color: Color(0xFFFA5151))),
),
if (_starting)
const Padding(
padding: EdgeInsets.symmetric(vertical: 48),
child: CircularProgressIndicator(color: kDeskBind),
)
else if (_payload.isNotEmpty) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: kDeskLine),
),
child: QrImageView(data: _payload, size: 200, backgroundColor: Colors.white),
),
const SizedBox(height: 10),
Text(_hint, textAlign: TextAlign.center, style: const TextStyle(color: kDeskMute, fontSize: 12)),
const SizedBox(height: 18),
TextField(
controller: _codeCtrl,
onChanged: (v) {
final next = v.replaceAll(RegExp(r'\D'), '');
final clipped = next.length > 6 ? next.substring(0, 6) : next;
if (clipped != v) {
_codeCtrl.value = TextEditingValue(
text: clipped,
selection: TextSelection.collapsed(offset: clipped.length),
);
}
},
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
maxLength: 6,
inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6)],
decoration: const InputDecoration(hintText: '六位登录码', counterText: ''),
style: const TextStyle(fontSize: 26, fontWeight: FontWeight.w700, letterSpacing: 8),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: FilledButton(
style: FilledButton.styleFrom(backgroundColor: kDeskBind, minimumSize: const Size.fromHeight(40)),
onPressed: _submitting || _ticket.isEmpty ? null : _complete,
child: Text(_submitting ? '登录中…' : '确认登录'),
),
),
const SizedBox(width: 8),
OutlinedButton(onPressed: _starting ? null : _start, child: const Text('刷新')),
],
),
],
],
),
),
),
),
),
bottomNavigationBar: const BeianFooter(),
);
}
}
@@ -0,0 +1,295 @@
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../nav/open_module.dart';
import '../pages/approvals_page.dart';
import '../pages/attendance_page.dart';
import '../pages/calendar_page.dart';
import '../pages/flow_page.dart';
import '../pages/hr_apply_page.dart';
import '../pages/module_list_page.dart';
import '../pages/todos_page.dart';
import '../pages/work_assign_page.dart';
import '../session/session.dart';
import '../theme.dart';
import '../widgets/desktop_ui.dart';
import '../widgets/wecom.dart';
typedef DesktopOpenTab = void Function(String key, String title, IconData icon, Color color, Widget page);
class DesktopWorkbenchPage extends StatefulWidget {
const DesktopWorkbenchPage({
super.key,
required this.session,
required this.api,
required this.onOpenTab,
});
final SessionStore session;
final OaClient api;
final DesktopOpenTab onOpenTab;
@override
State<DesktopWorkbenchPage> createState() => _DesktopWorkbenchPageState();
}
class _DesktopWorkbenchPageState extends State<DesktopWorkbenchPage> {
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<_DeskApp> _personalApps() {
return [
if (_match('待办'))
_DeskApp('todos', '待办', '处理待办事项与提醒', Icons.task_alt, const Color(0xFFFA9D3B), TodosPage(api: widget.api)),
if (_match('审批'))
_DeskApp('approvals', '待审批', '审批各类办公申请', Icons.fact_check, kBind, ApprovalsPage(api: widget.api)),
if (_match('申请'))
_DeskApp('flow', '我的申请', '查看我发起的申请', Icons.assignment_outlined, const Color(0xFF6267F2), FlowPage(api: widget.api)),
if (_match('日程'))
_DeskApp('calendar', '日程', '会议与日程安排', Icons.calendar_month, const Color(0xFF10AEFF), CalendarPage(api: widget.api)),
if (_match('安排'))
_DeskApp('work', '工作安排', '任务分配与跟进', Icons.event_note, const Color(0xFF00B578), WorkAssignPage(session: widget.session, api: widget.api)),
if (_match('打卡') || _match('考勤'))
_DeskApp('attendance', '考勤打卡', '上下班打卡记录', Icons.access_time_filled, const Color(0xFF267EF0), AttendancePage(api: widget.api)),
if (_match('人事'))
_DeskApp('hr', '人事申请', '请假、加班、外出等', Icons.beach_access, const Color(0xFF8B5CF6), HrApplyPage(api: widget.api)),
];
}
List<_DeskApp> _menuApps() {
final out = <_DeskApp>[];
for (final m in widget.session.menus) {
if (m.name == '工作台' || m.name == '个人办公' || m.code == 'office:overview') 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 (_skipChild(c)) continue;
if (!(_q.isEmpty || c.name.contains(_q) || m.name.contains(_q))) continue;
out.add(_DeskApp(
c.code,
c.name,
m.name,
iconFor(c.code, c.name),
colorFor(c.code, c.name, i),
_pageFor(c),
));
}
} else if (!_skipChild(m) && _match(m.name)) {
out.add(_DeskApp(m.code, m.name, '业务应用', iconFor(m.code, m.name), colorFor(m.code, m.name), _pageFor(m)));
}
}
return out;
}
bool _skipChild(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);
}
Widget _pageFor(MenuNode n) {
final shell = shellFor(n.code, n.path);
if (shell != null) {
return ModuleListPage(api: widget.api, title: n.name, shell: shell);
}
return Center(child: Text('${n.name} 暂未配置桌面入口', style: const TextStyle(color: kMute)));
}
List<_DeskApp> get _shownApps {
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 >= 1200 ? 3 : 2;
return ColoredBox(
color: kDeskBg,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DesktopPaneHeader(
title: '工作台',
actions: [
SizedBox(width: 220, child: DesktopSearchBar(hint: '搜索应用', onChanged: (v) => setState(() => _q = v.trim()))),
const SizedBox(width: 8),
OutlinedButton(onPressed: _load, child: const Text('刷新')),
],
bottom: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 10),
child: Row(
children: [
_catChip('all', '全部应用'),
const SizedBox(width: 8),
_catChip('personal', '个人办公'),
const SizedBox(width: 8),
_catChip('biz', '业务模块'),
],
),
),
),
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kBind),
Expanded(
child: RefreshIndicator(
onRefresh: _load,
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
children: [
Container(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 18),
decoration: BoxDecoration(
gradient: const LinearGradient(colors: [Color(0xFFEEF4FF), Color(0xFFF8FBFF)]),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFDCE8FF)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_greet.isEmpty ? '你好,${widget.session.displayName}' : _greet,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: kInk),
),
const SizedBox(height: 6),
const Text('工作台 · 个人办公与业务入口', style: TextStyle(color: kMute, fontSize: 13)),
],
),
),
const SizedBox(height: 16),
Row(
children: [
_statCard('待办', '${_ov['pendingTodos'] ?? 0}', () => _open('todos', '待办', Icons.task_alt, const Color(0xFFFA9D3B), TodosPage(api: widget.api))),
const SizedBox(width: 12),
_statCard('待审批', '${_ov['pendingApprovals'] ?? 0}', () => _open('approvals', '待审批', Icons.fact_check, kBind, ApprovalsPage(api: widget.api))),
const SizedBox(width: 12),
_statCard('我的申请', '${_ov['myPending'] ?? 0}', () => _open('flow', '我的申请', Icons.assignment_outlined, const Color(0xFF6267F2), FlowPage(api: widget.api))),
const SizedBox(width: 12),
_statCard('逾期', '${_ov['overdueTodos'] ?? 0}', () => _open('todos', '待办', Icons.task_alt, const Color(0xFFFA9D3B), TodosPage(api: widget.api))),
],
),
const SizedBox(height: 20),
if (_shownApps.isEmpty)
const Padding(
padding: EdgeInsets.only(top: 48),
child: Center(child: Text('没有匹配的应用', style: TextStyle(color: kMute))),
)
else
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: cols,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 2.8,
),
itemCount: _shownApps.length,
itemBuilder: (_, i) {
final a = _shownApps[i];
return DesktopAppCard(
label: a.title,
desc: a.desc,
icon: a.icon,
color: a.color,
onTap: () => _open(a.key, a.title, a.icon, a.color, a.page),
);
},
),
],
),
),
),
],
),
);
}
Widget _catChip(String id, String label) {
final on = _cat == id;
return GestureDetector(
onTap: () => setState(() => _cat = id),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: on ? kDeskActive : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: on ? const Color(0xFFBFD7FF) : kLine),
),
child: Text(label, style: TextStyle(fontSize: 13, color: on ? kBind : kMute, fontWeight: on ? FontWeight.w600 : FontWeight.w400)),
),
);
}
Widget _statCard(String label, String value, VoidCallback onTap) {
return Expanded(
child: Material(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Column(
children: [
Text(value, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: kBind)),
const SizedBox(height: 4),
Text(label, style: const TextStyle(fontSize: 12, color: kMute)),
],
),
),
),
),
);
}
}
class _DeskApp {
_DeskApp(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;
}
+405
View File
@@ -0,0 +1,405 @@
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../im/pinyin.dart';
import '../session/session.dart';
import '../theme.dart';
import '../widgets/common.dart';
import '../widgets/desktop_ui.dart';
import '../widgets/wecom.dart';
import 'chat_page.dart';
import 'org_browse_page.dart';
class DirectoryPage extends StatefulWidget {
const DirectoryPage({super.key, required this.session, required this.api, this.embedded = false, this.desktopStyle = false});
final SessionStore session;
final OaClient api;
final bool embedded;
final bool desktopStyle;
@override
State<DirectoryPage> createState() => _DirectoryPageState();
}
class _DirectoryPageState extends State<DirectoryPage> {
List<Map<String, dynamic>> _items = [];
Map<String, List<String>> _presence = {};
String _q = '';
bool _loading = true;
@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);
}
}
void _openPerson(Map<String, dynamic> r) {
showModalBottomSheet<void>(
context: context,
backgroundColor: kPaper,
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SquareAvatar(
label: '${r['name'] ?? ''}',
size: 64,
fileId: '${r['avatarFileId'] ?? ''}',
api: widget.api,
),
const SizedBox(height: 12),
Text('${r['name'] ?? ''}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Text(
[r['department'], r['title']].where((e) => e != null && '$e'.isNotEmpty).join(' · '),
style: const TextStyle(color: kMute),
),
const SizedBox(height: 16),
CellGroup(
children: [
Cell(title: '手机', subtitle: '${r['mobile'] ?? '未填'}', showLine: true),
Cell(title: '邮箱', subtitle: '${r['email'] ?? '未填'}', showLine: false),
],
),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () async {
Navigator.pop(ctx);
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 (_) {}
await Navigator.of(context).push(MaterialPageRoute(
builder: (_) => ChatPage(
session: widget.session,
api: widget.api,
peerId: '${r['id']}',
conversationId: conversationId,
peerName: '${r['name'] ?? '同事'}',
peerAvatarFileId: '${r['avatarFileId'] ?? ''}',
),
));
widget.session.im.bump();
},
child: const Padding(padding: EdgeInsets.symmetric(vertical: 10), child: Text('发消息')),
),
),
],
),
),
),
);
}
Widget _desktopBody(
List<Map<String, dynamic>> shown,
Map<String, List<Map<String, dynamic>>> groups,
List<String> letters,
Set<String> depts,
) {
if (_loading) return const LinearProgressIndicator(minHeight: 2, color: kBind);
if (shown.isEmpty) return const Center(child: EmptyHint('没有匹配的同事'));
return RefreshIndicator(
onRefresh: _load,
child: Stack(
children: [
ListView(
padding: const EdgeInsets.fromLTRB(20, 12, 28, 24),
children: [
if (_q.isEmpty)
Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10)),
child: Column(
children: [
Cell(
title: '组织架构',
subtitle: '${depts.length} 个部门',
leading: const SquareAvatar(label: '', color: kBind, icon: Icons.account_tree, size: 36),
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => OrgBrowsePage(session: widget.session, api: widget.api, staff: _items),
)),
),
Cell(
title: '企业通讯录',
subtitle: '${_items.length}',
leading: const SquareAvatar(label: '', color: Color(0xFF07C160), icon: Icons.contacts, size: 36),
showLine: false,
),
],
),
),
for (final letter in letters) ...[
Padding(
padding: const EdgeInsets.fromLTRB(4, 8, 4, 6),
child: Text(letter, style: const TextStyle(fontSize: 13, color: kMute, fontWeight: FontWeight.w600)),
),
Container(
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10)),
child: Column(
children: [
for (final r in groups[letter]!)
InkWell(
onTap: () => _openPerson(r),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 0),
child: Row(
children: [
SquareAvatar(label: '${r['name'] ?? ''}', fileId: '${r['avatarFileId'] ?? ''}', api: widget.api),
const SizedBox(width: 12),
Expanded(
child: Container(
padding: const EdgeInsets.only(bottom: 12),
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: kLine, width: 0.5))),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text('${r['name'] ?? ''}', style: const TextStyle(fontSize: 16, color: kInk)),
const SizedBox(width: 6),
Container(width: 6, height: 6, decoration: BoxDecoration(shape: BoxShape.circle, color: (_presence['${r['id']}'] ?? const []).isNotEmpty ? const Color(0xFF22C55E) : const Color(0xFFB8BEC8))),
const SizedBox(width: 3),
Text((_presence['${r['id']}'] ?? const []).isNotEmpty ? '在线' : '离线', style: const TextStyle(fontSize: 11, color: kMute)),
],
),
Text(
[r['department'], r['title']].where((e) => e != null && '$e'.isNotEmpty).join(' · '),
style: const TextStyle(fontSize: 12, color: kMute),
),
],
),
),
),
],
),
),
),
],
),
),
],
],
),
Align(
alignment: Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.only(right: 8),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [for (final l in letters) Text(l, style: const TextStyle(fontSize: 10, color: kBind, height: 1.35))],
),
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final me = widget.session.userId;
final shown = _items.where((e) {
if ('${e['id']}' == me) return false;
if (_q.isEmpty) return true;
return '${e['name']}${e['department']}${e['mobile']}${e['email']}${e['title']}'.contains(_q);
}).toList()
..sort((a, b) {
final la = letterOf('${a['name']}');
final lb = letterOf('${b['name']}');
final c = la.compareTo(lb);
if (c != 0) return c;
return '${a['name']}'.compareTo('${b['name']}');
});
final groups = <String, List<Map<String, dynamic>>>{};
for (final r in shown) {
groups.putIfAbsent(letterOf('${r['name']}'), () => []).add(r);
}
final letters = groups.keys.toList()
..sort((a, b) {
if (a == '#') return 1;
if (b == '#') return -1;
return a.compareTo(b);
});
final depts = <String>{};
for (final r in _items) {
final d = '${r['department'] ?? ''}';
if (d.isNotEmpty) depts.add(d);
}
final body = widget.desktopStyle
? _desktopBody(shown, groups, letters, depts)
: Column(
children: [
WxSearchBar(hint: '搜索同事、部门、手机', onChanged: (v) => setState(() => _q = v.trim())),
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kBind),
Expanded(
child: shown.isEmpty
? const EmptyHint('没有匹配的同事')
: RefreshIndicator(
onRefresh: _load,
child: Stack(
children: [
ListView(
children: [
if (_q.isEmpty) ...[
CellGroup(
children: [
Cell(
title: '组织架构',
subtitle: '${depts.length} 个部门',
leading: const SquareAvatar(label: '', color: kBind, icon: Icons.account_tree, size: 36),
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => OrgBrowsePage(
session: widget.session,
api: widget.api,
staff: _items,
),
)),
),
Cell(
title: '企业通讯录',
subtitle: '${_items.length}',
leading: const SquareAvatar(label: '', color: Color(0xFF07C160), icon: Icons.contacts, size: 36),
showLine: false,
),
],
),
],
for (final letter in letters) ...[
Container(
width: double.infinity,
color: kPaper,
padding: const EdgeInsets.fromLTRB(16, 6, 16, 6),
child: Text(letter, style: const TextStyle(fontSize: 13, color: kMute, fontWeight: FontWeight.w600)),
),
for (final r in groups[letter]!)
InkWell(
onTap: () => _openPerson(r),
child: Container(
color: Colors.white,
padding: const EdgeInsets.fromLTRB(12, 10, 28, 0),
child: Row(
children: [
Stack(
clipBehavior: Clip.none,
children: [
SquareAvatar(label: '${r['name'] ?? ''}', fileId: '${r['avatarFileId'] ?? ''}', api: widget.api),
if ((_presence['${r['id']}'] ?? const []).isNotEmpty)
Positioned(
right: -3,
bottom: -2,
child: DecoratedBox(
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)),
child: Icon(
(_presence['${r['id']}'] ?? const []).contains('mobile') ? Icons.phone_android : Icons.computer,
size: 14,
color: const Color(0xFF10AEFF),
),
),
),
],
),
const SizedBox(width: 10),
Expanded(
child: Container(
padding: const EdgeInsets.only(bottom: 10),
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: kLine, width: 0.5))),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text('${r['name'] ?? ''}', style: const TextStyle(fontSize: 16, color: kInk)),
const SizedBox(width: 6),
Container(width: 6, height: 6, decoration: BoxDecoration(shape: BoxShape.circle, color: (_presence['${r['id']}'] ?? const []).isNotEmpty ? const Color(0xFF22C55E) : const Color(0xFFB8BEC8))),
const SizedBox(width: 3),
Text((_presence['${r['id']}'] ?? const []).isNotEmpty ? '在线' : '离线', style: const TextStyle(fontSize: 11, color: kMute)),
],
),
Text(
[r['department'], r['title']].where((e) => e != null && '$e'.isNotEmpty).join(' · '),
style: const TextStyle(fontSize: 12, color: kMute),
),
],
),
),
),
],
),
),
),
],
const SizedBox(height: 32),
],
),
Align(
alignment: Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.only(right: 4),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (final l in letters)
Text(l, style: const TextStyle(fontSize: 10, color: kBind, height: 1.35)),
],
),
),
),
],
),
),
),
],
);
if (widget.embedded) {
return ColoredBox(
color: widget.desktopStyle ? kDeskBg : kPaper,
child: Column(
children: [
widget.desktopStyle
? DesktopPaneHeader(
title: '通讯录',
bottom: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: DesktopSearchBar(hint: '搜索同事、部门、手机', onChanged: (v) => setState(() => _q = v.trim())),
),
)
: const WxHeader(title: '通讯录'),
Expanded(child: widget.desktopStyle ? _desktopBody(shown, groups, letters, depts) : body),
],
),
);
}
return Scaffold(appBar: AppBar(title: const Text('通讯录')), body: body);
}
}
+83
View File
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../labels.dart';
import '../pages/record_detail_page.dart';
import '../widgets/common.dart';
class FlowPage extends StatefulWidget {
const FlowPage({super.key, required this.api});
final OaClient api;
@override
State<FlowPage> createState() => _FlowPageState();
}
class _FlowPageState extends State<FlowPage> {
List<Map<String, dynamic>> _items = [];
String _bucket = '';
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final q = <String, String>{};
if (_bucket.isNotEmpty) q['bucket'] = _bucket;
final data = await widget.api.get('/office/flow', query: q.isEmpty ? null : q);
if (!mounted) return;
setState(() {
_items = asMaps(data);
_loading = false;
});
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('我的申请')),
body: Column(
children: [
BucketBar(
value: _bucket,
extra: const [('occupying', '占用中')],
onChanged: (v) {
setState(() {
_bucket = v;
_loading = true;
});
_load();
},
),
if (_loading) const LinearProgressIndicator(minHeight: 2),
Expanded(
child: _items.isEmpty
? const EmptyHint('还没有申请记录')
: RefreshIndicator(
onRefresh: _load,
child: ListView.builder(
itemCount: _items.length,
itemBuilder: (_, i) {
final r = _items[i];
return KvTile(
title: '${r['title'] ?? ''}',
subtitle: '${zh(r['source'])} · ${zh(r['kind'])} ${fmtTime(r['createdAt'])}',
status: r['status'],
onTap: () => openRecord(context, widget.api, r),
);
},
),
),
),
],
),
);
}
}
+193
View File
@@ -0,0 +1,193 @@
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../device/device_bridge.dart';
import '../labels.dart';
import '../theme.dart';
import '../widgets/common.dart';
import '../widgets/wecom.dart';
import 'record_detail_page.dart';
const _kinds = [
('PERSONAL', '请假'),
('OVERTIME', '加班'),
('BUSINESS', '出差'),
('OUT', '外出'),
('REGULARIZE', '转正'),
('RESIGN', '离职'),
];
class HrApplyPage extends StatefulWidget {
const HrApplyPage({super.key, required this.api});
final OaClient api;
@override
State<HrApplyPage> createState() => _HrApplyPageState();
}
class _HrApplyPageState extends State<HrApplyPage> {
List<Map<String, dynamic>> _items = [];
bool _loading = true;
String _bucket = '';
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
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 showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
builder: (ctx) => Padding(
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(ctx).bottom),
child: StatefulBuilder(
builder: (ctx, setSt) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('发起人事申请', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
Wrap(
spacing: 8,
children: [
for (final k in _kinds)
ChoiceChip(
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(hintText: '天数')),
const SizedBox(height: 8),
TextField(controller: reason, maxLines: 3, decoration: const InputDecoration(hintText: '事由')),
const SizedBox(height: 16),
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) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已提交,由汇报对象和部门总监审批')));
_load();
}
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e')));
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: kPaper,
appBar: AppBar(
title: const Text('人事申请'),
actions: [IconButton(onPressed: _create, icon: const Icon(Icons.add))],
),
body: Column(
children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
child: Row(
children: [
for (final it in [('', '全部'), ('pending', '待审批'), ('approved', '已通过'), ('rejected', '已驳回')])
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(it.$2),
selected: _bucket == it.$1,
onSelected: (_) {
setState(() => _bucket = it.$1);
_load();
},
),
),
],
),
),
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kBind),
Expanded(
child: _items.isEmpty
? const EmptyHint('还没有人事申请')
: RefreshIndicator(
onRefresh: _load,
child: ListView(
children: [
for (final r in _items)
ConvTile(
title: '${zh(r['kind'])} · ${r['reason'] ?? ''}',
preview: '${zh(r['status'])} ${fmtTime(r['createdAt'])}',
avatarIcon: Icons.beach_access_outlined,
avatarColor: kBind,
avatarLabel: '',
onTap: () => openRecord(context, widget.api, {...r, 'source': 'HR'}),
),
],
),
),
),
],
),
);
}
}
+93
View File
@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../theme.dart';
import '../widgets/beian_footer.dart';
class LegalPage extends StatefulWidget {
const LegalPage({super.key, required this.api, required this.kind});
final OaClient api;
final String kind;
@override
State<LegalPage> createState() => _LegalPageState();
}
class _LegalPageState extends State<LegalPage> {
String _title = '';
String _meta = '';
List<Map<String, String>> _sections = [];
String _error = '';
bool _loading = true;
@override
void initState() {
super.initState();
_title = widget.kind == 'privacy' ? '隐私政策' : '用户协议';
_load();
}
Future<void> _load() async {
try {
final data = await widget.api.get('/legal/${widget.kind}');
if (data is! Map) throw ApiException('文档格式错误');
final paras = data['paragraphs'];
final sections = <Map<String, String>>[];
if (paras is List) {
for (final row in paras) {
if (row is Map) {
sections.add({
'heading': '${row['heading'] ?? ''}',
'body': '${row['body'] ?? ''}',
});
}
}
}
if (!mounted) return;
setState(() {
_title = '${data['title'] ?? _title}';
_meta = '${data['operator'] ?? ''} · 更新日期 ${data['updatedAt'] ?? ''}';
_sections = sections;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = '$e';
_loading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(title: Text(_title)),
body: _loading
? const Center(child: CircularProgressIndicator(color: kBind))
: _error.isNotEmpty
? Center(child: Padding(padding: const EdgeInsets.all(24), child: Text(_error, style: const TextStyle(color: kDanger))))
: ListView(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 40),
children: [
if (_meta.trim().isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(_meta, style: const TextStyle(color: kMute, fontSize: 12, height: 1.5)),
),
for (final s in _sections) ...[
if ((s['heading'] ?? '').isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 8),
child: Text(s['heading']!, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
),
Text(s['body'] ?? '', style: const TextStyle(fontSize: 14, height: 1.7, color: Color(0xFF334155))),
const SizedBox(height: 12),
],
const BeianFooter(),
],
),
);
}
}
@@ -0,0 +1,262 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../device/device_bridge.dart';
import '../theme.dart';
import '../widgets/tencent_map_thumb.dart';
import '../widgets/wecom.dart';
class GeoPlace {
GeoPlace({required this.lat, required this.lng, required this.title, required this.address, this.distance});
final double lat;
final double lng;
final String title;
final String address;
final num? distance;
Map<String, dynamic> toMeta() => {
'lat': lat,
'lng': lng,
'title': title,
'address': address,
};
}
class LocationPickPage extends StatefulWidget {
const LocationPickPage({super.key, required this.api});
final OaClient api;
@override
State<LocationPickPage> createState() => _LocationPickPageState();
}
class _LocationPickPageState extends State<LocationPickPage> {
double? _lat;
double? _lng;
GeoPlace? _picked;
List<GeoPlace> _pois = [];
bool _loading = true;
String _err = '';
String _q = '';
Timer? _searchDebounce;
@override
void initState() {
super.initState();
_locate();
}
@override
void dispose() {
_searchDebounce?.cancel();
super.dispose();
}
Future<void> _locate() async {
setState(() {
_loading = true;
_err = '';
});
try {
Map<String, double>? loc;
try {
loc = await DeviceBridge.getLocation();
} catch (_) {}
final lat = loc?['lat'];
final lng = loc?['lng'];
if (lat == null || lng == null) {
throw Exception('没有拿到定位,请允许位置权限并打开系统定位');
}
await _loadAround(lat, lng);
} catch (e) {
if (mounted) setState(() { _loading = false; _err = '$e'; });
}
}
Future<void> _loadAround(double lat, double lng) async {
final data = await widget.api.post('/office/geo/reverse', {'lat': lat, 'lng': lng});
if (!mounted) return;
final map = data is Map ? Map<String, dynamic>.from(data) : <String, dynamic>{};
final here = GeoPlace(
lat: lat,
lng: lng,
title: '${map['title'] ?? '当前位置'}',
address: '${map['address'] ?? ''}',
);
final pois = <GeoPlace>[here];
final raw = map['pois'];
if (raw is List) {
for (final e in raw) {
if (e is! Map) continue;
final plat = (e['lat'] as num?)?.toDouble();
final plng = (e['lng'] as num?)?.toDouble();
if (plat == null || plng == null) continue;
pois.add(GeoPlace(
lat: plat,
lng: plng,
title: '${e['title'] ?? e['address'] ?? '地点'}',
address: '${e['address'] ?? ''}',
distance: e['distance'] as num?,
));
}
}
setState(() {
_lat = lat;
_lng = lng;
_picked = here;
_pois = pois;
_loading = false;
_err = '';
});
}
void _onSearch(String q) {
_q = q.trim();
_searchDebounce?.cancel();
if (_q.isEmpty) {
if (_lat != null && _lng != null) _loadAround(_lat!, _lng!);
return;
}
_searchDebounce = Timer(const Duration(milliseconds: 350), () async {
try {
final data = await widget.api.get('/office/geo/suggest', query: {
'keyword': _q,
if (_lat != null) 'lat': '$_lat',
if (_lng != null) 'lng': '$_lng',
});
if (!mounted) return;
final list = data is List ? data : (data is Map ? (data['items'] ?? data['data'] ?? []) : []);
final pois = <GeoPlace>[];
for (final e in (list is List ? list : <dynamic>[])) {
if (e is! Map) continue;
final plat = (e['lat'] as num?)?.toDouble();
final plng = (e['lng'] as num?)?.toDouble();
if (plat == null || plng == null) continue;
pois.add(GeoPlace(
lat: plat,
lng: plng,
title: '${e['title'] ?? '地点'}',
address: '${e['address'] ?? ''}',
distance: e['distance'] as num?,
));
}
setState(() {
_pois = pois;
if (pois.isNotEmpty) _picked = pois.first;
});
} catch (_) {}
});
}
String _dist(num? d) {
if (d == null) return '';
if (d < 1) return '当前位置';
if (d < 1000) return '${d.round()}m';
return '${(d / 1000).toStringAsFixed(1)}km';
}
@override
Widget build(BuildContext context) {
final picked = _picked;
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
leading: TextButton(onPressed: () => Navigator.pop(context), child: const Text('取消')),
title: const Text('发送位置'),
actions: [
Padding(
padding: const EdgeInsets.only(right: 8, top: 8, bottom: 8),
child: FilledButton(
style: FilledButton.styleFrom(backgroundColor: kWeGreen, minimumSize: const Size(64, 32)),
onPressed: picked == null ? null : () => Navigator.pop(context, picked),
child: const Text('发送'),
),
),
],
),
body: Column(
children: [
SizedBox(
height: 220,
width: double.infinity,
child: Stack(
children: [
if (picked != null)
Positioned.fill(
child: TencentMapThumb(api: widget.api, lat: picked.lat, lng: picked.lng, height: 220),
)
else
const ColoredBox(color: Color(0xFFE8F5E9), child: Center(child: CircularProgressIndicator())),
Positioned(
left: 12,
bottom: 12,
child: Material(
color: Colors.white,
shape: const CircleBorder(),
elevation: 2,
child: IconButton(
onPressed: _locate,
icon: const Icon(Icons.my_location, color: kBind),
),
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
child: WxSearchBar(hint: '搜索地点', onChanged: _onSearch),
),
if (_err.isNotEmpty)
Padding(
padding: const EdgeInsets.all(12),
child: Text(_err, style: const TextStyle(color: kDanger, fontSize: 13)),
),
Expanded(
child: _loading
? const Center(child: CircularProgressIndicator())
: ListView.builder(
itemCount: _pois.length,
itemBuilder: (_, i) {
final p = _pois[i];
final on = picked?.lat == p.lat && picked?.lng == p.lng && picked?.title == p.title;
return InkWell(
onTap: () => setState(() => _picked = p),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Row(
children: [
Expanded(
child: Container(
padding: const EdgeInsets.only(bottom: 12),
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: kLine, width: 0.5))),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(p.title, style: const TextStyle(fontSize: 16, color: kInk)),
const SizedBox(height: 2),
Text(
[_dist(p.distance), p.address].where((e) => e.isNotEmpty).join(' | '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, color: kMute),
),
],
),
),
),
if (on) const Padding(padding: EdgeInsets.only(left: 8), child: Icon(Icons.check, color: kWeGreen)),
],
),
),
);
},
),
),
],
),
);
}
}
+207
View File
@@ -0,0 +1,207 @@
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../app_config.dart';
import '../pages/legal_page.dart';
import '../session/session.dart';
import '../theme.dart';
import '../widgets/beian_footer.dart';
import '../widgets/wecom.dart';
class LoginPage extends StatefulWidget {
const LoginPage({super.key, required this.session, required this.api});
final SessionStore session;
final OaClient api;
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final _phone = TextEditingController();
final _pass = TextEditingController();
final _sms = TextEditingController();
bool _loading = false;
bool _smsLogin = false;
bool _agreed = false;
String _error = '';
int _wait = 0;
@override
void initState() {
super.initState();
_loadOptions();
}
Future<void> _loadOptions() async {
try {
final data = await widget.api.get('/auth/login-options');
if (data is Map && mounted) setState(() => _smsLogin = data['smsLogin'] == true);
} catch (_) {}
}
Future<void> _sendSms() async {
if (_phone.text.trim().isEmpty) {
setState(() => _error = '请先填写手机号');
return;
}
setState(() => _error = '');
try {
await widget.api.post('/auth/sms-code', {'username': _phone.text.trim()});
setState(() => _wait = 60);
_tick();
} catch (e) {
setState(() => _error = '$e');
}
}
void _tick() {
Future.delayed(const Duration(seconds: 1), () {
if (!mounted || _wait <= 0) return;
setState(() => _wait--);
if (_wait > 0) _tick();
});
}
Future<void> _openLegal(String kind) async {
await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => LegalPage(api: widget.api, kind: kind)),
);
}
Future<void> _submit() async {
if (!_agreed) {
setState(() => _error = '请先阅读并同意用户协议和隐私政策');
return;
}
setState(() {
_loading = true;
_error = '';
});
try {
final data = await widget.api.post('/auth/login', {
'username': _phone.text.trim(),
'password': _pass.text,
'agreeTerms': true,
'clientKind': 'mobile',
if (_smsLogin && _sms.text.trim().isNotEmpty) 'smsCode': _sms.text.trim(),
});
await widget.session.applyLogin(Map<String, dynamic>.from(data as Map));
try {
final menus = await widget.api.get('/system/menus');
widget.session.setMenus(asMaps(menus).map(MenuNode.fromJson).toList());
} catch (_) {}
} catch (e) {
if (mounted) setState(() => _error = '$e');
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
void dispose() {
_phone.dispose();
_pass.dispose();
_sms.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
children: [
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(28, 48, 28, 16),
children: [
const SquareAvatar(label: '', size: 64, color: kBind, icon: Icons.apartment),
const SizedBox(height: 16),
const Text(AppConfig.shortName, textAlign: TextAlign.center, style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700)),
const SizedBox(height: 4),
const Text('手机号登录', textAlign: TextAlign.center, style: TextStyle(color: kMute)),
const SizedBox(height: 36),
if (_error.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(_error, style: const TextStyle(color: kDanger)),
),
TextField(
controller: _phone,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(hintText: '手机号'),
),
const SizedBox(height: 12),
TextField(
controller: _pass,
obscureText: true,
decoration: const InputDecoration(hintText: '密码'),
),
if (_smsLogin) ...[
const SizedBox(height: 12),
Row(
children: [
Expanded(child: TextField(controller: _sms, decoration: const InputDecoration(hintText: '短信验证码'))),
const SizedBox(width: 8),
TextButton(onPressed: _wait > 0 ? null : _sendSms, child: Text(_wait > 0 ? '${_wait}s' : '获取验证码')),
],
),
],
const SizedBox(height: 16),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 24,
height: 24,
child: Checkbox(
value: _agreed,
onChanged: (v) => setState(() => _agreed = v == true),
),
),
const SizedBox(width: 4),
Expanded(
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
const Text('我已阅读并同意', style: TextStyle(fontSize: 13, color: kMute)),
GestureDetector(
onTap: () => _openLegal('user-agreement'),
child: const Text('《用户协议》', style: TextStyle(fontSize: 13, color: kBind)),
),
const Text('', style: TextStyle(fontSize: 13, color: kMute)),
GestureDetector(
onTap: () => _openLegal('privacy'),
child: const Text('《隐私政策》', style: TextStyle(fontSize: 13, color: kBind)),
),
],
),
),
],
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
height: 46,
child: FilledButton(
onPressed: _loading || !_agreed ? null : _submit,
child: Text(_loading ? '登录中…' : '登录', style: const TextStyle(fontSize: 16)),
),
),
],
),
),
const BeianFooter(),
],
),
),
),
),
);
}
}
+575
View File
@@ -0,0 +1,575 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../im/chat_prefs.dart';
import '../pages/chat_page.dart';
import '../pages/notices_page.dart';
import '../pages/pick_people_page.dart';
import '../pages/scan_login_page.dart';
import '../pages/system_notice_page.dart';
import '../session/session.dart';
import '../theme.dart';
import '../widgets/common.dart';
import '../widgets/desktop_ui.dart';
import '../widgets/wecom.dart';
class MessagesPage extends StatefulWidget {
const MessagesPage({
super.key,
required this.session,
required this.api,
this.desktopPane = false,
this.desktopStyle = false,
this.selectedId,
this.onSelectConv,
});
final SessionStore session;
final OaClient api;
final bool desktopPane;
final bool desktopStyle;
final String? selectedId;
final void Function(Map<String, dynamic> row)? onSelectConv;
@override
State<MessagesPage> createState() => _MessagesPageState();
}
class _MessagesPageState extends State<MessagesPage> {
List<Map<String, dynamic>> _items = [];
bool _loading = true;
String _err = '';
String _filter = 'all';
String _q = '';
bool _showSearch = false;
Timer? _imDebounce;
@override
void initState() {
super.initState();
widget.session.im.addListener(_onIm);
ChatPrefs.ensure().then((_) {
if (mounted) setState(() {});
});
_load();
}
@override
void dispose() {
_imDebounce?.cancel();
widget.session.im.removeListener(_onIm);
super.dispose();
}
void _onIm() {
_imDebounce?.cancel();
_imDebounce = Timer(const Duration(milliseconds: 250), () {
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;
_err = '';
});
} catch (e) {
if (!mounted) return;
setState(() {
_loading = false;
if (_items.isEmpty) _err = '$e';
});
}
}
int get _unread {
var n = 0;
for (final r in _items) {
if (ChatPrefs.muted('${r['id'] ?? ''}')) continue;
final id = '${r['id'] ?? ''}';
final fake = ChatPrefs.fakeUnread(id);
if (fake > 0) {
n += fake;
continue;
}
n += (r['unread'] as num?)?.toInt() ?? 0;
}
return n;
}
Future<void> _showDesktopConvMenu(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':
await ChatPrefs.setHidden(id, true);
case 'clear':
await ChatPrefs.clearHistory(id);
case 'delete':
await ChatPrefs.setHidden(id, true);
}
if (mounted) {
setState(() {});
await _load();
}
}
Widget _wrapConv(Map<String, dynamic> r, Widget child) {
if (!widget.desktopStyle) return child;
return GestureDetector(
onSecondaryTapDown: (d) => _showDesktopConvMenu(r, d.globalPosition),
child: child,
);
}
Future<void> _newChat({required bool group}) async {
final picked = await Navigator.of(context).push<List<Map<String, dynamic>>>(
MaterialPageRoute(
builder: (_) => PickPeoplePage(
api: widget.api,
title: group ? '选择联系人' : '发起单聊',
multiple: group,
exclude: {widget.session.userId},
),
),
);
if (picked == null || picked.isEmpty) return;
if (!group) {
final p = picked.first;
if (!mounted) return;
await Navigator.of(context).push(MaterialPageRoute(
builder: (_) => ChatPage(
session: widget.session,
api: widget.api,
peerId: '${p['id']}',
peerName: '${p['name'] ?? '同事'}',
peerAvatarFileId: '${p['avatarFileId'] ?? ''}',
),
));
_load();
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();
}
}
List<Map<String, dynamic>> get _shown {
var list = _items;
// 只有用户明确执行“不显示/删除”后才隐藏;阅读状态不会改变会话是否存在。
list = list.where((e) {
final id = '${e['id'] ?? ''}';
return !ChatPrefs.hidden(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 = [...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;
}
@override
Widget build(BuildContext context) {
final header = widget.desktopStyle
? DesktopPaneHeader(
title: _unread > 0 ? '消息($_unread' : '消息',
actions: [
IconButton(
onPressed: () => setState(() => _showSearch = !_showSearch),
icon: const Icon(Icons.search, color: kInk, size: 20),
),
Builder(
builder: (ctx) => IconButton(
onPressed: () => showPlusMenu(ctx, [
(Icons.qr_code_scanner_outlined, '扫一扫登录电脑', () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => ScanLoginPage(api: widget.api)))),
(Icons.chat_bubble_outline, '发起单聊', () => _newChat(group: false)),
(Icons.group_outlined, '发起群聊', () => _newChat(group: true)),
]),
icon: const Icon(Icons.add, color: kInk, size: 22),
),
),
],
bottom: Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 10),
child: Column(
children: [
if (_showSearch) ...[
DesktopSearchBar(hint: '搜索', onChanged: (v) => setState(() => _q = v.trim())),
const SizedBox(height: 8),
],
Row(
children: [
_filterChip('all', '全部'),
const SizedBox(width: 8),
_filterChip('unread', '未读'),
const SizedBox(width: 8),
_filterChip('dm', '单聊'),
const SizedBox(width: 8),
_filterChip('group', '群聊'),
],
),
],
),
),
)
: Column(
children: [
WxHeader(
title: _unread > 0 ? '消息($_unread)' : '消息',
actions: [
IconButton(
onPressed: () => setState(() => _showSearch = !_showSearch),
icon: const Icon(Icons.search, color: kInk),
),
Builder(
builder: (ctx) => IconButton(
onPressed: () => showPlusMenu(ctx, [
(Icons.qr_code_scanner_outlined, '扫一扫登录电脑', () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => ScanLoginPage(api: widget.api)))),
(Icons.chat_bubble_outline, '发起单聊', () => _newChat(group: false)),
(Icons.group_outlined, '发起群聊', () => _newChat(group: true)),
]),
icon: const Icon(Icons.add_circle_outline, color: kInk),
),
),
],
),
if (_showSearch) WxSearchBar(hint: '搜索', onChanged: (v) => setState(() => _q = v.trim())),
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 4),
child: Row(
children: [
_filterChip('all', '全部'),
const SizedBox(width: 8),
_filterChip('unread', '未读'),
const SizedBox(width: 8),
_filterChip('dm', '单聊'),
const SizedBox(width: 8),
_filterChip('group', '群聊'),
],
),
),
],
);
return ColoredBox(
color: Colors.white,
child: Column(
children: [
header,
if (_loading)
const LinearProgressIndicator(minHeight: 2, color: kBind),
if (_err.isNotEmpty)
Padding(
padding: const EdgeInsets.all(16),
child: Text(_err, style: const TextStyle(color: kDanger))),
Expanded(
child: RefreshIndicator(
onRefresh: _load,
child: ListView(
padding: EdgeInsets.zero,
children: [
ConvTile(
title: '公告',
preview: '公司通知与制度',
avatarIcon: Icons.campaign,
avatarColor: const Color(0xFFE75D5D),
avatarLabel: '',
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => NoticesPage(api: widget.api),
)),
),
if (_shown.isEmpty && !_loading)
const Padding(
padding: EdgeInsets.only(top: 48),
child: EmptyHint('还没有会话。点右上角 + 或到通讯录找同事。'),
),
for (final r in _shown)
_wrapConv(
r,
widget.desktopStyle
? ConvTile(
title: '${r['name'] ?? r['peerName'] ?? '同事'}',
preview: '${r['lastText'] ?? ''}',
time: shortTime(r['lastAt']),
muted: ChatPrefs.muted('${r['id'] ?? ''}'),
unread: ChatPrefs.muted('${r['id'] ?? ''}')
? 0
: (ChatPrefs.fakeUnread('${r['id'] ?? ''}') > 0
? ChatPrefs.fakeUnread('${r['id'] ?? ''}')
: ((r['unread'] as num?)?.toInt() ?? 0)),
avatarLabel: '${r['peerId']}' == '0' ? '?' : '${r['name'] ?? r['peerName'] ?? ''}',
avatarIcon: '${r['peerId']}' == '0' ? Icons.notifications : (r['type'] == 'group' ? Icons.groups : null),
avatarColor: '${r['peerId']}' == '0' ? const Color(0xFF07C160) : (r['type'] == 'group' ? const Color(0xFF07C160) : null),
avatarFileId: r['type'] == 'group' ? null : '${r['avatarFileId'] ?? ''}',
api: widget.api,
selected: widget.desktopPane && widget.selectedId == '${r['id'] ?? ''}',
onTap: () async {
final id = '${r['id'] ?? ''}';
if (id.isNotEmpty) await ChatPrefs.setFakeUnread(id, 0);
if (widget.desktopPane && widget.onSelectConv != null) {
widget.onSelectConv!(r);
return;
}
await Navigator.of(context).push(MaterialPageRoute(
builder: (_) => '${r['peerId']}' == '0'
? SystemNoticePage(session: widget.session, api: widget.api, conversationId: id)
: ChatPage(
session: widget.session,
api: widget.api,
peerId: '${r['peerId'] ?? ''}',
conversationId: id,
peerName: '${r['name'] ?? r['peerName'] ?? '同事'}',
isGroup: r['type'] == 'group',
peerAvatarFileId: '${r['avatarFileId'] ?? ''}',
),
));
_load();
},
)
: _SwipeConversation(
id: '${r['id'] ?? ''}',
api: widget.api,
onChanged: _load,
child: ConvTile(
title: '${r['name'] ?? r['peerName'] ?? '同事'}',
preview: '${r['lastText'] ?? ''}',
time: shortTime(r['lastAt']),
muted: ChatPrefs.muted('${r['id'] ?? ''}'),
unread: ChatPrefs.muted('${r['id'] ?? ''}')
? 0
: ((r['unread'] as num?)?.toInt() ?? 0),
avatarLabel: '${r['peerId']}' == '0'
? '?'
: '${r['name'] ?? r['peerName'] ?? ''}',
avatarIcon: '${r['peerId']}' == '0'
? Icons.notifications
: (r['type'] == 'group' ? Icons.groups : null),
avatarColor: '${r['peerId']}' == '0'
? const Color(0xFF07C160)
: (r['type'] == 'group'
? const Color(0xFF07C160)
: null),
avatarFileId: r['type'] == 'group'
? null
: '${r['avatarFileId'] ?? ''}',
api: widget.api,
selected: widget.desktopPane && widget.selectedId == '${r['id'] ?? ''}',
onTap: () async {
if (widget.desktopPane && widget.onSelectConv != null) {
widget.onSelectConv!(r);
return;
}
await Navigator.of(context).push(MaterialPageRoute(
builder: (_) => '${r['peerId']}' == '0'
? SystemNoticePage(session: widget.session, api: widget.api, conversationId: '${r['id'] ?? ''}')
: ChatPage(
session: widget.session,
api: widget.api,
peerId: '${r['peerId'] ?? ''}',
conversationId: '${r['id'] ?? ''}',
peerName: '${r['name'] ?? r['peerName'] ?? '同事'}',
isGroup: r['type'] == 'group',
peerAvatarFileId: '${r['avatarFileId'] ?? ''}',
),
));
_load();
},
),
),
),
const SizedBox(height: 24),
],
),
),
),
],
),
);
}
Widget _filterChip(String id, String label) {
final on = _filter == id;
return GestureDetector(
onTap: () => setState(() => _filter = id),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: on ? const Color(0xFFE8F3FF) : Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
child: Text(label,
style: TextStyle(
fontSize: 13,
color: on ? kBind : kMute,
fontWeight: on ? FontWeight.w600 : FontWeight.w400)),
),
);
}
}
/// 微信式左滑操作栏:操作不会误删服务器会话,删除仅隐藏本机列表。
class _SwipeConversation extends StatefulWidget {
const _SwipeConversation(
{required this.id,
required this.api,
required this.child,
required this.onChanged});
final String id;
final OaClient api;
final Widget child;
final Future<void> Function() onChanged;
@override
State<_SwipeConversation> createState() => _SwipeConversationState();
}
class _SwipeConversationState extends State<_SwipeConversation> {
double _offset = 0;
static const _width = 216.0;
Future<void> _hide() async {
if (!mounted) return;
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) {
if (mounted) setState(() => _offset = 0);
return;
}
await ChatPrefs.setHidden(widget.id, true);
await widget.onChanged();
}
Future<void> _toggleMute() async {
await ChatPrefs.setMuted(widget.id, !ChatPrefs.muted(widget.id));
if (mounted) setState(() => _offset = 0);
await widget.onChanged();
}
@override
Widget build(BuildContext context) {
return SizedBox(
height: 76,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragUpdate: (d) => setState(() => _offset = (_offset + d.delta.dx).clamp(-_width, 0)),
onHorizontalDragEnd: (_) => setState(() => _offset = _offset < -80 ? -_width : 0),
child: Stack(
fit: StackFit.expand,
children: [
Align(
alignment: Alignment.centerRight,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_action('免打扰', const Color(0xFFFFA940), _toggleMute),
_action('不显示', const Color(0xFF8C8C8C), _hide),
_action('删除', const Color(0xFFF5222D), _hide),
],
),
),
AnimatedContainer(
duration: const Duration(milliseconds: 160),
curve: Curves.easeOut,
transform: Matrix4.translationValues(_offset, 0, 0),
color: Colors.white,
child: widget.child,
),
],
),
),
);
}
Widget _action(String label, Color color, Future<void> Function() onTap) {
return SizedBox(
width: 72,
height: double.infinity,
child: Material(
color: color,
child: InkWell(
onTap: () async => onTap(),
child: Center(
child: Text(label,
style: const TextStyle(color: Colors.white, fontSize: 13))),
),
),
);
}
}
+267
View File
@@ -0,0 +1,267 @@
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../device/push_bridge.dart';
import '../app_config.dart';
import '../labels.dart';
import '../ota/updater.dart';
import '../pages/legal_page.dart';
import '../pages/profile_page.dart';
import '../session/session.dart';
import '../theme.dart';
import '../widgets/beian_footer.dart';
import '../widgets/desktop_ui.dart';
import '../widgets/wecom.dart';
class MinePage extends StatefulWidget {
const MinePage({super.key, required this.session, required this.api, this.desktopStyle = false});
final SessionStore session;
final OaClient api;
final bool desktopStyle;
@override
State<MinePage> createState() => _MinePageState();
}
class _MinePageState extends State<MinePage> {
AppRelease? _rel;
bool _checking = false;
@override
void initState() {
super.initState();
widget.session.addListener(_onSession);
_peek();
}
@override
void dispose() {
widget.session.removeListener(_onSession);
super.dispose();
}
void _onSession() {
if (mounted) setState(() {});
}
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) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('暂时无法检查更新')));
return;
}
await OtaUpdater(widget.api).prompt(context, rel, manual: true);
} finally {
if (mounted) setState(() => _checking = false);
}
}
@override
Widget build(BuildContext context) {
final s = widget.session;
final avatarId = '${s.user['avatarFileId'] ?? ''}';
return ColoredBox(
color: widget.desktopStyle ? kDeskBg : kPaper,
child: Column(
children: [
widget.desktopStyle ? const DesktopPaneHeader(title: '') : const WxHeader(title: ''),
Expanded(
child: widget.desktopStyle
? Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
children: _mineItems(s, avatarId),
),
),
)
: ListView(
children: _mineItems(s, avatarId),
),
),
],
),
);
}
List<Widget> _mineItems(SessionStore s, String avatarId) {
return [
const SizedBox(height: 12),
Material(
color: Colors.white,
child: InkWell(
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => ProfilePage(session: s, api: widget.api),
)),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
child: Row(
children: [
SquareAvatar(
label: s.displayName,
size: 64,
fileId: avatarId.isEmpty ? null : avatarId,
api: widget.api,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(s.displayName,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Text('账号 ${s.user['username'] ?? ''}',
style: const TextStyle(
color: kMute, fontSize: 13)),
Text(s.roles.map(zh).join(' · '),
style: const TextStyle(
color: kMute, fontSize: 12)),
],
),
),
const Icon(Icons.chevron_right,
color: Color(0xFFC0C0C0)),
],
),
),
),
),
const SizedBox(height: 12),
CellGroup(
children: [
if (!widget.desktopStyle)
Cell(
title: '后台运行与通知',
subtitle: '允许自启动和后台,消息才能及时送达',
leading: const SquareAvatar(
label: '',
color: kBind,
icon: Icons.notifications_active_outlined,
size: 32),
onTap: () async {
await PushBridge.requestBattery();
await PushBridge.openOemKeepAlive();
},
),
if (!widget.desktopStyle)
Cell(
title: '检查更新',
subtitle: _rel != null && _rel!.newer
? '有新版本 ${_rel!.version}'
: '当前 ${AppConfig.version} (${AppConfig.build})',
leading: const SquareAvatar(
label: '',
color: kBind,
icon: Icons.system_update_alt,
size: 32),
trailing: _checking
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2))
: (_rel != null && _rel!.newer
? const UnreadBadge(1, dot: true)
: null),
onTap: _checkUpdate,
),
if (widget.desktopStyle)
Cell(
title: '检查更新',
subtitle: _rel != null && _rel!.newer
? '有新版本 ${_rel!.version}'
: '当前 ${AppConfig.version} (${AppConfig.build})',
leading: const SquareAvatar(
label: '',
color: kBind,
icon: Icons.system_update_alt,
size: 32),
trailing: _checking
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2))
: (_rel != null && _rel!.newer
? const UnreadBadge(1, dot: true)
: null),
onTap: _checkUpdate,
),
Cell(
title: '用户协议',
leading: const SquareAvatar(
label: '',
color: kBind,
icon: Icons.article_outlined,
size: 32),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => LegalPage(
api: widget.api, kind: 'user-agreement')),
),
),
Cell(
title: '隐私政策',
leading: const SquareAvatar(
label: '',
color: kBind,
icon: Icons.privacy_tip_outlined,
size: 32),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) =>
LegalPage(api: widget.api, kind: 'privacy')),
),
showLine: false,
),
],
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
child: Material(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: _logout,
borderRadius: BorderRadius.circular(8),
child: const SizedBox(
height: 48,
child: Center(
child: Text('退出登录',
style:
TextStyle(fontSize: 16, color: kDanger))),
),
),
),
),
const SizedBox(height: 8),
Text(
'${AppConfig.brand}\n${AppConfig.version} (${AppConfig.build})',
textAlign: TextAlign.center,
style:
const TextStyle(color: kMute, fontSize: 12, height: 1.5),
),
const BeianFooter(),
];
}
}
+146
View File
@@ -0,0 +1,146 @@
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../labels.dart';
import '../widgets/common.dart';
import 'record_detail_page.dart';
class RestShell {
const RestShell(this.path, {this.query});
final String path;
final Map<String, String>? query;
}
RestShell? shellFor(String code, String? path) {
final p = path ?? '';
final c = code.toLowerCase();
if (c.startsWith('bid') || p.startsWith('/bid')) return const RestShell('/bid-cases');
if (c.startsWith('contract:hr') || p.startsWith('/contract/hr') || p.contains('labor-contract')) {
return const RestShell('/labor-contracts');
}
if (c.startsWith('contract') || p.startsWith('/contract')) return const RestShell('/contracts');
if (p.startsWith('/party/entities') || c == 'party:entities') return const RestShell('/legal-entities');
if (p.startsWith('/party/contacts') || c == 'party:contacts') return const RestShell('/contacts');
if (c.startsWith('party') || p.startsWith('/party')) return const RestShell('/parties');
if (c == 'seal:borrows' || p.contains('borrow')) return const RestShell('/credential-borrows');
if (c == 'seal:requests' || p.contains('seal-apply') || p.contains('/seal/requests')) {
return const RestShell('/seal-requests');
}
if (c == 'seal:registry' || p.contains('/seal/registry')) return const RestShell('/seals');
if (c.startsWith('seal') || p.startsWith('/seal')) return const RestShell('/qualifications');
if (c.startsWith('asset') || p.startsWith('/asset') || p.contains('registered-asset')) {
return const RestShell('/registered-assets');
}
if (p.contains('asset-purchase')) return const RestShell('/asset-purchases');
if (p.contains('purchase') || c.contains('purchase')) return const RestShell('/purchase-requests');
if (p.contains('/assets') && !p.contains('registered')) return const RestShell('/assets');
if (c.contains('timesheet') || p.contains('timesheet')) return const RestShell('/timesheets');
if (c.contains('task') || p.contains('task')) return const RestShell('/project-tasks');
if (p.contains('project-change')) return const RestShell('/project-changes');
if (p.contains('resource-board')) return const RestShell('/resource-board');
if (c.startsWith('project') || p.startsWith('/project')) return const RestShell('/projects');
if (p.contains('loan') || c.contains('loan')) return const RestShell('/loans');
if (p.contains('bond')) return const RestShell('/bonds');
if (p.contains('invoice')) return const RestShell('/invoices');
if (p.contains('payment')) return const RestShell('/payments');
if (p.contains('expense') || c.contains('expense')) return const RestShell('/expenses');
if (p.contains('my-expense')) return const RestShell('/office/my-expenses');
if (p.contains('payroll') || c.contains('payroll')) return const RestShell('/payroll');
if (p.contains('leave') || c.contains('leave')) return const RestShell('/leave-requests');
if (p.contains('employment')) return const RestShell('/employment-events');
if (p.contains('departments') || c.contains('dept')) return const RestShell('/departments');
if (p.contains('applies') || c.contains('apply')) return const RestShell('/office/applies');
if (p.contains('work-assign')) return const RestShell('/office/work-assignments');
if (p.contains('cost-target')) return const RestShell('/cost-targets');
if (p.contains('attendance/punch')) return const RestShell('/attendance/punches');
if (c.contains('attendance') || p.contains('attendance')) return const RestShell('/attendance');
if (p.contains('employee') || c.contains('employee') || p.startsWith('/hr')) {
return const RestShell('/employees', query: {'page': '1', 'pageSize': '100'});
}
if (c.startsWith('report') || p.startsWith('/reports') || p.startsWith('/office/reports')) {
return const RestShell('/office/reports');
}
if (c.startsWith('legal') || p.startsWith('/legal')) return null;
return null;
}
class ModuleListPage extends StatefulWidget {
const ModuleListPage({super.key, required this.api, required this.title, required this.shell});
final OaClient api;
final String title;
final RestShell shell;
@override
State<ModuleListPage> createState() => _ModuleListPageState();
}
class _ModuleListPageState extends State<ModuleListPage> {
List<Map<String, dynamic>> _items = [];
String _err = '';
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_loading = true;
_err = '';
});
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 (e) {
if (!mounted) return;
setState(() {
_err = '$e';
_loading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.title)),
body: _loading
? const Center(child: CircularProgressIndicator())
: _err.isNotEmpty
? EmptyHint(_err)
: _items.isEmpty
? EmptyHint('暂无${widget.title}')
: RefreshIndicator(
onRefresh: _load,
child: ListView(
children: [
for (final r in _items)
KvTile(
title: pickTitle(r),
subtitle: zh(pickSub(r)),
status: r['status'],
onTap: () {
final seed = Map<String, dynamic>.from(r);
if ('${r['id']}'.length >= 8) seed['_fetch'] = '${widget.shell.path}/${r['id']}';
openRecord(context, widget.api, seed, title: pickTitle(r));
},
),
],
),
),
);
}
}
@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../theme.dart';
import '../widgets/html_body.dart';
class NoticeDetailPage extends StatefulWidget {
const NoticeDetailPage({super.key, required this.api, required this.id, this.title = '公告'});
final OaClient api;
final String id;
final String title;
@override
State<NoticeDetailPage> createState() => _NoticeDetailPageState();
}
class _NoticeDetailPageState extends State<NoticeDetailPage> {
Map<String, dynamic> _row = {};
bool _loading = true;
String _err = '';
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final data = await widget.api.get('/office/notices/${widget.id}');
if (!mounted) return;
setState(() {
_row = data is Map ? Map<String, dynamic>.from(data) : {};
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_err = '$e';
_loading = false;
});
}
}
@override
Widget build(BuildContext context) {
final title = '${_row['title'] ?? widget.title}';
final author = '${(_row['createdBy'] is Map ? (_row['createdBy'] as Map)['displayName'] : '')}';
final time = fmtTime(_row['createdAt'] ?? _row['publishedAt']);
final content = '${_row['content'] ?? ''}';
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(title: const Text('通知公告'), leading: const BackButton()),
body: _loading
? const Center(child: CircularProgressIndicator())
: _err.isNotEmpty
? Center(child: Padding(padding: const EdgeInsets.all(24), child: Text(_err, style: const TextStyle(color: kMute))))
: ListView(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 40),
children: [
Text(title, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: kInk, height: 1.35)),
const SizedBox(height: 8),
Text(
[if (author.isNotEmpty) author, if (time.isNotEmpty) time].join(' '),
style: const TextStyle(fontSize: 13, color: kMute),
),
const SizedBox(height: 20),
HtmlBody(html: content, api: widget.api),
],
),
);
}
}
+67
View File
@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../widgets/common.dart';
import 'notice_detail_page.dart';
class NoticesPage extends StatefulWidget {
const NoticesPage({super.key, required this.api});
final OaClient api;
@override
State<NoticesPage> createState() => _NoticesPageState();
}
class _NoticesPageState extends State<NoticesPage> {
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('/office/notices');
if (!mounted) return;
setState(() {
_items = asMaps(data);
_loading = false;
});
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('公司公告')),
body: _loading
? const Center(child: CircularProgressIndicator())
: _items.isEmpty
? const EmptyHint('暂无公告')
: RefreshIndicator(
onRefresh: _load,
child: ListView(
children: [
for (final r in _items)
KvTile(
title: '${r['title'] ?? ''}',
subtitle: '${r['excerpt'] ?? ''} ${fmtTime(r['createdAt'])}',
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => NoticeDetailPage(
api: widget.api,
id: '${r['id']}',
title: '${r['title'] ?? '公告'}',
),
)),
),
],
),
),
);
}
}
+181
View File
@@ -0,0 +1,181 @@
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../session/session.dart';
import '../theme.dart';
import '../widgets/wecom.dart';
import 'chat_page.dart';
class OrgBrowsePage extends StatefulWidget {
const OrgBrowsePage({
super.key,
required this.session,
required this.api,
required this.staff,
this.parentId,
this.title = '组织架构',
this.deptName,
});
final SessionStore session;
final OaClient api;
final List<Map<String, dynamic>> staff;
final String? parentId;
final String title;
final String? deptName;
@override
State<OrgBrowsePage> createState() => _OrgBrowsePageState();
}
class _OrgBrowsePageState extends State<OrgBrowsePage> {
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 _openPerson(Map<String, dynamic> r) {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => ChatPage(
session: widget.session,
api: widget.api,
peerId: '${r['id']}',
peerName: '${r['name'] ?? '同事'}',
peerAvatarFileId: '${r['avatarFileId'] ?? ''}',
),
));
}
@override
Widget build(BuildContext context) {
final children = _children;
final people = _people;
final fallbackDepts = <String>{};
if (_depts.isEmpty && (widget.parentId == null || widget.parentId!.isEmpty)) {
for (final r in widget.staff) {
final d = '${r['department'] ?? ''}';
if (d.isNotEmpty) fallbackDepts.add(d);
}
}
return Scaffold(
backgroundColor: kPaper,
appBar: AppBar(title: Text(widget.title), leading: const BackButton()),
body: _loading
? const Center(child: CircularProgressIndicator())
: ListView(
children: [
const SizedBox(height: 12),
if (children.isNotEmpty)
CellGroup(
children: [
for (var i = 0; i < children.length; i++)
Cell(
title: '${children[i]['name'] ?? ''}',
subtitle: '${_countOf(children[i])}',
leading: const SquareAvatar(label: '', color: kBind, icon: Icons.apartment, size: 36),
showLine: i != children.length - 1,
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => OrgBrowsePage(
session: widget.session,
api: widget.api,
staff: widget.staff,
parentId: '${children[i]['id']}',
title: '${children[i]['name'] ?? '部门'}',
deptName: '${children[i]['name'] ?? ''}',
),
)),
),
],
)
else if (fallbackDepts.isNotEmpty)
CellGroup(
children: [
for (final d in (fallbackDepts.toList()..sort()))
Cell(
title: d,
subtitle: '${widget.staff.where((e) => '${e['department']}' == d).length} ',
leading: const SquareAvatar(label: '', color: kBind, icon: Icons.apartment, size: 36),
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => OrgBrowsePage(
session: widget.session,
api: widget.api,
staff: widget.staff,
parentId: '__leaf__',
title: d,
deptName: d,
),
)),
),
],
),
if (people.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text('${widget.deptName} · ${people.length}', style: const TextStyle(fontSize: 13, color: kMute)),
),
CellGroup(
children: [
for (var i = 0; i < people.length; i++)
Cell(
title: '${people[i]['name'] ?? ''}',
subtitle: '${people[i]['title'] ?? ''}',
leading: SquareAvatar(
label: '${people[i]['name'] ?? ''}',
fileId: '${people[i]['avatarFileId'] ?? ''}',
api: widget.api,
size: 36,
),
showLine: i != people.length - 1,
onTap: () => _openPerson(people[i]),
),
],
),
],
if (children.isEmpty && fallbackDepts.isEmpty && people.isEmpty)
const Padding(
padding: EdgeInsets.only(top: 48),
child: Center(child: Text('这个部门还没有人', style: TextStyle(color: kMute))),
),
],
),
);
}
}
+142
View File
@@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../theme.dart';
import '../widgets/wecom.dart';
class PickPeoplePage extends StatefulWidget {
const PickPeoplePage({
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<PickPeoplePage> createState() => _PickPeoplePageState();
}
class _PickPeoplePageState extends State<PickPeoplePage> {
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);
}
}
@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: Colors.white,
appBar: AppBar(
title: Text(widget.title),
leading: IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)),
actions: [
if (widget.multiple)
TextButton(
onPressed: _selected.isEmpty
? null
: () => Navigator.pop(context, _selected.entries.map((e) => {'id': e.key, 'name': e.value}).toList()),
child: Text('确定(${_selected.length})'),
),
],
),
body: Column(
children: [
WxSearchBar(hint: '搜索同事', onChanged: (v) => setState(() => _q = v.trim())),
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kBind),
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 InkWell(
onTap: () {
if (!widget.multiple) {
Navigator.pop(context, [
{'id': id, 'name': name},
]);
return;
}
setState(() {
if (on) {
_selected.remove(id);
} else {
_selected[id] = name;
}
});
},
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 0),
child: Row(
children: [
if (widget.multiple)
Padding(
padding: const EdgeInsets.only(right: 10),
child: Icon(on ? Icons.check_circle : Icons.circle_outlined, color: on ? kBind : kMute, size: 22),
),
SquareAvatar(
label: name,
fileId: '${r['avatarFileId'] ?? ''}',
api: widget.api,
),
const SizedBox(width: 10),
Expanded(
child: Container(
padding: const EdgeInsets.only(bottom: 10),
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: kLine, width: 0.5))),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name, style: const TextStyle(fontSize: 16)),
Text(
[r['department'], r['title']].where((e) => e != null && '$e'.isNotEmpty).join(' · '),
style: const TextStyle(fontSize: 12, color: kMute),
),
],
),
),
),
],
),
),
);
},
),
),
],
),
);
}
}
+140
View File
@@ -0,0 +1,140 @@
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import '../api/oa_client.dart';
import '../labels.dart';
import '../session/session.dart';
import '../theme.dart';
import '../widgets/wecom.dart';
class ProfilePage extends StatefulWidget {
const ProfilePage({super.key, required this.session, required this.api});
final SessionStore session;
final OaClient api;
@override
State<ProfilePage> createState() => _ProfilePageState();
}
class _ProfilePageState extends State<ProfilePage> {
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));
}
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) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('头像已更新')));
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e')));
} 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(hintText: '显示名')),
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) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已保存')));
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e')));
}
}
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(hintText: '当前密码')),
const SizedBox(height: 8),
TextField(controller: newC, obscureText: true, decoration: const InputDecoration(hintText: '新密码(至少 6 位)')),
],
),
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.patch('/auth/password', {'oldPassword': oldC.text, 'newPassword': newC.text});
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('密码已更新')));
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e')));
}
}
@override
Widget build(BuildContext context) {
final s = widget.session;
final avatarId = '${s.user['avatarFileId'] ?? ''}';
return Scaffold(
backgroundColor: kPaper,
appBar: AppBar(title: const Text('个人资料')),
body: ListView(
children: [
const SizedBox(height: 12),
CellGroup(
children: [
Cell(
title: '头像',
trailing: SquareAvatar(
label: s.displayName,
size: 48,
fileId: avatarId.isEmpty ? null : avatarId,
api: widget.api,
),
onTap: _busy ? null : _pickAvatar,
),
Cell(title: '姓名', subtitle: s.displayName, onTap: _editName),
Cell(title: '账号', subtitle: '${s.user['username'] ?? ''}'),
Cell(
title: '角色',
subtitle: s.roles.map(zh).join(' · '),
showLine: false,
),
],
),
CellGroup(
children: [
Cell(title: '修改密码', onTap: _editPassword, showLine: false),
],
),
],
),
);
}
}
@@ -0,0 +1,401 @@
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../labels.dart';
import '../nav/biz_route.dart';
import '../theme.dart';
import '../widgets/wecom.dart';
import 'pick_people_page.dart';
void openRecord(BuildContext context, OaClient api, Map<String, dynamic> row,
{String? title}) {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => RecordDetailPage(
api: api, seed: row, title: title ?? detailTitleOf(row)),
));
}
class RecordDetailPage extends StatefulWidget {
const RecordDetailPage(
{super.key, required this.api, required this.seed, required this.title});
final OaClient api;
final Map<String, dynamic> seed;
final String title;
@override
State<RecordDetailPage> createState() => _RecordDetailPageState();
}
class _RecordDetailPageState extends State<RecordDetailPage> {
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('/')) {
showWxToast(context, '当前信息暂不支持转发', error: true);
return;
}
final picked = await Navigator.of(context)
.push<List<Map<String, dynamic>>>(MaterialPageRoute(
builder: (_) => PickPeoplePage(
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) showWxToast(context, '已转发给 ${picked.length}');
} catch (e) {
if (mounted) showWxToast(context, '$e', error: true);
}
}
Future<void> _act(String label, Future<void> Function() run) async {
try {
await run();
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(label)));
await _load();
}
} catch (e) {
if (mounted)
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('$e')));
}
}
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 (actions['canTerminate'] == true && id.isNotEmpty) {
out.add(OutlinedButton(
onPressed: () async {
final c = TextEditingController();
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('终止投标'),
content: TextField(
controller: c,
maxLines: 3,
decoration:
const InputDecoration(hintText: '请填写终止原因')),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('取消')),
FilledButton(
onPressed: () =>
Navigator.pop(ctx, c.text.trim().isNotEmpty),
child: const Text('确认'))
]));
if (ok == true)
await _act(
'已终止',
() => widget.api.post(
'/bid-cases/$id/terminate', {'comment': c.text.trim()}));
},
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 Scaffold(
backgroundColor: kPaper,
appBar: AppBar(
title: Text(widget.title, maxLines: 1, overflow: TextOverflow.ellipsis),
actions: [
IconButton(
tooltip: '转发给同事',
onPressed: _loading ? null : _forward,
icon: const Icon(Icons.forward_to_inbox_outlined),
),
],
),
body: _loading
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.fromLTRB(0, 12, 0, 32),
children: [
if (_err.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Text(_err,
style: const TextStyle(color: kMute, fontSize: 12)),
),
CellGroup(
children: [
for (var i = 0; i < pairs.length; i++)
_kv(pairs[i].$1, pairs[i].$2, i == pairs.length - 1),
],
),
if (_actions.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Wrap(spacing: 8, runSpacing: 8, children: _actions),
),
],
),
);
}
Widget _kv(String k, String v, bool last) {
final isStatus = k == '状态';
return Cell(
title: k,
subtitle: isStatus ? null : v,
trailing:
isStatus ? StatusDot(widget.seed['status'] ?? _row['status']) : null,
showLine: !last,
);
}
}
final _uuid = RegExp(
r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$');
const _skip = {
'id',
'tenantId',
'applicantId',
'assigneeId',
'createdById',
'decidedById',
'managerId',
'employeeId',
'bizId',
'path',
'bucket',
'_fetch',
'parentId',
'departmentId',
'userId',
'roleIds',
'managerIds',
'accounts',
'users',
'excerpt',
'legalEntityId',
'bidCaseId',
'partyId',
'fingerprint',
'meta',
'actionLogs',
'projects',
'reviewers',
'assignees',
'versions',
'files',
'logs',
};
bool _skipKey(String k, String key, dynamic v) {
if (_skip.contains(k) || _skip.contains(key)) return true;
if (key.startsWith('can') && (v is bool || v == 'true' || v == 'false'))
return true;
if (key.endsWith('Id') || key.endsWith('Ids')) return true;
return false;
}
List<(String, String)> flattenRecord(Map<String, dynamic> row) {
final out = <(String, String)>[];
void add(String k, dynamic v, [int depth = 0]) {
if (v == null) return;
final key = k.contains('.') ? k.split('.').last : k;
if (_skipKey(k, key, v)) return;
if (k == 'content' && '$v'.contains('<')) return;
final label = fieldLabel(k);
if (label.isEmpty) return;
if (v is Map) {
final name =
v['displayName'] ?? v['name'] ?? v['title'] ?? v['departmentName'];
if (name != null &&
'$name'.trim().isNotEmpty &&
!'$name'.startsWith('{')) {
out.add((label, zh(name)));
return;
}
if (depth > 0) return;
v.forEach((ck, cv) => add('$k.$ck', cv, depth + 1));
return;
}
if (v is List) {
if (v.isEmpty) return;
out.add((label, '${v.length}'));
return;
}
final raw = '$v'.trim();
if (raw.isEmpty || raw == 'null') return;
if (raw.startsWith('{') && raw.contains('id:')) return;
if (_uuid.hasMatch(raw)) return;
final s = v is DateTime
? fmtTime(v.toIso8601String())
: (k.endsWith('At') || k.contains('Time') ? fmtTime(v) : zh(v));
if (s == '' || s.isEmpty) return;
if (RegExp(r'^[A-Z][A-Z0-9_]+$').hasMatch(s) && zh(s) == s) return;
out.add((label, s));
}
row.forEach(add);
return out;
}
+152
View File
@@ -0,0 +1,152 @@
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import '../api/oa_client.dart';
import '../theme.dart';
class ScanLoginPage extends StatefulWidget {
const ScanLoginPage({super.key, required this.api, this.ticket = ''});
final OaClient api;
final String ticket;
@override
State<ScanLoginPage> createState() => _ScanLoginPageState();
}
class _ScanLoginPageState extends State<ScanLoginPage> {
late final TextEditingController _ticket;
final MobileScannerController _scanner = MobileScannerController();
bool _loading = false;
bool _scanned = false;
String _err = '';
String _code = '';
@override
void initState() {
super.initState();
_ticket = TextEditingController(text: widget.ticket);
}
@override
void dispose() {
_ticket.dispose();
_scanner.dispose();
super.dispose();
}
String _ticketFrom(String raw) {
final value = raw.trim();
final uri = Uri.tryParse(value);
if (uri?.scheme == 'fengyingoa' && uri?.host == 'qr') {
return uri?.queryParameters['ticket']?.trim() ?? '';
}
return value;
}
Future<void> _ok([String? scanned]) async {
final t = _ticketFrom(scanned ?? _ticket.text);
if (t.isEmpty) {
setState(() => _err = '请扫描电脑端二维码');
return;
}
setState(() {
_loading = true;
_err = '';
});
try {
final raw = await widget.api.post('/auth/qr/confirm', {'ticket': t});
final map =
raw is Map ? Map<String, dynamic>.from(raw) : <String, dynamic>{};
if (!mounted) return;
setState(() {
_ticket.text = t;
_code = '${map['code'] ?? ''}';
_scanned = true;
});
await _scanner.stop();
} catch (e) {
if (mounted) setState(() => _err = '$e');
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _onDetect(BarcodeCapture capture) async {
if (_loading || _scanned) return;
final raw = capture.barcodes
.map((b) => b.rawValue?.trim() ?? '')
.firstWhere((v) => v.isNotEmpty, orElse: () => '');
if (raw.isEmpty) return;
await _ok(raw);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(title: const Text('扫一扫登录电脑')),
body: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_code.isEmpty) ...[
const Text('扫描电脑端二维码后,手机会生成一次性六位登录码。请把该六码填写到电脑端,电脑才会完成登录。',
style: TextStyle(color: kMute, height: 1.5)),
const SizedBox(height: 16),
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: AspectRatio(
aspectRatio: 1,
child:
MobileScanner(controller: _scanner, onDetect: _onDetect),
),
),
] else ...[
const Text('请在电脑端输入以下一次性六位登录码:',
style: TextStyle(color: kMute, height: 1.5)),
const SizedBox(height: 24),
Center(
child: SelectableText(
_code,
style: const TextStyle(
fontSize: 42,
fontWeight: FontWeight.w700,
letterSpacing: 12,
color: kInk),
),
),
const SizedBox(height: 12),
const Center(
child: Text('两分钟内有效;不要把此码发给他人。',
style: TextStyle(color: kMute))),
const SizedBox(height: 24),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('我已在电脑端输入'),
),
],
const SizedBox(height: 16),
if (_code.isEmpty)
TextField(
controller: _ticket,
decoration: const InputDecoration(hintText: '扫码异常时,可粘贴二维码内容'),
textCapitalization: TextCapitalization.characters,
),
if (_err.isNotEmpty) ...[
const SizedBox(height: 12),
Text(_err, style: const TextStyle(color: kDanger)),
],
if (_code.isEmpty) ...[
const SizedBox(height: 20),
FilledButton(
onPressed: _loading ? null : _ok,
child: Text(_loading ? '确认中…' : '确认扫码'),
),
],
],
),
),
);
}
}
@@ -0,0 +1,162 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../im/chat_local_store.dart';
import '../nav/notice_route.dart';
import '../session/session.dart';
import '../theme.dart';
import '../widgets/wecom.dart';
class SystemNoticePage extends StatefulWidget {
const SystemNoticePage(
{super.key,
required this.session,
required this.api,
this.conversationId});
final SessionStore session;
final OaClient api;
final String? conversationId;
@override
State<SystemNoticePage> createState() => _SystemNoticePageState();
}
class _SystemNoticePageState extends State<SystemNoticePage> {
List<Map<String, dynamic>> _items = [];
bool _loading = true;
Map<String, dynamic> _metaOf(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 decoded = jsonDecode(raw);
if (decoded is Map) return Map<String, dynamic>.from(decoded);
} catch (_) {}
}
return {};
}
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final raw = await widget.api.get('/im/messages', query: {
'peerId': '0',
if (widget.conversationId != null)
'conversationId': widget.conversationId!,
});
final remote = asMaps(raw);
final cid = widget.conversationId ??
(raw is Map ? '${raw['conversationId'] ?? ''}' : '');
final local = await ChatLocalStore.load(cid);
final byKey = <String, Map<String, dynamic>>{};
for (final e in [...local, ...remote]) {
final fp = '${e['fingerprint'] ?? ''}';
byKey[fp.isNotEmpty ? 'fp:$fp' : 'id:${e['id'] ?? ''}'] = e;
}
final merged = byKey.values.toList()
..sort((a, b) =>
'${a['createdAt'] ?? ''}'.compareTo('${b['createdAt'] ?? ''}'));
if (!mounted) return;
setState(() {
_items = merged;
_loading = false;
});
await ChatLocalStore.save(cid, merged);
if (widget.conversationId != null) {
await widget.api
.post('/im/read', {'conversationId': widget.conversationId});
}
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F6F8),
appBar: AppBar(title: const Text('系统通知'), backgroundColor: Colors.white),
body: _loading
? const Center(child: CircularProgressIndicator())
: RefreshIndicator(
onRefresh: _load,
child: ListView.builder(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 28),
itemCount: _items.isEmpty ? 1 : _items.length,
itemBuilder: (context, index) {
if (_items.isEmpty)
return const Padding(
padding: EdgeInsets.only(top: 100),
child: Center(
child: Text('暂无系统通知',
style: TextStyle(color: kMute))));
final r = _items[index];
final title = '${r['title'] ?? r['body'] ?? '系统通知'}';
final body = '${r['body'] ?? ''}';
final time = '${r['createdAt'] ?? ''}'
.replaceFirst('T', ' ')
.replaceFirst(RegExp(r'\.\d+Z$'), '');
return Card(
elevation: 0,
margin: const EdgeInsets.only(bottom: 10),
child: InkWell(
onTap: () {
final meta = _metaOf(r);
openNoticeTarget(
context,
widget.api,
session: widget.session,
kind: '${meta['kind'] ?? r['kind'] ?? 'todo'}',
bizType: '${meta['bizType'] ?? ''}',
bizId: '${meta['bizId'] ?? ''}',
title:
'${meta['title'] ?? r['title'] ?? r['body'] ?? '系统通知'}',
);
},
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SquareAvatar(
label: '',
color: Color(0xFF07C160),
icon: Icons.notifications,
size: 40),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600)),
if (body.isNotEmpty && body != title) ...[
const SizedBox(height: 5),
Text(body,
style: const TextStyle(color: kMute))
],
const SizedBox(height: 7),
Text(time,
style: const TextStyle(
fontSize: 12, color: kMute)),
])),
]),
),
),
);
},
),
),
);
}
}
+91
View File
@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../labels.dart';
import '../pages/record_detail_page.dart';
import '../widgets/common.dart';
class TodosPage extends StatefulWidget {
const TodosPage({super.key, required this.api, this.embedded = false});
final OaClient api;
final bool embedded;
@override
State<TodosPage> createState() => _TodosPageState();
}
class _TodosPageState extends State<TodosPage> {
List<Map<String, dynamic>> _items = [];
String _bucket = 'OPEN';
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final data = await widget.api.get('/office/todos');
if (!mounted) return;
setState(() {
_items = asMaps(data);
_loading = false;
});
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
final filtered = _bucket.isEmpty ? _items : _items.where((e) => '${e['status']}' == _bucket).toList();
final body = Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Row(
children: [
for (final it in [('OPEN', '待办'), ('DONE', '已办'), ('', '全部')])
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(it.$2),
selected: _bucket == it.$1,
showCheckmark: false,
visualDensity: VisualDensity.compact,
onSelected: (_) => setState(() => _bucket = it.$1),
),
),
],
),
),
if (_loading) const LinearProgressIndicator(minHeight: 2),
Expanded(
child: filtered.isEmpty
? const EmptyHint('没有待办')
: RefreshIndicator(
onRefresh: _load,
child: ListView.builder(
itemCount: filtered.length,
itemBuilder: (_, i) {
final r = filtered[i];
return KvTile(
title: '${r['title'] ?? ''}',
subtitle: '${zh(r['bizType'])} ${fmtTime(r['dueAt'] ?? r['createdAt'])}',
status: r['status'],
onTap: () => openRecord(context, widget.api, r),
);
},
),
),
),
],
);
if (widget.embedded) return body;
return Scaffold(appBar: AppBar(title: const Text('待办事项')), body: body);
}
}
+125
View File
@@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../session/session.dart';
import '../widgets/common.dart';
import 'record_detail_page.dart';
class WorkAssignPage extends StatefulWidget {
const WorkAssignPage({super.key, required this.session, required this.api});
final SessionStore session;
final OaClient api;
@override
State<WorkAssignPage> createState() => _WorkAssignPageState();
}
class _WorkAssignPageState extends State<WorkAssignPage> {
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 {
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: 420,
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,
});
await _load();
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e')));
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('工作安排'),
actions: [
if (_canAssign) IconButton(onPressed: _create, icon: const Icon(Icons.add)),
],
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _items.isEmpty
? const EmptyHint('没有工作安排')
: RefreshIndicator(
onRefresh: _load,
child: ListView(
children: [
for (final r in _items)
KvTile(
title: '${r['title'] ?? ''}',
subtitle: '${r['assignee'] is Map ? r['assignee']['displayName'] : ''} ${fmtTime(r['dueAt'] ?? r['createdAt'])}',
status: r['status'],
onTap: () => openRecord(context, widget.api, r),
),
],
),
),
);
}
}
+281
View File
@@ -0,0 +1,281 @@
import 'package:flutter/material.dart';
import '../api/oa_client.dart';
import '../nav/open_module.dart';
import '../ota/updater.dart';
import '../pages/approvals_page.dart';
import '../pages/attendance_page.dart';
import '../pages/calendar_page.dart';
import '../pages/flow_page.dart';
import '../pages/hr_apply_page.dart';
import '../pages/todos_page.dart';
import '../pages/work_assign_page.dart';
import '../session/session.dart';
import '../theme.dart';
import '../widgets/wecom.dart';
class WorkbenchPage extends StatefulWidget {
const WorkbenchPage({super.key, required this.session, required this.api, this.embedded = true});
final SessionStore session;
final OaClient api;
final bool embedded;
@override
State<WorkbenchPage> createState() => _WorkbenchPageState();
}
class _WorkbenchPageState extends State<WorkbenchPage> {
Map<String, dynamic> _ov = {};
String _greet = '';
AppRelease? _rel;
bool _loading = true;
String _q = '';
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final ov = await widget.api.get('/office/overview');
String greet = '';
try {
final w = await widget.api.get('/office/weather');
if (w is Map) greet = '${w['greeting'] ?? w['text'] ?? ''}';
} catch (_) {}
AppRelease? rel;
try {
rel = await OtaUpdater(widget.api).fetch();
} catch (_) {}
if (!mounted) return;
setState(() {
_ov = Map<String, dynamic>.from(ov as Map? ?? {});
_greet = greet;
_rel = rel;
_loading = false;
});
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
bool _match(String name) => _q.isEmpty || name.contains(_q);
bool _skipGroup(MenuNode m) {
if (m.name == '工作台' || m.name == '个人办公' || m.code == 'office:overview') return true;
if (m.name == '系统设置' || m.code.startsWith('system')) return true;
return false;
}
bool _skipChild(MenuNode n) {
const names = {
'待办',
'待审批',
'我的申请',
'日程',
'公告',
'公司公告',
'工作安排',
'人事申请',
'工作台',
'个人办公',
'消息',
'通讯录',
'员工通讯',
'发送短信',
'人事设置',
'数据字典',
'权限设置',
'编号规则',
'短信服务',
'消息通道',
'手机端升级',
'操作日志',
'系统设置',
};
if (n.code.startsWith('office:')) return true;
if (n.code.startsWith('system')) return true;
if (n.code.contains('sms') || n.name.contains('短信')) return true;
if (n.name.contains('公告') || n.name == '员工通讯') return true;
return names.contains(n.name);
}
@override
Widget build(BuildContext context) {
final body = RefreshIndicator(
onRefresh: _load,
child: ListView(
padding: const EdgeInsets.only(bottom: 24),
children: [
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kBind),
if (_rel != null && _rel!.newer)
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
child: Material(
color: const Color(0xFFE8F3FF),
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: () => OtaUpdater(widget.api).prompt(context, _rel!, manual: true),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
child: Row(
children: [
const Icon(Icons.system_update_alt, color: kBind),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('发现新版本 ${_rel!.version}', style: const TextStyle(fontWeight: FontWeight.w600, color: kInk)),
Text(_rel!.changelog.isEmpty ? '点击升级到最新手机版' : _rel!.changelog, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: kMute)),
],
),
),
const Text('升级', style: TextStyle(color: kBind, fontWeight: FontWeight.w600)),
],
),
),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(_greet.isEmpty ? '你好,${widget.session.displayName}' : _greet, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: kInk)),
const SizedBox(height: 4),
const Text('工作台', style: TextStyle(color: kMute, fontSize: 13)),
],
),
),
GroupCard(
title: '待处理',
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 14),
child: Row(
children: [
_stat('待办', '${_ov['pendingTodos'] ?? 0}', () => pushPage(context, TodosPage(api: widget.api))),
_stat('待审批', '${_ov['pendingApprovals'] ?? 0}', () => pushPage(context, ApprovalsPage(api: widget.api))),
_stat('我的申请', '${_ov['myPending'] ?? 0}', () => pushPage(context, FlowPage(api: widget.api))),
_stat('逾期', '${_ov['overdueTodos'] ?? 0}', () => pushPage(context, TodosPage(api: widget.api))),
],
),
),
),
if (_match('待办') || _match('审批') || _match('申请') || _match('日程') || _match('人事'))
GroupCard(
title: '个人办公',
child: AppGrid(
items: [
if (_match('待办'))
AppGridItem(label: '待办', icon: Icons.task_alt, color: const Color(0xFFFA9D3B), onTap: () => pushPage(context, TodosPage(api: widget.api))),
if (_match('审批'))
AppGridItem(label: '待审批', icon: Icons.fact_check, color: kBind, onTap: () => pushPage(context, ApprovalsPage(api: widget.api))),
if (_match('申请'))
AppGridItem(label: '我的申请', icon: Icons.assignment_outlined, color: const Color(0xFF6267F2), onTap: () => pushPage(context, FlowPage(api: widget.api))),
if (_match('日程'))
AppGridItem(label: '日程', icon: Icons.calendar_month, color: const Color(0xFF10AEFF), onTap: () => pushPage(context, CalendarPage(api: widget.api))),
if (_match('安排'))
AppGridItem(label: '工作安排', icon: Icons.event_note, color: const Color(0xFF00B578), onTap: () => pushPage(context, WorkAssignPage(session: widget.session, api: widget.api))),
if (_match('打卡') || _match('考勤'))
AppGridItem(label: '考勤打卡', icon: Icons.access_time_filled, color: const Color(0xFF267EF0), onTap: () => pushPage(context, AttendancePage(api: widget.api))),
if (_match('人事') || _match('请假') || _match('加班'))
AppGridItem(label: '人事申请', icon: Icons.beach_access, color: const Color(0xFF8B5CF6), onTap: () => pushPage(context, HrApplyPage(api: widget.api))),
],
),
),
..._menuGroups(),
],
),
);
if (!widget.embedded) {
return Scaffold(appBar: AppBar(title: const Text('工作台')), body: body);
}
return ColoredBox(
color: kPaper,
child: Column(
children: [
WxHeader(
title: '工作台',
actions: [
IconButton(
onPressed: () async {
final c = TextEditingController(text: _q);
final v = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('搜索应用'),
content: TextField(controller: c, autofocus: true, decoration: const InputDecoration(hintText: '待办、投标、报销…')),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, ''), child: const Text('清除')),
FilledButton(onPressed: () => Navigator.pop(ctx, c.text.trim()), child: const Text('搜索')),
],
),
);
if (v != null) setState(() => _q = v);
},
icon: const Icon(Icons.search, color: kInk),
),
],
),
Expanded(child: body),
],
),
);
}
List<Widget> _menuGroups() {
final out = <Widget>[];
for (final m in widget.session.menus) {
if (_skipGroup(m)) continue;
if (m.children.isNotEmpty) {
final items = <AppGridItem>[];
for (var i = 0; i < m.children.length; i++) {
final c = m.children[i];
if (_skipChild(c)) continue;
if (!(_q.isEmpty || c.name.contains(_q) || m.name.contains(_q))) continue;
items.add(AppGridItem(
label: c.name,
icon: iconFor(c.code, c.name),
color: colorFor(c.code, c.name, i),
onTap: () => openMenuNode(context, widget.session, widget.api, c),
));
}
if (items.isEmpty) continue;
out.add(GroupCard(title: m.name, child: AppGrid(items: items)));
} else if (!_skipChild(m) && _match(m.name)) {
out.add(GroupCard(
title: m.name,
child: AppGrid(items: [
AppGridItem(
label: m.name,
icon: iconFor(m.code, m.name),
color: colorFor(m.code, m.name),
onTap: () => openMenuNode(context, widget.session, widget.api, m),
),
]),
));
}
}
return out;
}
Widget _stat(String label, String value, VoidCallback onTap) {
return Expanded(
child: InkWell(
onTap: onTap,
child: Column(
children: [
Text(value, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: kBind)),
const SizedBox(height: 2),
Text(label, style: const TextStyle(fontSize: 12, color: kMute)),
],
),
),
);
}
}