Files
social-app/apps/lib/features/settings/ui/screens/job_detail_screen.dart
T

784 lines
23 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_input.dart';
import '../../../../shared/widgets/app_loading_indicator.dart';
import '../../../../shared/widgets/app_pressable.dart';
import '../../../../shared/widgets/app_selection_sheet.dart';
import '../../../../shared/widgets/detail_header_action_menu.dart';
import '../../../../shared/widgets/destructive_action_sheet.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../../../shared/utils/tool_name_localizer.dart';
import '../../data/models/automation_job_model.dart';
import '../../data/services/automation_jobs_api.dart';
import '../../presentation/cubits/job_detail_cubit.dart';
import '../widgets/settings_page_scaffold.dart';
class JobDetailScreen extends StatefulWidget {
const JobDetailScreen({super.key, this.jobId});
final String? jobId;
@override
State<JobDetailScreen> createState() => _JobDetailScreenState();
}
enum _JobDetailHeaderAction { delete }
class _JobDetailScreenState extends State<JobDetailScreen> {
late final JobDetailCubit _cubit;
final TextEditingController _titleController = TextEditingController();
final TextEditingController _templateController = TextEditingController();
String _scheduleType = 'daily';
String _timezone = 'Asia/Shanghai';
TimeOfDay _runAt = const TimeOfDay(hour: 8, minute: 0);
String _contextSource = 'latest_chat';
String _contextWindowMode = 'day';
int _contextWindowCount = 2;
final Set<String> _selectedTools = <String>{'memory.write', 'memory.forget'};
@override
void initState() {
super.initState();
_cubit = JobDetailCubit(sl<AutomationJobsApi>());
if (widget.jobId != null) {
_cubit.loadJob(widget.jobId!);
}
}
@override
void dispose() {
_titleController.dispose();
_templateController.dispose();
_cubit.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cubit,
child: BlocConsumer<JobDetailCubit, JobDetailState>(
listener: (context, state) {
if (state.error != null) {
Toast.show(context, state.error!, type: ToastType.error);
}
},
builder: (context, state) {
if (state.isLoading) {
return SettingsPageScaffold(
title: '加载中',
body: const Center(child: AppLoadingIndicator()),
);
}
final job = state.job;
final isEditMode = widget.jobId != null;
if (isEditMode && job == null && state.error != null) {
return SettingsPageScaffold(
title: '任务详情',
onBack: () => context.pop(),
body: _buildLoadFailedView(state.error!),
);
}
return SettingsPageScaffold(
title: job?.title ?? '新建周期计划',
onBack: () => context.pop(),
trailing: job != null && !job.isSystem
? _buildHeaderActions(job.id, state)
: null,
body: job == null
? _buildCreateForm(state)
: _buildDetailPage(job, state),
);
},
),
);
}
Widget _buildLoadFailedView(String error) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionTitle('加载失败'),
const SizedBox(height: AppSpacing.sm),
Text(
error,
style: const TextStyle(
color: AppColors.error,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: AppSpacing.md),
AppButton(text: '重试', onPressed: () => _cubit.loadJob(widget.jobId!)),
],
);
}
Widget _buildDetailPage(AutomationJobModel job, JobDetailState state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildOverviewCard(job),
const SizedBox(height: AppSpacing.lg),
_buildSectionTitle('计划配置'),
const SizedBox(height: AppSpacing.sm),
_buildInfoCard([
_buildInfoRow('周期', _scheduleLabel(job.scheduleType)),
_buildInfoRow('执行时间', _displayRunAt(job.runAt)),
_buildInfoRow('时区', job.timezone),
_buildInfoRow('状态', job.isActive ? '已启用' : '未启用'),
]),
const SizedBox(height: AppSpacing.lg),
_buildSectionTitle('输入模板'),
const SizedBox(height: AppSpacing.sm),
_buildTextBlock(job.config.inputTemplate),
const SizedBox(height: AppSpacing.lg),
_buildSectionTitle('启用工具'),
const SizedBox(height: AppSpacing.sm),
_buildToolWrap(job.config.enabledTools),
const SizedBox(height: AppSpacing.lg),
_buildSectionTitle('上下文消息模式'),
const SizedBox(height: AppSpacing.sm),
_buildInfoCard([
_buildInfoRow('来源', _contextSourceLabel(job.config.context.source)),
_buildInfoRow(
'窗口模式',
_windowModeLabel(job.config.context.windowMode),
),
_buildInfoRow('窗口数量', '${job.config.context.windowCount}'),
]),
if (!job.isSystem && state.isSaving)
const Padding(
padding: EdgeInsets.only(top: AppSpacing.lg),
child: Center(child: AppLoadingIndicator(size: AppSpacing.lg)),
),
],
);
}
Widget _buildHeaderActions(String jobId, JobDetailState state) {
return DetailHeaderActionMenu<_JobDetailHeaderAction>(
items: const [
DetailHeaderActionItem<_JobDetailHeaderAction>(
value: _JobDetailHeaderAction.delete,
label: '删除',
icon: Icons.delete_outline,
isDestructive: true,
),
],
onSelected: (action) {
if (state.isSaving) {
return;
}
if (action == _JobDetailHeaderAction.delete) {
unawaited(_confirmAndDelete(jobId));
}
},
);
}
Future<void> _confirmAndDelete(String jobId) async {
final confirmed = await showDestructiveActionSheet(
context,
title: '删除周期计划',
message: '删除后将无法恢复,是否继续?',
confirmText: '确认删除',
);
if (!confirmed) {
return;
}
final success = await _cubit.deleteJob(jobId);
if (!mounted) {
return;
}
if (success) {
Toast.show(context, '删除成功', type: ToastType.success);
context.pop();
}
}
Widget _buildOverviewCard(AutomationJobModel job) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [AppColors.white, AppColors.surfaceInfoLight],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: AppColors.borderTertiary),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
job.title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.slate900,
),
),
const SizedBox(height: AppSpacing.sm),
Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: [
_buildBadge(job.isSystem ? '系统预置' : '自定义'),
_buildBadge(job.isActive ? '已启用' : '未启用'),
_buildBadge(_scheduleLabel(job.scheduleType)),
],
),
],
),
);
}
Widget _buildBadge(String text) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.xs,
),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(color: AppColors.borderSecondary),
),
child: Text(
text,
style: const TextStyle(
color: AppColors.slate600,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
);
}
Widget _buildCreateForm(JobDetailState state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildCreateBasicSection(),
const SizedBox(height: AppSpacing.lg),
_buildCreateRuleSection(),
const SizedBox(height: AppSpacing.lg),
_buildCreateToolSection(),
const SizedBox(height: AppSpacing.lg),
_buildCreateContextSection(),
const SizedBox(height: AppSpacing.xl),
AppButton(
text: '创建任务',
isLoading: state.isSaving,
onPressed: state.isSaving ? null : _submitCreate,
),
],
);
}
Widget _buildCreateBasicSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionTitle('基本信息'),
const SizedBox(height: AppSpacing.sm),
AppInput(label: '任务名称', hint: '请输入任务名称', controller: _titleController),
const SizedBox(height: AppSpacing.md),
AppInput(
label: '输入模板',
hint: '例如:请总结今天的记忆内容',
controller: _templateController,
maxLines: 4,
),
],
);
}
Widget _buildCreateRuleSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionTitle('执行规则'),
const SizedBox(height: AppSpacing.sm),
_buildPickerTile(
label: '周期',
value: _scheduleLabel(_scheduleType),
onTap: _pickScheduleType,
),
const SizedBox(height: AppSpacing.sm),
_buildPickerTile(
label: '执行时间',
value: _formatTime(_runAt),
onTap: _pickRunAt,
),
const SizedBox(height: AppSpacing.sm),
_buildPickerTile(label: '时区', value: _timezone, onTap: _pickTimezone),
],
);
}
Widget _buildCreateToolSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionTitle('工具选择'),
const SizedBox(height: AppSpacing.sm),
_buildToolSelector(),
],
);
}
Widget _buildCreateContextSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionTitle('上下文消息模式'),
const SizedBox(height: AppSpacing.sm),
_buildPickerTile(
label: '来源',
value: _contextSourceLabel(_contextSource),
onTap: _pickContextSource,
),
const SizedBox(height: AppSpacing.sm),
_buildPickerTile(
label: '窗口模式',
value: _windowModeLabel(_contextWindowMode),
onTap: _pickWindowMode,
),
const SizedBox(height: AppSpacing.sm),
_buildCounterTile(
label: '窗口数量',
value: _contextWindowCount,
onMinus: _contextWindowCount > 1
? () {
setState(() {
_contextWindowCount -= 1;
});
}
: null,
onPlus: _contextWindowCount < 200
? () {
setState(() {
_contextWindowCount += 1;
});
}
: null,
),
],
);
}
Widget _buildPickerTile({
required String label,
required String value,
required VoidCallback onTap,
}) {
return AppPressable(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.lg),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.md,
),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.borderSecondary),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
color: AppColors.slate500,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: AppSpacing.xs),
Text(
value,
style: const TextStyle(
color: AppColors.slate800,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
],
),
),
const Icon(Icons.keyboard_arrow_down, color: AppColors.slate400),
],
),
),
);
}
Widget _buildCounterTile({
required String label,
required int value,
required VoidCallback? onMinus,
required VoidCallback? onPlus,
}) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.md,
),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.borderSecondary),
),
child: Row(
children: [
Expanded(
child: Text(
'$label$value',
style: const TextStyle(
color: AppColors.slate800,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
_buildCounterAction(icon: Icons.remove, onTap: onMinus),
const SizedBox(width: AppSpacing.sm),
_buildCounterAction(icon: Icons.add, onTap: onPlus),
],
),
);
}
Widget _buildCounterAction({
required IconData icon,
required VoidCallback? onTap,
}) {
return AppPressable(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.full),
child: Container(
width: AppSpacing.xxl + AppSpacing.md,
height: AppSpacing.xxl + AppSpacing.md,
decoration: BoxDecoration(
color: onTap == null ? AppColors.slate100 : AppColors.surfaceTertiary,
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(color: AppColors.borderSecondary),
),
child: Icon(
icon,
size: AppSpacing.lg,
color: onTap == null ? AppColors.slate300 : AppColors.blue500,
),
),
);
}
Widget _buildToolSelector() {
return Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: automationToolOptions.map((toolName) {
final selected = _selectedTools.contains(toolName);
return AppPressable(
onTap: () {
setState(() {
if (selected) {
_selectedTools.remove(toolName);
} else {
_selectedTools.add(toolName);
}
});
},
borderRadius: BorderRadius.circular(AppRadius.full),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
color: selected ? AppColors.blue50 : AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(
color: selected ? AppColors.blue300 : AppColors.borderSecondary,
),
),
child: Text(
localizeToolName(toolName),
style: TextStyle(
color: selected ? AppColors.blue600 : AppColors.slate600,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
);
}).toList(),
);
}
Widget _buildToolWrap(List<String> tools) {
if (tools.isEmpty) {
return _buildTextBlock('未启用工具');
}
return Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: tools
.map((tool) => _buildBadge(localizeToolName(tool)))
.toList(),
);
}
Widget _buildTextBlock(String text) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.borderSecondary),
),
child: Text(
text,
style: const TextStyle(
color: AppColors.slate700,
fontSize: 13,
fontWeight: FontWeight.w500,
height: 1.5,
),
),
);
}
Widget _buildSectionTitle(String title) {
return Text(
title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.slate500,
),
);
}
Widget _buildInfoCard(List<Widget> children) {
return Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.borderSecondary),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
),
);
}
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: const TextStyle(
color: AppColors.slate500,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Text(
value,
textAlign: TextAlign.right,
style: const TextStyle(
color: AppColors.slate800,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
Future<void> _pickScheduleType() async {
final picked = await showAppSelectionSheet<String>(
context,
title: '选择周期',
selectedValue: _scheduleType,
items: const [
AppSelectionItem(value: 'daily', label: '每日'),
AppSelectionItem(value: 'weekly', label: '每周'),
],
);
if (picked != null) {
setState(() {
_scheduleType = picked;
});
}
}
Future<void> _pickTimezone() async {
final picked = await showAppSelectionSheet<String>(
context,
title: '选择时区',
selectedValue: _timezone,
items: const [
AppSelectionItem(value: 'Asia/Shanghai', label: 'Asia/Shanghai'),
AppSelectionItem(value: 'UTC', label: 'UTC'),
],
);
if (picked != null) {
setState(() {
_timezone = picked;
});
}
}
Future<void> _pickContextSource() async {
final picked = await showAppSelectionSheet<String>(
context,
title: '选择上下文来源',
selectedValue: _contextSource,
items: const [AppSelectionItem(value: 'latest_chat', label: '最近聊天')],
);
if (picked != null) {
setState(() {
_contextSource = picked;
});
}
}
Future<void> _pickWindowMode() async {
final picked = await showAppSelectionSheet<String>(
context,
title: '选择窗口模式',
selectedValue: _contextWindowMode,
items: const [
AppSelectionItem(value: 'day', label: '按天数'),
AppSelectionItem(value: 'number', label: '按消息数'),
],
);
if (picked != null) {
setState(() {
_contextWindowMode = picked;
});
}
}
Future<void> _pickRunAt() async {
final picked = await showTimePicker(context: context, initialTime: _runAt);
if (picked != null) {
setState(() {
_runAt = picked;
});
}
}
String _formatTime(TimeOfDay time) {
final hour = time.hour.toString().padLeft(2, '0');
final minute = time.minute.toString().padLeft(2, '0');
return '$hour:$minute:00';
}
String _displayRunAt(String runAtRaw) {
try {
final dt = DateTime.parse(runAtRaw).toLocal();
final hour = dt.hour.toString().padLeft(2, '0');
final minute = dt.minute.toString().padLeft(2, '0');
return '$hour:$minute';
} catch (_) {
return runAtRaw;
}
}
String _scheduleLabel(String scheduleType) {
final normalized = scheduleType.toLowerCase();
if (normalized == 'daily') {
return '每日';
}
if (normalized == 'weekly') {
return '每周';
}
return scheduleType;
}
String _contextSourceLabel(String source) {
if (source == 'latest_chat') {
return '最近聊天';
}
return source;
}
String _windowModeLabel(String mode) {
if (mode == 'day') {
return '按天数';
}
if (mode == 'number') {
return '按消息数';
}
return mode;
}
Future<void> _submitCreate() async {
final title = _titleController.text.trim();
final template = _templateController.text.trim();
if (title.isEmpty || template.isEmpty) {
Toast.show(context, '请填写完整信息', type: ToastType.error);
return;
}
final request = AutomationJobCreateRequest(
title: title,
scheduleType: _scheduleType,
runAt: _formatTime(_runAt),
timezone: _timezone,
status: 'active',
config: AutomationJobConfigModel(
inputTemplate: template,
enabledTools: _selectedTools.toList(),
context: MessageContextConfigModel(
source: _contextSource,
windowMode: _contextWindowMode,
windowCount: _contextWindowCount,
),
),
);
final success = await _cubit.createJob(request);
if (!mounted) {
return;
}
if (success) {
Toast.show(context, '创建成功', type: ToastType.success);
context.pop(true);
}
}
}