Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,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();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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'] ?? '日程'}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'}),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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))),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'] ?? '公告'}',
|
||||
),
|
||||
)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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))),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)),
|
||||
])),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user