feat: add Flutter mobile clients and staff delivery API

This commit is contained in:
david
2026-07-30 21:47:41 +08:00
parent 550efb3812
commit 36a5ced1c0
228 changed files with 17159 additions and 22 deletions

View File

@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
class AppTheme {
static ThemeData light() {
final scheme = ColorScheme.fromSeed(
seedColor: const Color(0xFF6C4CF1),
primary: const Color(0xFF6C4CF1),
secondary: const Color(0xFF16A66A),
surface: Colors.white,
);
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
scaffoldBackgroundColor: const Color(0xFFF5F4FA),
cardTheme: CardThemeData(
elevation: 0,
color: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
),
);
}
}

View File

@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import '../../../app/dependencies.dart';
class LoginPage extends StatefulWidget {
const LoginPage({required this.session, super.key});
final StaffSession session;
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final _phone = TextEditingController();
final _password = TextEditingController();
bool _busy = false;
String? _error;
Future<void> _login() async {
setState(() {
_busy = true;
_error = null;
});
try {
await widget.session.login(_phone.text.trim(), _password.text);
} catch (error) {
if (mounted) setState(() => _error = error.toString());
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
void dispose() {
_phone.dispose();
_password.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Icon(
Icons.engineering_rounded,
size: 72,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 20),
Text(
'瓶安芯服务工作台',
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 8),
const Text('配送、安装维修与安检共用入口', textAlign: TextAlign.center),
const SizedBox(height: 34),
TextField(
controller: _phone,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(
labelText: '工作人员手机号',
prefixIcon: Icon(Icons.phone_outlined),
),
),
const SizedBox(height: 14),
TextField(
controller: _password,
obscureText: true,
decoration: const InputDecoration(
labelText: '登录密码',
prefixIcon: Icon(Icons.lock_outline),
),
),
if (_error != null)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: _busy ? null : _login,
child: _busy
? const SizedBox.square(
dimension: 22,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('进入工作台'),
),
const SizedBox(height: 12),
const Text(
'账号与角色由平台审核分配,不提供自助注册',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12),
),
],
),
),
),
),
),
);
}

View File

