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,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)));
|
||||
}
|
||||
Reference in New Issue
Block a user