feat: complete leave approval MVP
This commit is contained in:
@@ -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('确认创建草稿'),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user