@@ -0,0 +1,225 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:uuid/uuid.dart';
import '../../../app/dependencies.dart';
import '../../../data/offline/encrypted_draft_store.dart';
import '../../../data/repositories/service_repository.dart';
class EvidencePage extends StatefulWidget {
const EvidencePage({
required this.session,
required this.repository,
required this.drafts,
required this.taskIdentity,
super.key,
});
final StaffSession session;
final ServiceRepository repository;
final EncryptedDraftStore drafts;
final String taskIdentity;
@override
State<EvidencePage> createState() => _EvidencePageState();
}
class _EvidencePageState extends State<EvidencePage> {
final _picker = ImagePicker();
final _result = TextEditingController();
final Map<String, Map<String, Object?>> _evidence = {};
bool _busy = false;
String _conclusion = 'qualified';
List<String> get _requiredStages => widget.session.roleCode == 'operations'
? const ['inspection', 'signature']
: const ['before', 'during', 'after', 'signature'];
@override
void initState() {
super.initState();
_restore();
}
Future<void> _restore() async {
final draft = await widget.drafts.readDraft(widget.session.identity, widget.taskIdentity);
if (draft == null || !mounted) return;
final values = draft['evidence'];
if (values is List) {
for (final value in values.whereType<Map<Object?, Object?>>()) {
final mapped = value.map<String, Object?>((key, item) => MapEntry(key.toString(), item));
final stage = mapped['stage'] as String? ?? '';
if (stage.isNotEmpty) _evidence[stage] = mapped;
}
}
_result.text = draft['result'] as String? ?? '';
_conclusion = draft['conclusion'] as String? ?? 'qualified';
setState(() {});
}
Future<void> _capture(String stage) async {
final image = await _picker.pickImage(
source: ImageSource.camera,
imageQuality: 82,
maxWidth: 1800,
);
if (image == null) return;
final sealedName = await widget.drafts.sealAttachment(
accountIdentity: widget.session.identity,
taskIdentity: widget.taskIdentity,
stage: stage,
sourcePath: image.path,
);
_evidence[stage] = {
'stage': stage,
'sealed_name': sealedName,
'captured_at': DateTime.now().toUtc().toIso8601String(),
'request_no': const Uuid().v7(),
'media_type': stage == 'signature' ? 'signature' : 'image',
};
await _save();
if (mounted) setState(() {});
}
Future<void> _save() => widget.drafts.saveDraft(
accountIdentity: widget.session.identity,
taskIdentity: widget.taskIdentity,
value: {
'task_identity': widget.taskIdentity,
'result': _result.text,
'conclusion': _conclusion,
'updated_at': DateTime.now().toUtc().toIso8601String(),
'evidence': _evidence.values.toList(),
},
);
Future<void> _submit() async {
if (!_requiredStages.every(_evidence.containsKey) || _result.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请完成结果说明和全部必需取证项')));
return;
}
setState(() => _busy = true);
final temporaryFiles = <String>[];
try {
await _save();
final inputs = <EvidenceInput>[];
for (final item in _evidence.values) {
final path = await widget.drafts.materializeAttachment(
accountIdentity: widget.session.identity,
sealedName: item['sealed_name'] as String,
);
temporaryFiles.add(path);
inputs.add(
EvidenceInput(
evidenceType: item['stage'] as String,
mediaType: item['media_type'] as String,
filePath: path,
capturedAt: DateTime.parse(item['captured_at'] as String),
requestNo: item['request_no'] as String,
),
);
}
await widget.repository.submitTicketResult(
identity: widget.taskIdentity,
result: _result.text.trim(),
conclusion: _conclusion,
evidence: inputs,
);
await widget.drafts.deleteDraft(widget.session.identity, widget.taskIdentity);
if (mounted) Navigator.pop(context, true);
} catch (error) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('提交失败,草稿仍安全保留:$error')));
}
} finally {
for (final path in temporaryFiles) {
final file = File(path);
if (file.existsSync()) await file.delete();
}
if (mounted) setState(() => _busy = false);
}
}
@override
void dispose() {
_result.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('现场取证')),
body: ListView(
padding: const EdgeInsets.all(18),
children: [
const Card(
child: Padding(
padding: EdgeInsets.all(18),
child: Row(
children: [
Icon(Icons.lock_outline),
SizedBox(width: 12),
Expanded(child: Text('照片与签名先按当前账号加密暂存;上传成功前仅显示“已暂存”。')),
],
),
),
),
const SizedBox(height: 10),
..._requiredStages.map(
(stage) => Card(
child: ListTile(
leading: Icon(
_evidence.containsKey(stage) ? Icons.check_circle : Icons.camera_alt_outlined,
color: _evidence.containsKey(stage) ? Colors.green : null,
),
title: Text(_stageName(stage)),
subtitle: Text(_evidence.containsKey(stage) ? '已加密暂存' : '尚未采集'),
trailing: TextButton(
onPressed: _busy ? null : () => _capture(stage),
child: Text(_evidence.containsKey(stage) ? '重拍' : '拍摄'),
),
),
),
),
const SizedBox(height: 12),
TextField(
controller: _result,
maxLines: 4,
onChanged: (_) => _save(),
decoration: const InputDecoration(labelText: '现场结果说明'),
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: _conclusion,
decoration: const InputDecoration(labelText: '结论'),
items: const [
DropdownMenuItem(value: 'qualified', child: Text('合格')),
DropdownMenuItem(value: 'noncompliant', child: Text('不合格')),
DropdownMenuItem(value: 'high_risk', child: Text('高风险')),
],
onChanged: (value) {
if (value == null) return;
setState(() => _conclusion = value);
_save();
},
),
const SizedBox(height: 22),
ElevatedButton(
onPressed: _busy ? null : _submit,
child: Text(_busy ? '正在上传并等待服务端确认…' : '在线提交结果'),
),
],
),
);
String _stageName(String stage) => switch (stage) {
'before' => '作业前照片',
'during' => '作业中照片',
'after' => '作业后照片',
'inspection' => '检查现场照片',
'signature' => '用户签名图片',
_ => stage,
};
}

