feat: complete leave approval MVP

This commit is contained in:
selfrelease
2026-07-18 19:20:07 +08:00
parent 2105fe3bac
commit 090a7e33ce
133 changed files with 7845 additions and 100 deletions
@@ -0,0 +1,138 @@
import 'package:aioa_mobile/features/form/application/leave_attachment_controller.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class LeaveAttachmentSheet extends ConsumerStatefulWidget {
const LeaveAttachmentSheet({required this.leaveRequestId, super.key});
final String leaveRequestId;
@override
ConsumerState<LeaveAttachmentSheet> createState() =>
_LeaveAttachmentSheetState();
}
class _LeaveAttachmentSheetState extends ConsumerState<LeaveAttachmentSheet> {
@override
void initState() {
super.initState();
Future.microtask(
() => ref
.read(leaveAttachmentProvider(widget.leaveRequestId).notifier)
.load(),
);
}
@override
Widget build(BuildContext context) {
final state = ref.watch(leaveAttachmentProvider(widget.leaveRequestId));
return SafeArea(
child: Padding(
padding: EdgeInsets.fromLTRB(
20,
0,
20,
20 + MediaQuery.viewInsetsOf(context).bottom,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'添加附件',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 6),
const Text('支持 JPEG、PNG、PDF,单个文件不超过 10 MB。文件将直接上传至对象存储。'),
if (state.error != null) ...[
const SizedBox(height: 10),
Text(
state.error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
if (state.uploading) ...[
const SizedBox(height: 14),
LinearProgressIndicator(value: state.progress),
const SizedBox(height: 6),
Text('正在上传 ${(state.progress * 100).round()}%'),
],
if (state.items.isNotEmpty) ...[
const SizedBox(height: 14),
for (final item in state.items)
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
item.contentType == 'application/pdf'
? Icons.picture_as_pdf_outlined
: Icons.image_outlined,
),
title: Text(
item.fileName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'${_formatBytes(item.sizeBytes)} · ${item.status == 'READY' ? '已上传' : '处理中'}',
),
trailing: item.status == 'READY'
? const Icon(Icons.check_circle, color: Colors.green)
: null,
),
],
const SizedBox(height: 14),
OutlinedButton.icon(
onPressed: state.uploading ? null : _pickAndUpload,
icon: const Icon(Icons.attach_file),
label: const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Text('选择附件'),
),
),
const SizedBox(height: 8),
FilledButton(
onPressed: state.uploading ? null : () => Navigator.pop(context),
child: const Text('完成'),
),
],
),
),
);
}
Future<void> _pickAndUpload() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: const ['jpg', 'jpeg', 'png', 'pdf'],
withData: true,
);
if (result == null || !mounted) return;
final file = result.files.single;
final bytes = file.bytes;
if (bytes == null) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('无法读取所选文件')));
return;
}
final contentType = switch (file.extension?.toLowerCase()) {
'jpg' || 'jpeg' => 'image/jpeg',
'png' => 'image/png',
'pdf' => 'application/pdf',
_ => null,
};
if (contentType == null) return;
await ref
.read(leaveAttachmentProvider(widget.leaveRequestId).notifier)
.upload(fileName: file.name, contentType: contentType, bytes: bytes);
}
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
}
@@ -1,8 +1,13 @@
import 'dart:convert';
import 'package:aioa_mobile/core/forms/presentation/dynamic_form_card.dart';
import 'package:aioa_mobile/features/form/application/form_definition_controller.dart';
import 'package:aioa_mobile/features/form/application/ai_leave_suggestion_provider.dart';
import 'package:aioa_mobile/features/form/application/leave_draft_controller.dart';
import 'package:aioa_mobile/features/form/domain/leave_form_definition.dart';
import 'package:aioa_mobile/features/form/application/leave_submission_controller.dart';
import 'package:aioa_mobile/features/form/data/form_definition_repository.dart';
import 'package:aioa_mobile/features/form/data/ai_leave_suggestion_repository.dart';
import 'package:aioa_mobile/features/form/presentation/leave_attachment_sheet.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -13,51 +18,154 @@ class LeaveFormPage extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final formState = ref.watch(leaveDraftProvider);
final controller = ref.read(leaveDraftProvider.notifier);
final loadedDefinition = ref.watch(leaveFormDefinitionProvider);
final submission = ref.watch(leaveSubmissionProvider);
return Scaffold(
appBar: AppBar(title: Text(leaveFormDefinition.dataSchema.title)),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
children: [
_AiAssistCard(onApply: controller.applyAiSuggestion),
const SizedBox(height: 14),
DynamicFormCard(
definition: leaveFormDefinition,
state: formState,
onChanged: controller.setValue,
),
const SizedBox(height: 8),
FilledButton.icon(
onPressed: () {
if (!controller.validate()) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请检查表单中的必填项和时间范围')),
);
return;
}
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (context) => _ConfirmationSheet(
values: ref.read(leaveDraftProvider).values,
),
);
},
icon: const Icon(Icons.check_circle_outline),
label: const Padding(
padding: EdgeInsets.symmetric(vertical: 14),
child: Text('检查并确认'),
appBar: AppBar(title: const Text('请假申请')),
body: loadedDefinition.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(child: Text('表单定义加载失败:$error')),
data: (loaded) => ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
children: [
_DefinitionSourceBanner(source: loaded.source),
if (formState.restoredAt != null) ...[
const SizedBox(height: 8),
_RestoredDraftBanner(onClear: controller.clear),
],
const SizedBox(height: 8),
_AiAssistCard(onApply: controller.applySuggestion),
const SizedBox(height: 14),
DynamicFormCard(
definition: loaded.definition,
state: formState,
onChanged: controller.setValue,
),
),
],
const SizedBox(height: 8),
FilledButton.icon(
onPressed: submission.submitting
? null
: () async {
if (!controller.validate(loaded.definition.dataSchema)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请检查表单中的必填项和时间范围')),
);
return;
}
final confirmed = await showModalBottomSheet<bool>(
context: context,
showDragHandle: true,
builder: (context) => _ConfirmationSheet(
values: ref.read(leaveDraftProvider).values,
),
);
if (confirmed != true || !context.mounted) return;
final created = await ref
.read(leaveSubmissionProvider.notifier)
.submit(ref.read(leaveDraftProvider).values);
if (!context.mounted) return;
if (created == null) {
final message = ref.read(leaveSubmissionProvider).error;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message ?? '创建草稿失败')),
);
return;
}
await controller.clear();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('后端草稿已创建:${created.id}')),
);
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) =>
LeaveAttachmentSheet(leaveRequestId: created.id),
);
},
icon: submission.submitting
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check_circle_outline),
label: Padding(
padding: EdgeInsets.symmetric(vertical: 14),
child: Text(submission.submitting ? '正在创建草稿…' : '检查并确认'),
),
),
],
),
),
);
}
}
class _AiAssistCard extends StatelessWidget {
class _RestoredDraftBanner extends StatelessWidget {
const _RestoredDraftBanner({required this.onClear});
final Future<void> Function() onClear;
@override
Widget build(BuildContext context) {
return MaterialBanner(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
leading: const Icon(Icons.restore),
content: const Text('已恢复上次未完成的本地草稿'),
actions: [TextButton(onPressed: onClear, child: const Text('清除'))],
);
}
}
class _DefinitionSourceBanner extends StatelessWidget {
const _DefinitionSourceBanner({required this.source});
final FormDefinitionSource source;
@override
Widget build(BuildContext context) {
final (icon, text) = switch (source) {
FormDefinitionSource.remote => (Icons.cloud_done_outlined, '已加载最新表单定义'),
FormDefinitionSource.cache => (
Icons.offline_bolt_outlined,
'当前离线,使用已缓存表单定义',
),
FormDefinitionSource.bundled => (
Icons.inventory_2_outlined,
'当前离线,使用内置安全表单定义',
),
};
return Row(
children: [
Icon(icon, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(text, style: Theme.of(context).textTheme.bodySmall),
),
],
);
}
}
class _AiAssistCard extends ConsumerStatefulWidget {
const _AiAssistCard({required this.onApply});
final VoidCallback onApply;
final void Function(Map<String, Object?> values) onApply;
@override
ConsumerState<_AiAssistCard> createState() => _AiAssistCardState();
}
class _AiAssistCardState extends ConsumerState<_AiAssistCard> {
final textController = TextEditingController(text: '明天下午请事假四小时,办理个人事务');
bool loading = false;
@override
void dispose() {
textController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
@@ -67,29 +175,75 @@ class _AiAssistCard extends StatelessWidget {
).colorScheme.primaryContainer.withValues(alpha: 0.45),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const CircleAvatar(child: Icon(Icons.auto_awesome)),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'AI 表单助手',
const Row(
children: [
CircleAvatar(child: Icon(Icons.auto_awesome)),
SizedBox(width: 12),
Expanded(
child: Text(
'千问表单助手',
style: TextStyle(fontWeight: FontWeight.w700),
),
SizedBox(height: 4),
Text('示例:帮我填写明天下午的事假申请'),
],
),
],
),
const SizedBox(height: 12),
TextField(
controller: textController,
minLines: 2,
maxLines: 4,
maxLength: 2000,
decoration: const InputDecoration(
hintText: '例如:明天下午请事假四小时,办理个人事务',
border: OutlineInputBorder(),
),
),
TextButton(onPressed: onApply, child: const Text('自动填写')),
FilledButton.icon(
onPressed: loading ? null : _suggest,
icon: loading
? const SizedBox.square(
dimension: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.auto_awesome),
label: Text(loading ? '正在生成建议…' : '生成草稿建议'),
),
],
),
),
);
}
Future<void> _suggest() async {
if (textController.text.trim().isEmpty) return;
setState(() => loading = true);
try {
final suggestion = await ref
.read(aiLeaveSuggestionRepositoryProvider)
.suggest(textController.text);
if (!mounted) return;
widget.onApply(suggestion.values);
final notes = [
...suggestion.assumptions,
...suggestion.needsClarification,
];
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(notes.isEmpty ? 'AI 建议已填入,请检查并确认' : notes.join('')),
),
);
} on AiLeaveSuggestionException catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.message)));
} finally {
if (mounted) setState(() => loading = false);
}
}
}
class _ConfirmationSheet extends StatelessWidget {
@@ -128,10 +282,7 @@ class _ConfirmationSheet extends StatelessWidget {
const SizedBox(height: 16),
FilledButton(
onPressed: () {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('演示模式:草稿已通过本地校验,尚未调用后端')),
);
Navigator.pop(context, true);
},
child: const Text('确认创建草稿'),
),