76f266645d
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
239 lines
9.4 KiB
Dart
239 lines
9.4 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../../api/oa_client.dart';
|
|
import '../../device/device_bridge.dart';
|
|
import '../desk_theme.dart';
|
|
import '../widgets/desk_widgets.dart';
|
|
|
|
class DeskAttendancePage extends StatefulWidget {
|
|
const DeskAttendancePage({super.key, required this.api});
|
|
final OaClient api;
|
|
|
|
@override
|
|
State<DeskAttendancePage> createState() => _DeskAttendancePageState();
|
|
}
|
|
|
|
class _DeskAttendancePageState extends State<DeskAttendancePage> {
|
|
Map<String, dynamic> _status = {};
|
|
Map<String, double>? _location;
|
|
bool _loading = true;
|
|
bool _sending = false;
|
|
String _mode = 'OFFICE';
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_load();
|
|
}
|
|
|
|
Future<void> _load() async {
|
|
setState(() => _loading = true);
|
|
try {
|
|
_location = await DeviceBridge.getLocation();
|
|
final q = <String, String>{
|
|
if (_location != null) 'lat': '${_location!['lat']}',
|
|
if (_location != null) 'lng': '${_location!['lng']}',
|
|
'mode': _mode,
|
|
};
|
|
final data = Map<String, dynamic>.from(await widget.api.get('/attendance/punch-status', query: q) as Map);
|
|
if (mounted) setState(() {
|
|
_status = data;
|
|
_loading = false;
|
|
});
|
|
if (mounted && _location == null && _mode == 'OFFICE') {
|
|
deskToast(context, '未获取到定位,请允许位置权限后点刷新', error: true);
|
|
}
|
|
} catch (_) {
|
|
if (mounted) setState(() => _loading = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _punch(String kind) async {
|
|
if (_mode == 'OFFICE' && _status['tripActive'] == true) return;
|
|
if (_mode == 'FIELD' && _status['fieldApproved'] != true) return;
|
|
setState(() => _sending = true);
|
|
try {
|
|
await widget.api.post('/attendance/punch', {'kind': kind, 'mode': _mode, if (_location != null) ..._location!});
|
|
if (mounted) deskToast(context, '打卡成功');
|
|
await _load();
|
|
} catch (e) {
|
|
if (mounted) deskToast(context, '$e', error: true);
|
|
} finally {
|
|
if (mounted) setState(() => _sending = false);
|
|
}
|
|
}
|
|
|
|
String _time(dynamic value) {
|
|
if (value == null || '$value' == 'null' || '$value'.isEmpty) return '未打卡';
|
|
final d = DateTime.tryParse('$value')?.toLocal();
|
|
return d == null ? '$value' : '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}';
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final inKey = _mode == 'FIELD' ? 'fieldIn' : 'clockIn';
|
|
final outKey = _mode == 'FIELD' ? 'fieldOut' : 'clockOut';
|
|
final inAt = _time(_status[inKey]);
|
|
final outAt = _time(_status[outKey]);
|
|
final hasIn = inAt != '未打卡';
|
|
final hasOut = outAt != '未打卡';
|
|
final trip = _status['tripActive'] == true;
|
|
final fieldOk = _status['fieldApproved'] == true;
|
|
final canPunch = _mode == 'FIELD' ? fieldOk : !trip && _status['inRange'] != false;
|
|
|
|
return ColoredBox(
|
|
color: kDeskBg,
|
|
child: Column(
|
|
children: [
|
|
DeskPaneHeader(
|
|
title: '考勤打卡',
|
|
actions: [IconButton(onPressed: _load, icon: const Icon(Icons.refresh, size: 20))],
|
|
),
|
|
if (_loading) const LinearProgressIndicator(minHeight: 2, color: kDeskBind),
|
|
Expanded(
|
|
child: Center(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 520),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
children: [
|
|
SegmentedButton<String>(
|
|
segments: const [
|
|
ButtonSegment(value: 'OFFICE', label: Text('上下班打卡'), icon: Icon(Icons.access_time)),
|
|
ButtonSegment(value: 'FIELD', label: Text('外勤打卡'), icon: Icon(Icons.location_on_outlined)),
|
|
],
|
|
selected: {_mode},
|
|
onSelectionChanged: (v) async {
|
|
setState(() => _mode = v.first);
|
|
await _load();
|
|
},
|
|
),
|
|
const SizedBox(height: 20),
|
|
Material(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
children: [
|
|
Text(_today(), style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700)),
|
|
const SizedBox(height: 12),
|
|
_banner(canPunch, trip, fieldOk),
|
|
const SizedBox(height: 20),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
_punchBtn(
|
|
enabled: canPunch && !hasIn && !_sending,
|
|
label: _mode == 'FIELD' ? '外勤上班' : '上班打卡',
|
|
onTap: () => _punch('IN'),
|
|
),
|
|
const SizedBox(width: 16),
|
|
_punchBtn(
|
|
enabled: canPunch && hasIn && !hasOut && !_sending,
|
|
label: '下班打卡',
|
|
color: kDeskGreen,
|
|
onTap: () => _punch('OUT'),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 20),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
children: [
|
|
_timeBox('上班', inAt, _status['late'] == true),
|
|
_timeBox('下班', outAt, _status['early'] == true),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Material(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: ListTile(
|
|
leading: Icon(Icons.place, color: _status['inRange'] == true ? kDeskGreen : const Color(0xFFFA5151)),
|
|
title: Text('${_status['rangeText'] ?? '正在获取考勤范围'}'),
|
|
subtitle: Text(_status['distanceMeters'] == null ? '请开启定位后刷新' : '距离考勤点约 ${_status['distanceMeters']} 米'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String _today() {
|
|
final d = DateTime.now();
|
|
return '${d.year}年${d.month}月${d.day}日';
|
|
}
|
|
|
|
Widget _banner(bool canPunch, bool trip, bool fieldOk) {
|
|
final text = _mode == 'FIELD'
|
|
? (fieldOk ? '外出申请已通过,可进行外勤打卡' : '需先申请并审批通过外出')
|
|
: (trip ? '出差期间无需打卡' : (canPunch ? '当前可进行上下班打卡' : '未进入考勤范围'));
|
|
final color = trip || fieldOk || canPunch ? kDeskGreen : const Color(0xFFFA9D3B);
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(10),
|
|
decoration: BoxDecoration(color: color.withValues(alpha: .1), borderRadius: BorderRadius.circular(8)),
|
|
child: Text(text, textAlign: TextAlign.center, style: TextStyle(color: color, fontWeight: FontWeight.w600, fontSize: 13)),
|
|
);
|
|
}
|
|
|
|
Widget _punchBtn({required bool enabled, required String label, required VoidCallback onTap, Color? color}) {
|
|
final c = color ?? kDeskBind;
|
|
return SizedBox(
|
|
width: 140,
|
|
height: 140,
|
|
child: Material(
|
|
color: enabled ? c : const Color(0xFFE8E8E8),
|
|
shape: const CircleBorder(),
|
|
child: InkWell(
|
|
onTap: enabled ? onTap : null,
|
|
customBorder: const CircleBorder(),
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(label, style: TextStyle(color: enabled ? Colors.white : kDeskMute, fontWeight: FontWeight.w600)),
|
|
const SizedBox(height: 4),
|
|
Text(_nowTime(), style: TextStyle(color: enabled ? Colors.white : kDeskMute, fontSize: 18, fontWeight: FontWeight.w700)),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _timeBox(String label, String value, bool flag) {
|
|
return Column(
|
|
children: [
|
|
Text(label, style: const TextStyle(color: kDeskMute, fontSize: 12)),
|
|
const SizedBox(height: 4),
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(value, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
|
|
if (flag) const Padding(padding: EdgeInsets.only(left: 4), child: Icon(Icons.error, color: Color(0xFFFA9D3B), size: 16)),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
String _nowTime() {
|
|
final d = DateTime.now();
|
|
return '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}';
|
|
}
|
|
}
|