View File

@@ -0,0 +1,143 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../app/dependencies.dart';
import '../../../data/repositories/service_repository.dart';
import '../../../domain/models/service_models.dart';
class PreflightPage extends StatefulWidget {
const PreflightPage({required this.session, required this.repository, super.key});
final StaffSession session;
final ServiceRepository repository;
@override
State<PreflightPage> createState() => _PreflightPageState();
}
class _PreflightPageState extends State<PreflightPage> {
late Future<PreflightResult> _future;
bool _busy = false;
@override
void initState() {
super.initState();
_future = widget.repository.preflight();
}
Future<void> _attendance(String action) async {
setState(() => _busy = true);
try {
await widget.repository.attendance(action, widget.session.deviceIdentity);
setState(() => _future = widget.repository.preflight());
} catch (error) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error.toString())));
}
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('作业前检查')),
body: FutureBuilder<PreflightResult>(
future: _future,
builder: (context, snapshot) {
if (!snapshot.hasData) {
if (snapshot.hasError) return Center(child: Text(snapshot.error.toString()));
return const Center(child: CircularProgressIndicator());
}
final result = snapshot.data!;
return ListView(
padding: const EdgeInsets.all(18),
children: [
Card(
color: Theme.of(context).colorScheme.primary,
child: Padding(
padding: const EdgeInsets.all(22),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(roleName(result.roleCode), style: const TextStyle(color: Colors.white70)),
const SizedBox(height: 6),
Text(
result.canWork ? '可以开始今日作业' : '仍有前置条件未完成',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w900,
fontSize: 24,
),
),
],
),
),
),
const SizedBox(height: 12),
...result.checks.entries.map((entry) {
final detail = entry.value is Map
? Map<Object?, Object?>.from(entry.value as Map)
: const <Object?, Object?>{};
final status = detail['status']?.toString() ?? 'blocked';
final passed = status == 'passed';
return Card(
child: ListTile(
leading: Icon(
passed
? Icons.check_circle
: status == 'not_configured'
? Icons.info_outline
: Icons.cancel,
color: passed
? Colors.green
: status == 'not_configured'
? Colors.orange
: Colors.red,
),
title: Text(_label(entry.key)),
subtitle: Text(
status == 'not_configured'
? '平台暂未启用,不冒充校验通过'
: passed
? '已通过服务端校验'
: '未通过',
),
),
);
}),
const SizedBox(height: 18),
if (result.workStatus != 'on_duty')
ElevatedButton.icon(
onPressed: _busy ? null : () => _attendance('clock_in'),
icon: const Icon(Icons.location_on),
label: const Text('定位并上班打卡'),
)
else ...[
ElevatedButton(
onPressed: result.canWork ? () => context.go('/work') : null,
child: const Text('进入工作台'),
),
TextButton(
onPressed: _busy ? null : () => _attendance('clock_out'),
child: const Text('下班打卡'),
),
],
],
);
},
),
);
String _label(String value) => switch (value) {
'account' => '账号状态',
'role' => '岗位',
'organization' => '所属组织',
'credential' => '人员资质',
'attendance' => '上班状态',
'daily_training' => '每日培训',
'service_area' => '服务区域',
'authorized_device' => '授权设备',
_ => value,
};
}

View File

@@ -0,0 +1,143 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../app/dependencies.dart';
import '../../../data/offline/encrypted_draft_store.dart';
import '../../../data/repositories/service_repository.dart';
import '../../../domain/models/service_models.dart';
class ProfilePage extends StatefulWidget {
const ProfilePage({
required this.session,
required this.repository,
required this.drafts,
super.key,
});
final StaffSession session;
final ServiceRepository repository;
final EncryptedDraftStore drafts;
@override
State<ProfilePage> createState() => _ProfilePageState();
}
class _ProfilePageState extends State<ProfilePage> {
late Future<(StaffProfile, Map<String, Object?>, int)> _future;
@override
void initState() {
super.initState();
_future = _load();
}
Future<(StaffProfile, Map<String, Object?>, int)> _load() async => (
await widget.repository.profile(),
await widget.repository.wallet(),
await widget.drafts.count(widget.session.identity),
);
Future<void> _logout(int draftCount) async {
if (draftCount > 0) {
final discard = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('仍有未同步现场草稿'),
content: Text('当前账号有 $draftCount 份加密草稿。建议返回任务页完成上传;若确认放弃,将安全删除草稿和附件。'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('返回上传')),
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('放弃并删除')),
],
),
);
if (discard != true) return;
await widget.drafts.discardAccount(widget.session.identity);
}
await widget.session.logout();
if (mounted) context.go('/login');
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('我的工作台')),
body: FutureBuilder<(StaffProfile, Map<String, Object?>, int)>(
future: _future,
builder: (context, snapshot) {
if (!snapshot.hasData) {
if (snapshot.hasError) return Center(child: Text(snapshot.error.toString()));
return const Center(child: CircularProgressIndicator());
}
final (profile, wallet, drafts) = snapshot.data!;
final balance = (wallet['balance'] as num?)?.toInt() ?? 0;
return ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
CircleAvatar(
radius: 30,
child: Text(profile.name.isEmpty ? '' : profile.name.substring(0, 1)),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
profile.name,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900),
),
Text('${roleName(profile.roleCode)} · ${profile.phone}'),
],
),
),
Chip(label: Text(profile.workStatus == 'on_duty' ? '在岗' : '离岗')),
],
),
),
),
Card(
child: ListTile(
leading: const Icon(Icons.account_balance_wallet_outlined),
title: const Text('钱包余额'),
trailing: Text(
'¥${(balance / 100).toStringAsFixed(2)}',
style: const TextStyle(fontWeight: FontWeight.w900),
),
),
),
Card(
child: ListTile(
leading: const Icon(Icons.lock_outline),
title: const Text('加密现场草稿'),
subtitle: const Text('仅当前账号重新认证后可恢复'),
trailing: Text('$drafts'),
),
),
Card(
child: ListTile(
leading: const Icon(Icons.verified_user_outlined),
title: const Text('重新执行作业前检查'),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.go('/preflight'),
),
),
Card(
child: ListTile(
leading: const Icon(Icons.logout),
title: const Text('退出登录'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _logout(drafts),
),
),
],
);
},
),
);
}

View File

@@ -0,0 +1,290 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:uuid/uuid.dart';
import '../../../app/dependencies.dart';
import '../../../data/offline/encrypted_draft_store.dart';
import '../../../data/repositories/service_repository.dart';
import '../../../domain/models/service_models.dart';
class WorkDetailPage extends StatefulWidget {
const WorkDetailPage({
required this.session,
required this.repository,
required this.drafts,
required this.identity,
super.key,
});
final StaffSession session;
final ServiceRepository repository;
final EncryptedDraftStore drafts;
final String identity;
@override
State<WorkDetailPage> createState() => _WorkDetailPageState();
}
class _WorkDetailPageState extends State<WorkDetailPage> {
late Future<WorkItem> _future;
bool _busy = false;
@override
void initState() {
super.initState();
_future = _load();
}
Future<WorkItem> _load() => widget.session.roleCode == 'delivery'
? widget.repository.deliveryDetail(widget.identity)
: widget.repository.ticketDetail(widget.identity);
Future<void> _run(Future<void> Function(WorkItem) action, WorkItem item) async {
setState(() => _busy = true);
try {
await action(item);
setState(() => _future = _load());
} catch (error) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error.toString())));
}
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<String?> _reason() async {
final controller = TextEditingController();
final value = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('填写原因'),
content: TextField(
controller: controller,
maxLines: 3,
decoration: const InputDecoration(labelText: '原因'),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('取消')),
FilledButton(
onPressed: () => Navigator.pop(context, controller.text.trim()),
child: const Text('确认'),
),
],
),
);
controller.dispose();
return value;
}
Future<void> _receipt(WorkItem item) async {
final existing = await widget.drafts.readDraft(widget.session.identity, item.identity);
if (!mounted) return;
String? sealedName;
if (existing?['kind'] == 'delivery_receipt' && existing?['sealed_name'] is String) {
final reuse = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('发现未提交签收草稿'),
content: const Text('是否继续提交上次加密保存的签收凭证?'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('重新拍摄')),
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('继续提交')),
],
),
);
if (reuse == true) sealedName = existing!['sealed_name'] as String;
}
if (sealedName == null) {
final image = await ImagePicker().pickImage(source: ImageSource.camera, imageQuality: 82);
if (image == null) return;
sealedName = await widget.drafts.sealAttachment(
accountIdentity: widget.session.identity,
taskIdentity: item.identity,
stage: 'receipt',
sourcePath: image.path,
);
}
setState(() => _busy = true);
String? temporaryPath;
try {
await widget.drafts.saveDraft(
accountIdentity: widget.session.identity,
taskIdentity: item.identity,
value: {
'task_identity': item.identity,
'kind': 'delivery_receipt',
'sealed_name': sealedName,
'request_no': const Uuid().v7(),
'captured_at': DateTime.now().toUtc().toIso8601String(),
},
);
temporaryPath = await widget.drafts.materializeAttachment(
accountIdentity: widget.session.identity,
sealedName: sealedName,
);
await widget.repository.submitDeliveryReceipt(
identity: item.identity,
recipientName: item.raw['contact_name'] as String? ?? '收货人',
recipientPhone: item.raw['contact_phone'] as String? ?? '',
proofFile: temporaryPath,
);
await widget.drafts.deleteDraft(widget.session.identity, item.identity);
if (mounted) setState(() => _future = _load());
} catch (error) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('签收提交失败,加密草稿已保留:$error')));
}
} finally {
if (temporaryPath != null) {
final file = File(temporaryPath);
if (file.existsSync()) await file.delete();
}
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('任务详情')),
body: FutureBuilder<WorkItem>(
future: _future,
builder: (context, snapshot) {
if (!snapshot.hasData) {
if (snapshot.hasError) return Center(child: Text(snapshot.error.toString()));
return const Center(child: CircularProgressIndicator());
}
final item = snapshot.data!;
return ListView(
padding: const EdgeInsets.all(18),
children: [
Card(
color: Theme.of(context).colorScheme.primary,
child: Padding(
padding: const EdgeInsets.all(22),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.number, style: const TextStyle(color: Colors.white70)),
const SizedBox(height: 8),
Text(
item.title,
style: const TextStyle(
color: Colors.white,
fontSize: 26,
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 12),
Chip(label: Text(statusName(item.status))),
],
),
),
),
Card(
child: ListTile(
leading: const Icon(Icons.location_on_outlined),
title: const Text('服务地址'),
subtitle: Text(item.address.isEmpty ? '未提供地址' : item.address),
),
),
Card(
child: ListTile(
leading: const Icon(Icons.person_outline),
title: Text(item.raw['contact_name'] as String? ?? '服务用户'),
subtitle: Text(item.raw['contact_phone'] as String? ?? ''),
),
),
const SizedBox(height: 18),
if (item.allowedActions.contains('start'))
ElevatedButton(
onPressed: _busy
? null
: () => _run(
(value) => widget.repository.start(value, widget.session.roleCode),
item,
),
child: const Text('开始处理'),
),
if (item.allowedActions.contains('append_tracks'))
OutlinedButton(
onPressed: _busy
? null
: () => _run(
(value) => widget.repository.appendCurrentTrack(value.identity),
item,
),
child: const Text('上报当前位置'),
),
if (item.allowedActions.contains('arrive'))
ElevatedButton(
onPressed: _busy
? null
: () => _run((value) => widget.repository.arrive(value.identity), item),
child: const Text('到达并校验围栏'),
),
if (item.allowedActions.contains('submit_receipt'))
ElevatedButton(
onPressed: _busy ? null : () => _receipt(item),
child: const Text('拍摄签收凭证并提交'),
),
if (item.allowedActions.contains('submit_result'))
ElevatedButton(
onPressed: _busy
? null
: () async {
final changed = await context.push<bool>(
'/tasks/${item.identity}/evidence',
);
if (changed == true) setState(() => _future = _load());
},
child: const Text('现场取证与提交'),
),
if (item.allowedActions.contains('exception'))
TextButton(
onPressed: _busy
? null
: () async {
final reason = await _reason();
if (reason != null && reason.isNotEmpty) {
await _run(
(value) =>
widget.repository.exception(value, widget.session.roleCode, reason),
item,
);
}
},
child: const Text('标记异常'),
),
if (item.allowedActions.contains('recover'))
ElevatedButton(
onPressed: _busy
? null
: () async {
final reason = await _reason();
if (reason != null && reason.isNotEmpty) {
await _run(
(value) =>
widget.repository.recover(value, widget.session.roleCode, reason),
item,
);
}
},
child: const Text('恢复任务'),
),
if (_busy)
const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
),
],
);
},
),
);
}

View File

@@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../app/dependencies.dart';
import '../../../data/repositories/service_repository.dart';
import '../../../domain/models/service_models.dart';
import 'work_list_view_model.dart';
class WorkListPage extends StatefulWidget {
const WorkListPage({
required this.session,
required this.repository,
required this.completed,
super.key,
});
final StaffSession session;
final ServiceRepository repository;
final bool completed;
@override
State<WorkListPage> createState() => _WorkListPageState();
}
class _WorkListPageState extends State<WorkListPage> {
late final WorkListViewModel _viewModel;
@override
void initState() {
super.initState();
_viewModel = WorkListViewModel(
widget.repository,
widget.session.roleCode,
completed: widget.completed,
)..load();
}
@override
void dispose() {
_viewModel.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: Text(widget.completed ? '作业记录' : '${roleName(widget.session.roleCode)}工作台'),
),
body: ListenableBuilder(
listenable: _viewModel,
builder: (context, _) {
if (_viewModel.loading && _viewModel.items.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (_viewModel.error != null && _viewModel.items.isEmpty) {
return Center(child: Text(_viewModel.error.toString()));
}
return RefreshIndicator(
onRefresh: _viewModel.load,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 10, 16, 28),
children: [
Card(
color: widget.session.roleCode == 'operations'
? const Color(0xFFE9F8F0)
: const Color(0xFFEEEAFE),
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
Icon(
widget.session.roleCode == 'delivery'
? Icons.local_shipping
: widget.session.roleCode == 'installer'
? Icons.handyman
: Icons.health_and_safety,
size: 38,
),
const SizedBox(width: 14),
Expanded(
child: Text(
widget.completed ? '服务端确认完成的历史记录' : '仅展示服务端分派给本账号的任务',
style: const TextStyle(fontWeight: FontWeight.w800),
),
),
],
),
),
),
if (_viewModel.items.isEmpty)
const Padding(
padding: EdgeInsets.all(42),
child: Center(child: Text('暂无任务')),
)
else
..._viewModel.items.map(
(item) => Card(
child: ListTile(
contentPadding: const EdgeInsets.all(16),
title: Text(item.title, style: const TextStyle(fontWeight: FontWeight.w800)),
subtitle: Padding(
padding: const EdgeInsets.only(top: 8),
child: Text('${item.number}\n${item.address}'),
),
trailing: Chip(label: Text(statusName(item.status))),
onTap: () => context.push('/tasks/${item.identity}'),
),
),
),
],
),
);
},
),
);
}

View File

@@ -0,0 +1,36 @@
import 'package:flutter/foundation.dart';
import '../../../data/repositories/service_repository.dart';
import '../../../domain/models/service_models.dart';
class WorkListViewModel extends ChangeNotifier {
WorkListViewModel(this._repository, this._roleCode, {required this.completed});
final ServiceRepository _repository;
final String _roleCode;
final bool completed;
List<WorkItem> _items = const [];
Object? _error;
bool _loading = false;
List<WorkItem> get items => _items;
Object? get error => _error;
bool get loading => _loading;
Future<void> load() async {
_loading = true;
_error = null;
notifyListeners();
try {
final all = await _repository.tasks(_roleCode);
_items = all
.where((item) => completed ? item.status == 23 : item.status != 23 && item.status != 22)
.toList();
} catch (error) {
_error = error;
} finally {
_loading = false;
notifyListeners();
}
}
}