feat: 添加自动化任务(automation_jobs)功能模块
This commit is contained in:
@@ -1,110 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
enum Intent { createEvent, searchEvent, unknown }
|
||||
|
||||
/// 意图匹配规则(顺序敏感:searchEvent 优先级高于 createEvent)
|
||||
final _orderedPatterns = <(RegExp, Intent)>[
|
||||
(RegExp(r'^查看|^有什么|^今天.*日程|^明天.*安排|^查询'), Intent.searchEvent),
|
||||
(RegExp(r'提醒|开会|预约|安排.*时间|创建.*日程'), Intent.createEvent),
|
||||
(RegExp(r'明天.*\d|今天.*\d|后天.*\d|\d{1,2}点|\d{1,2}:\d{2}'), Intent.createEvent),
|
||||
];
|
||||
|
||||
/// 时区常量
|
||||
const _defaultTimezone = 'Asia/Shanghai';
|
||||
const _dayToday = '今天';
|
||||
const _dayTomorrow = '明天';
|
||||
const _dayAfterTomorrow = '后天';
|
||||
const _tomorrowOffset = 1;
|
||||
const _dayAfterTomorrowOffset = 2;
|
||||
const _defaultMinute = 0;
|
||||
|
||||
class AiDecisionEngine {
|
||||
Intent matchIntent(String text) {
|
||||
for (final (pattern, intent) in _orderedPatterns) {
|
||||
if (pattern.hasMatch(text)) return intent;
|
||||
}
|
||||
return Intent.unknown;
|
||||
}
|
||||
|
||||
Map<String, dynamic>? tryExtractEventArgs(String text) {
|
||||
if (matchIntent(text) != Intent.createEvent) return null;
|
||||
|
||||
final args = <String, dynamic>{};
|
||||
|
||||
final titleMatch = RegExp(r'提醒(.+?)(?:明天|今天|几点|$)').firstMatch(text);
|
||||
if (titleMatch != null) {
|
||||
args['title'] = titleMatch.group(1)?.trim() ?? text;
|
||||
} else if (RegExp(r'\d{1,2}[:点]|\d{1,2}点').hasMatch(text)) {
|
||||
args['title'] = text
|
||||
.replaceAll(RegExp(r'\d{1,2}[:点]\d{0,2}|明天|今天|后天'), '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
final title = args['title'];
|
||||
if (title == null || (title as String).isEmpty) return null;
|
||||
|
||||
final timeMatch = RegExp(
|
||||
r'(明天|今天|后天)?\s*(\d{1,2})[:点](\d{2})?',
|
||||
).firstMatch(text);
|
||||
if (timeMatch != null) {
|
||||
final dayStr = timeMatch.group(1) ?? _dayToday;
|
||||
final hour = int.parse(timeMatch.group(2)!);
|
||||
final minute = int.parse(timeMatch.group(3) ?? '$_defaultMinute');
|
||||
|
||||
final now = DateTime.now();
|
||||
final dayOffset = switch (dayStr) {
|
||||
_dayTomorrow => _tomorrowOffset,
|
||||
_dayAfterTomorrow => _dayAfterTomorrowOffset,
|
||||
_ => 0,
|
||||
};
|
||||
final startAt = DateTime(
|
||||
now.year,
|
||||
now.month,
|
||||
now.day + dayOffset,
|
||||
hour,
|
||||
minute,
|
||||
);
|
||||
|
||||
args['startAt'] = startAt.toIso8601String();
|
||||
args['timezone'] = _defaultTimezone;
|
||||
}
|
||||
|
||||
if (!args.containsKey('startAt')) return null;
|
||||
return args;
|
||||
}
|
||||
|
||||
bool shouldTriggerToolCall(String text) =>
|
||||
matchIntent(text) == Intent.createEvent;
|
||||
|
||||
Map<String, dynamic>? getToolCallArgs(String text) {
|
||||
if (!shouldTriggerToolCall(text)) return null;
|
||||
return tryExtractEventArgs(text);
|
||||
}
|
||||
|
||||
ForceTriggerResult? tryForceTrigger(String text) {
|
||||
final match = RegExp(
|
||||
r'#tool:([A-Za-z0-9_.-]+)\s*(\{.*\})?',
|
||||
).firstMatch(text);
|
||||
if (match == null) return null;
|
||||
|
||||
final toolName = match.group(1)!;
|
||||
final argsJson = match.group(2);
|
||||
|
||||
Map<String, dynamic>? args;
|
||||
if (argsJson != null) {
|
||||
try {
|
||||
args = jsonDecode(argsJson) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
args = {};
|
||||
}
|
||||
}
|
||||
|
||||
return ForceTriggerResult(toolName: toolName, args: args ?? {});
|
||||
}
|
||||
}
|
||||
|
||||
class ForceTriggerResult {
|
||||
final String toolName;
|
||||
final Map<String, dynamic> args;
|
||||
ForceTriggerResult({required this.toolName, required this.args});
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import '../../../../core/router/app_routes.dart';
|
||||
|
||||
typedef RouteNavigator = void Function(String target, {bool replace});
|
||||
|
||||
const Set<String> _allowedRoutes = {
|
||||
AppRoutes.settingsMain,
|
||||
AppRoutes.todoList,
|
||||
AppRoutes.todoCreate,
|
||||
AppRoutes.calendarDayWeek,
|
||||
AppRoutes.calendarMonth,
|
||||
AppRoutes.calendarEventCreate,
|
||||
AppRoutes.messageInviteList,
|
||||
AppRoutes.contactsList,
|
||||
AppRoutes.contactsAdd,
|
||||
};
|
||||
|
||||
const List<String> _allowedRoutePrefixes = [
|
||||
'/calendar/events/',
|
||||
'/todo/',
|
||||
'/messages/invites/',
|
||||
];
|
||||
|
||||
class RouteNavigationTool {
|
||||
RouteNavigationTool._();
|
||||
|
||||
static final RouteNavigationTool instance = RouteNavigationTool._();
|
||||
|
||||
RouteNavigator? _navigator;
|
||||
|
||||
void bindNavigator(RouteNavigator navigator) {
|
||||
_navigator = navigator;
|
||||
}
|
||||
|
||||
void clearNavigator() {
|
||||
_navigator = null;
|
||||
}
|
||||
|
||||
Map<String, dynamic> execute(Map<String, dynamic> args) {
|
||||
final target = args['target'];
|
||||
if (target is! String || target.isEmpty) {
|
||||
return {'ok': false, 'error': 'target is required'};
|
||||
}
|
||||
if (!_isAllowedTarget(target)) {
|
||||
return {'ok': false, 'target': target, 'error': 'target is not allowed'};
|
||||
}
|
||||
final replace = args['replace'] == true;
|
||||
final navigator = _navigator;
|
||||
if (navigator == null) {
|
||||
return {
|
||||
'ok': false,
|
||||
'target': target,
|
||||
'replace': replace,
|
||||
'error': 'navigator not bound',
|
||||
};
|
||||
}
|
||||
navigator(target, replace: replace);
|
||||
return {'ok': true, 'target': target, 'replace': replace, 'applied': true};
|
||||
}
|
||||
|
||||
bool _isAllowedTarget(String target) {
|
||||
if (!target.startsWith('/')) {
|
||||
return false;
|
||||
}
|
||||
final normalized = target.split('?').first;
|
||||
if (_allowedRoutes.contains(normalized)) {
|
||||
return true;
|
||||
}
|
||||
for (final prefix in _allowedRoutePrefixes) {
|
||||
if (normalized.startsWith(prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import 'route_navigation_tool.dart';
|
||||
|
||||
typedef ToolHandler =
|
||||
Future<Map<String, dynamic>> Function(Map<String, dynamic> args);
|
||||
|
||||
/// 工具常量
|
||||
const _toolNameNavigateRoute = 'front.navigate_to_route';
|
||||
|
||||
class ToolDefinition {
|
||||
final String name;
|
||||
final String description;
|
||||
final Map<String, dynamic> parameters;
|
||||
final ToolHandler handler;
|
||||
|
||||
ToolDefinition({
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.parameters,
|
||||
required this.handler,
|
||||
});
|
||||
}
|
||||
|
||||
class ToolRegistry {
|
||||
static final Map<String, ToolDefinition> _tools = {};
|
||||
static bool _initialized = false;
|
||||
|
||||
static void initialize() {
|
||||
if (_initialized) return;
|
||||
|
||||
_tools[_toolNameNavigateRoute] = ToolDefinition(
|
||||
name: _toolNameNavigateRoute,
|
||||
description: '在前端执行路由跳转',
|
||||
parameters: {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'target': {'type': 'string', 'description': '跳转目标路由'},
|
||||
'replace': {'type': 'boolean', 'description': '是否 replace 导航'},
|
||||
},
|
||||
'required': ['target'],
|
||||
},
|
||||
handler: _handleNavigateRoute,
|
||||
);
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> _handleNavigateRoute(
|
||||
Map<String, dynamic> args,
|
||||
) async {
|
||||
return RouteNavigationTool.instance.execute(args);
|
||||
}
|
||||
|
||||
static ToolDefinition? getTool(String name) => _tools[name];
|
||||
static List<ToolDefinition> getAllTools() => _tools.values.toList();
|
||||
|
||||
static Future<Map<String, dynamic>> execute(
|
||||
String toolName,
|
||||
Map<String, dynamic> args,
|
||||
) async {
|
||||
final tool = _tools[toolName];
|
||||
if (tool == null) throw ToolNotFoundException('Tool not found: $toolName');
|
||||
return tool.handler(args);
|
||||
}
|
||||
|
||||
static ToolValidationResult validateArgs(
|
||||
String toolName,
|
||||
Map<String, dynamic> args,
|
||||
) {
|
||||
final tool = _tools[toolName];
|
||||
if (tool == null) {
|
||||
return ToolValidationResult(
|
||||
ok: false,
|
||||
error: 'Tool not found: $toolName',
|
||||
);
|
||||
}
|
||||
|
||||
final required = tool.parameters['required'] as List<dynamic>? ?? [];
|
||||
final missing = <String>[];
|
||||
for (final field in required) {
|
||||
if (!args.containsKey(field) || args[field] == null) {
|
||||
missing.add(field as String);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.isNotEmpty) {
|
||||
return ToolValidationResult(
|
||||
ok: false,
|
||||
error: 'Missing required fields: ${missing.join(', ')}',
|
||||
);
|
||||
}
|
||||
return ToolValidationResult(ok: true);
|
||||
}
|
||||
}
|
||||
|
||||
class ToolNotFoundException implements Exception {
|
||||
final String message;
|
||||
ToolNotFoundException(this.message);
|
||||
@override
|
||||
String toString() => 'ToolNotFoundException: $message';
|
||||
}
|
||||
|
||||
class ToolValidationResult {
|
||||
final bool ok;
|
||||
final String? error;
|
||||
ToolValidationResult({required this.ok, this.error});
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../../../../shared/utils/tool_name_localizer.dart';
|
||||
import '../../../../shared/widgets/app_loading_indicator.dart';
|
||||
import '../../../chat/data/models/chat_list_item.dart';
|
||||
import '../../../chat/ui/widgets/ui_schema_renderer.dart';
|
||||
@@ -248,7 +249,7 @@ class HomeChatItemRenderer {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.toolName,
|
||||
localizeToolName(item.toolName),
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
int _parseInt(dynamic v, {required String field, required int fallback}) {
|
||||
if (v == null) {
|
||||
return fallback;
|
||||
}
|
||||
if (v is int) {
|
||||
return v;
|
||||
}
|
||||
if (v is String) {
|
||||
return int.tryParse(v) ?? (throw FormatException('$field invalid'));
|
||||
}
|
||||
throw FormatException('$field invalid type');
|
||||
}
|
||||
|
||||
List<String> _parseStringList(dynamic v, {required String field}) {
|
||||
if (v == null) {
|
||||
return const [];
|
||||
}
|
||||
if (v is List && v.every((e) => e is String)) {
|
||||
return v.cast<String>();
|
||||
}
|
||||
throw FormatException('$field invalid type');
|
||||
}
|
||||
|
||||
String _parseString(
|
||||
dynamic v, {
|
||||
required String field,
|
||||
required String fallback,
|
||||
}) {
|
||||
if (v == null) {
|
||||
return fallback;
|
||||
}
|
||||
if (v is String) {
|
||||
return v;
|
||||
}
|
||||
throw FormatException('$field invalid type');
|
||||
}
|
||||
|
||||
class MessageContextConfigModel {
|
||||
final String source;
|
||||
final String windowMode;
|
||||
final int windowCount;
|
||||
|
||||
MessageContextConfigModel({
|
||||
required this.source,
|
||||
required this.windowMode,
|
||||
required this.windowCount,
|
||||
});
|
||||
|
||||
factory MessageContextConfigModel.fromJson(Map<String, dynamic>? json) {
|
||||
if (json == null) {
|
||||
return MessageContextConfigModel(
|
||||
source: 'latest_chat',
|
||||
windowMode: 'day',
|
||||
windowCount: 2,
|
||||
);
|
||||
}
|
||||
return MessageContextConfigModel(
|
||||
source: _parseString(
|
||||
json['source'],
|
||||
field: 'source',
|
||||
fallback: 'latest_chat',
|
||||
),
|
||||
windowMode: _parseString(
|
||||
json['window_mode'],
|
||||
field: 'window_mode',
|
||||
fallback: 'day',
|
||||
),
|
||||
windowCount: _parseInt(
|
||||
json['window_count'],
|
||||
field: 'window_count',
|
||||
fallback: 2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'source': source,
|
||||
'window_mode': windowMode,
|
||||
'window_count': windowCount,
|
||||
};
|
||||
|
||||
MessageContextConfigModel copyWith({
|
||||
String? source,
|
||||
String? windowMode,
|
||||
int? windowCount,
|
||||
}) {
|
||||
return MessageContextConfigModel(
|
||||
source: source ?? this.source,
|
||||
windowMode: windowMode ?? this.windowMode,
|
||||
windowCount: windowCount ?? this.windowCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AutomationJobConfigModel {
|
||||
final String inputTemplate;
|
||||
final List<String> enabledTools;
|
||||
final MessageContextConfigModel context;
|
||||
|
||||
AutomationJobConfigModel({
|
||||
required this.inputTemplate,
|
||||
required this.enabledTools,
|
||||
required this.context,
|
||||
});
|
||||
|
||||
factory AutomationJobConfigModel.fromJson(Map<String, dynamic>? json) {
|
||||
if (json == null) {
|
||||
return AutomationJobConfigModel(
|
||||
inputTemplate: '',
|
||||
enabledTools: const [],
|
||||
context: MessageContextConfigModel.fromJson(null),
|
||||
);
|
||||
}
|
||||
return AutomationJobConfigModel(
|
||||
inputTemplate: _parseString(
|
||||
json['input_template'],
|
||||
field: 'input_template',
|
||||
fallback: '',
|
||||
),
|
||||
enabledTools: _parseStringList(
|
||||
json['enabled_tools'],
|
||||
field: 'enabled_tools',
|
||||
),
|
||||
context: json['context'] != null
|
||||
? MessageContextConfigModel.fromJson(
|
||||
json['context'] as Map<String, dynamic>?,
|
||||
)
|
||||
: MessageContextConfigModel.fromJson(null),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'input_template': inputTemplate,
|
||||
'enabled_tools': enabledTools,
|
||||
'context': context.toJson(),
|
||||
};
|
||||
|
||||
AutomationJobConfigModel copyWith({
|
||||
String? inputTemplate,
|
||||
List<String>? enabledTools,
|
||||
MessageContextConfigModel? context,
|
||||
}) {
|
||||
return AutomationJobConfigModel(
|
||||
inputTemplate: inputTemplate ?? this.inputTemplate,
|
||||
enabledTools: enabledTools ?? this.enabledTools,
|
||||
context: context ?? this.context,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AutomationJobModel {
|
||||
final String id;
|
||||
final String ownerId;
|
||||
final String? bootstrapKey;
|
||||
final String title;
|
||||
final String scheduleType;
|
||||
final String runAt;
|
||||
final String timezone;
|
||||
final String status;
|
||||
final bool isSystem;
|
||||
final AutomationJobConfigModel config;
|
||||
final DateTime nextRunAt;
|
||||
final DateTime? lastRunAt;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
AutomationJobModel({
|
||||
required this.id,
|
||||
required this.ownerId,
|
||||
this.bootstrapKey,
|
||||
required this.title,
|
||||
required this.scheduleType,
|
||||
required this.runAt,
|
||||
required this.timezone,
|
||||
required this.status,
|
||||
required this.isSystem,
|
||||
required this.config,
|
||||
required this.nextRunAt,
|
||||
this.lastRunAt,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
factory AutomationJobModel.fromJson(Map<String, dynamic> json) {
|
||||
return AutomationJobModel(
|
||||
id: _parseString(json['id'], field: 'id', fallback: ''),
|
||||
ownerId: _parseString(json['owner_id'], field: 'owner_id', fallback: ''),
|
||||
bootstrapKey: json['bootstrap_key'] == null
|
||||
? null
|
||||
: _parseString(
|
||||
json['bootstrap_key'],
|
||||
field: 'bootstrap_key',
|
||||
fallback: '',
|
||||
),
|
||||
title: _parseString(json['title'], field: 'title', fallback: ''),
|
||||
scheduleType: _parseString(
|
||||
json['schedule_type'],
|
||||
field: 'schedule_type',
|
||||
fallback: 'daily',
|
||||
),
|
||||
runAt: _parseString(
|
||||
json['run_at'],
|
||||
field: 'run_at',
|
||||
fallback: '08:00:00',
|
||||
),
|
||||
timezone: _parseString(
|
||||
json['timezone'],
|
||||
field: 'timezone',
|
||||
fallback: 'UTC',
|
||||
),
|
||||
status: _parseString(json['status'], field: 'status', fallback: 'active'),
|
||||
isSystem: json['is_system'] == null
|
||||
? false
|
||||
: (json['is_system'] is bool
|
||||
? json['is_system'] as bool
|
||||
: throw FormatException('is_system invalid type')),
|
||||
config: json['config'] != null
|
||||
? AutomationJobConfigModel.fromJson(
|
||||
json['config'] as Map<String, dynamic>?,
|
||||
)
|
||||
: AutomationJobConfigModel.fromJson(null),
|
||||
nextRunAt: json['next_run_at'] != null
|
||||
? DateTime.parse(
|
||||
_parseString(
|
||||
json['next_run_at'],
|
||||
field: 'next_run_at',
|
||||
fallback: '',
|
||||
),
|
||||
)
|
||||
: throw FormatException('next_run_at is required'),
|
||||
lastRunAt: json['last_run_at'] != null
|
||||
? DateTime.parse(
|
||||
_parseString(
|
||||
json['last_run_at'],
|
||||
field: 'last_run_at',
|
||||
fallback: '',
|
||||
),
|
||||
)
|
||||
: null,
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.parse(
|
||||
_parseString(
|
||||
json['created_at'],
|
||||
field: 'created_at',
|
||||
fallback: '',
|
||||
),
|
||||
)
|
||||
: throw FormatException('created_at is required'),
|
||||
updatedAt: json['updated_at'] != null
|
||||
? DateTime.parse(
|
||||
_parseString(
|
||||
json['updated_at'],
|
||||
field: 'updated_at',
|
||||
fallback: '',
|
||||
),
|
||||
)
|
||||
: throw FormatException('updated_at is required'),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'owner_id': ownerId,
|
||||
'bootstrap_key': bootstrapKey,
|
||||
'title': title,
|
||||
'schedule_type': scheduleType,
|
||||
'run_at': runAt,
|
||||
'timezone': timezone,
|
||||
'status': status,
|
||||
'is_system': isSystem,
|
||||
'config': config.toJson(),
|
||||
'next_run_at': nextRunAt.toIso8601String(),
|
||||
'last_run_at': lastRunAt?.toIso8601String(),
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'updated_at': updatedAt.toIso8601String(),
|
||||
};
|
||||
|
||||
AutomationJobModel copyWith({
|
||||
String? id,
|
||||
String? ownerId,
|
||||
String? bootstrapKey,
|
||||
String? title,
|
||||
String? scheduleType,
|
||||
String? runAt,
|
||||
String? timezone,
|
||||
String? status,
|
||||
bool? isSystem,
|
||||
AutomationJobConfigModel? config,
|
||||
DateTime? nextRunAt,
|
||||
DateTime? lastRunAt,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return AutomationJobModel(
|
||||
id: id ?? this.id,
|
||||
ownerId: ownerId ?? this.ownerId,
|
||||
bootstrapKey: bootstrapKey ?? this.bootstrapKey,
|
||||
title: title ?? this.title,
|
||||
scheduleType: scheduleType ?? this.scheduleType,
|
||||
runAt: runAt ?? this.runAt,
|
||||
timezone: timezone ?? this.timezone,
|
||||
status: status ?? this.status,
|
||||
isSystem: isSystem ?? this.isSystem,
|
||||
config: config ?? this.config,
|
||||
nextRunAt: nextRunAt ?? this.nextRunAt,
|
||||
lastRunAt: lastRunAt ?? this.lastRunAt,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
bool get isActive => status.toLowerCase() == 'active';
|
||||
|
||||
bool get isDaily => scheduleType.toLowerCase() == 'daily';
|
||||
|
||||
bool get isWeekly => scheduleType.toLowerCase() == 'weekly';
|
||||
}
|
||||
|
||||
class AutomationJobListResponse {
|
||||
final List<AutomationJobModel> items;
|
||||
|
||||
AutomationJobListResponse({required this.items});
|
||||
|
||||
factory AutomationJobListResponse.fromJson(Map<String, dynamic>? json) {
|
||||
if (json == null) {
|
||||
return AutomationJobListResponse(items: const []);
|
||||
}
|
||||
final itemsJson = json['items'];
|
||||
if (itemsJson == null) {
|
||||
return AutomationJobListResponse(items: const []);
|
||||
}
|
||||
if (itemsJson is List) {
|
||||
return AutomationJobListResponse(
|
||||
items: itemsJson
|
||||
.map((e) => AutomationJobModel.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
throw FormatException('items invalid type');
|
||||
}
|
||||
}
|
||||
|
||||
class AutomationJobCreateRequest {
|
||||
final String title;
|
||||
final String scheduleType;
|
||||
final String runAt;
|
||||
final String timezone;
|
||||
final String status;
|
||||
final AutomationJobConfigModel config;
|
||||
|
||||
AutomationJobCreateRequest({
|
||||
required this.title,
|
||||
required this.scheduleType,
|
||||
required this.runAt,
|
||||
required this.timezone,
|
||||
required this.status,
|
||||
required this.config,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'title': title,
|
||||
'schedule_type': scheduleType,
|
||||
'run_at': runAt,
|
||||
'timezone': timezone,
|
||||
'status': status,
|
||||
'config': config.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
class AutomationJobConfigPatchModel {
|
||||
final String? inputTemplate;
|
||||
final List<String>? enabledTools;
|
||||
final MessageContextConfigModel? context;
|
||||
|
||||
AutomationJobConfigPatchModel({
|
||||
this.inputTemplate,
|
||||
this.enabledTools,
|
||||
this.context,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final map = <String, dynamic>{};
|
||||
if (inputTemplate != null) map['input_template'] = inputTemplate;
|
||||
if (enabledTools != null) map['enabled_tools'] = enabledTools;
|
||||
if (context != null) map['context'] = context!.toJson();
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
class AutomationJobUpdateRequest {
|
||||
final String? title;
|
||||
final String? scheduleType;
|
||||
final String? runAt;
|
||||
final String? timezone;
|
||||
final String? status;
|
||||
final AutomationJobConfigPatchModel? config;
|
||||
|
||||
AutomationJobUpdateRequest({
|
||||
this.title,
|
||||
this.scheduleType,
|
||||
this.runAt,
|
||||
this.timezone,
|
||||
this.status,
|
||||
this.config,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final map = <String, dynamic>{};
|
||||
if (title != null) map['title'] = title;
|
||||
if (scheduleType != null) map['schedule_type'] = scheduleType;
|
||||
if (runAt != null) map['run_at'] = runAt;
|
||||
if (timezone != null) map['timezone'] = timezone;
|
||||
if (status != null) map['status'] = status;
|
||||
if (config != null) map['config'] = config!.toJson();
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:social_app/core/api/i_api_client.dart';
|
||||
import '../models/automation_job_model.dart';
|
||||
|
||||
class AutomationJobsApi {
|
||||
final IApiClient _client;
|
||||
static const _prefix = '/api/v1/automation-jobs';
|
||||
|
||||
AutomationJobsApi(this._client);
|
||||
|
||||
Future<List<AutomationJobModel>> list() async {
|
||||
final response = await _client.get(_prefix);
|
||||
final parsed = AutomationJobListResponse.fromJson(response.data);
|
||||
return parsed.items;
|
||||
}
|
||||
|
||||
Future<AutomationJobModel> get(String id) async {
|
||||
final response = await _client.get('$_prefix/$id');
|
||||
return AutomationJobModel.fromJson(response.data);
|
||||
}
|
||||
|
||||
Future<AutomationJobModel> create(AutomationJobCreateRequest request) async {
|
||||
final response = await _client.post(_prefix, data: request.toJson());
|
||||
return AutomationJobModel.fromJson(response.data);
|
||||
}
|
||||
|
||||
Future<AutomationJobModel> update(
|
||||
String id,
|
||||
AutomationJobUpdateRequest request,
|
||||
) async {
|
||||
final data = request.toJson();
|
||||
final response = await _client.patch('$_prefix/$id', data: data);
|
||||
return AutomationJobModel.fromJson(response.data);
|
||||
}
|
||||
|
||||
Future<void> delete(String id) async {
|
||||
await _client.delete('$_prefix/$id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../data/models/automation_job_model.dart';
|
||||
import '../../data/services/automation_jobs_api.dart';
|
||||
|
||||
class AutomationJobsState extends Equatable {
|
||||
final List<AutomationJobModel> jobs;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
|
||||
const AutomationJobsState({
|
||||
this.jobs = const [],
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
List<AutomationJobModel> get systemJobs =>
|
||||
jobs.where((j) => j.isSystem).toList();
|
||||
List<AutomationJobModel> get userJobs =>
|
||||
jobs.where((j) => !j.isSystem).toList();
|
||||
List<AutomationJobModel> get dailyJobs =>
|
||||
jobs.where((j) => j.isDaily).toList();
|
||||
List<AutomationJobModel> get weeklyJobs =>
|
||||
jobs.where((j) => j.isWeekly).toList();
|
||||
bool get canCreateMore => userJobs.length < 3;
|
||||
|
||||
AutomationJobsState copyWith({
|
||||
List<AutomationJobModel>? jobs,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
}) {
|
||||
return AutomationJobsState(
|
||||
jobs: jobs ?? this.jobs,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [jobs, isLoading, error];
|
||||
}
|
||||
|
||||
class AutomationJobsCubit extends Cubit<AutomationJobsState> {
|
||||
final AutomationJobsApi _api;
|
||||
|
||||
AutomationJobsCubit(this._api) : super(AutomationJobsState());
|
||||
|
||||
Future<void> loadJobs() async {
|
||||
emit(state.copyWith(isLoading: true));
|
||||
try {
|
||||
final jobs = await _api.list();
|
||||
emit(state.copyWith(jobs: jobs, isLoading: false));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(isLoading: false, error: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteJob(String id) async {
|
||||
try {
|
||||
await _api.delete(id);
|
||||
await loadJobs();
|
||||
} catch (e) {
|
||||
emit(state.copyWith(error: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateJobStatus({
|
||||
required String id,
|
||||
required bool enabled,
|
||||
}) async {
|
||||
try {
|
||||
final updated = await _api.update(
|
||||
id,
|
||||
AutomationJobUpdateRequest(status: enabled ? 'active' : 'disabled'),
|
||||
);
|
||||
final nextJobs = state.jobs
|
||||
.map((job) => job.id == id ? updated : job)
|
||||
.toList();
|
||||
emit(state.copyWith(jobs: nextJobs));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(error: e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../data/models/automation_job_model.dart';
|
||||
import '../../data/services/automation_jobs_api.dart';
|
||||
|
||||
class JobDetailState extends Equatable {
|
||||
final AutomationJobModel? job;
|
||||
final bool isLoading;
|
||||
final bool isSaving;
|
||||
final String? error;
|
||||
|
||||
const JobDetailState({
|
||||
this.job,
|
||||
this.isLoading = false,
|
||||
this.isSaving = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
JobDetailState copyWith({
|
||||
AutomationJobModel? job,
|
||||
bool? isLoading,
|
||||
bool? isSaving,
|
||||
String? error,
|
||||
}) {
|
||||
return JobDetailState(
|
||||
job: job ?? this.job,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
isSaving: isSaving ?? this.isSaving,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [job, isLoading, isSaving, error];
|
||||
}
|
||||
|
||||
class JobDetailCubit extends Cubit<JobDetailState> {
|
||||
final AutomationJobsApi _api;
|
||||
|
||||
JobDetailCubit(this._api) : super(JobDetailState());
|
||||
|
||||
Future<void> loadJob(String id) async {
|
||||
emit(state.copyWith(isLoading: true));
|
||||
try {
|
||||
final job = await _api.get(id);
|
||||
emit(state.copyWith(job: job, isLoading: false));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(isLoading: false, error: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> updateJob(String id, AutomationJobUpdateRequest request) async {
|
||||
emit(state.copyWith(isSaving: true));
|
||||
try {
|
||||
final job = await _api.update(id, request);
|
||||
emit(state.copyWith(job: job, isSaving: false));
|
||||
return true;
|
||||
} catch (e) {
|
||||
emit(state.copyWith(isSaving: false, error: e.toString()));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> deleteJob(String id) async {
|
||||
emit(state.copyWith(isSaving: true, error: null));
|
||||
try {
|
||||
await _api.delete(id);
|
||||
emit(state.copyWith(isSaving: false));
|
||||
return true;
|
||||
} catch (e) {
|
||||
emit(state.copyWith(isSaving: false, error: e.toString()));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> createJob(AutomationJobCreateRequest request) async {
|
||||
emit(state.copyWith(isSaving: true, error: null));
|
||||
try {
|
||||
final job = await _api.create(request);
|
||||
emit(state.copyWith(job: job, isSaving: false));
|
||||
return true;
|
||||
} catch (e) {
|
||||
emit(state.copyWith(isSaving: false, error: e.toString()));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,18 @@
|
||||
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/router/app_routes.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/app_loading_indicator.dart';
|
||||
import '../../../../shared/widgets/app_pressable.dart';
|
||||
import '../../../../shared/widgets/app_toggle_switch.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
import '../../data/models/automation_job_model.dart';
|
||||
import '../../data/services/automation_jobs_api.dart';
|
||||
import '../../presentation/cubits/automation_jobs_cubit.dart';
|
||||
import '../widgets/settings_page_scaffold.dart';
|
||||
|
||||
class FeaturesScreen extends StatefulWidget {
|
||||
@@ -12,27 +23,88 @@ class FeaturesScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _FeaturesScreenState extends State<FeaturesScreen> {
|
||||
bool _dailyReminderEnabled = true;
|
||||
bool _dailySummaryEnabled = false;
|
||||
bool _weeklyReportEnabled = true;
|
||||
bool _weeklyDigestEnabled = false;
|
||||
late final AutomationJobsCubit _cubit;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_cubit = AutomationJobsCubit(sl<AutomationJobsApi>());
|
||||
_cubit.loadJobs();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cubit.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SettingsPageScaffold(
|
||||
title: '周期计划',
|
||||
onBack: () => context.pop(),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionTitle('每日'),
|
||||
const SizedBox(height: 8),
|
||||
_buildDailyList(),
|
||||
const SizedBox(height: 16),
|
||||
_buildSectionTitle('每周'),
|
||||
const SizedBox(height: 8),
|
||||
_buildWeeklyList(),
|
||||
return BlocProvider.value(
|
||||
value: _cubit,
|
||||
child: SettingsPageScaffold(
|
||||
title: '周期计划',
|
||||
onBack: () => context.pop(),
|
||||
body: BlocBuilder<AutomationJobsCubit, AutomationJobsState>(
|
||||
builder: (context, state) {
|
||||
if (state.isLoading) {
|
||||
return const Center(child: AppLoadingIndicator());
|
||||
}
|
||||
if (state.error != null) {
|
||||
return Center(child: Text(state.error!));
|
||||
}
|
||||
return _buildJobList(state);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildJobList(AutomationJobsState state) {
|
||||
final dailyJobs = state.dailyJobs;
|
||||
final weeklyJobs = state.weeklyJobs;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionTitle('每日'),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
if (dailyJobs.isEmpty)
|
||||
_buildEmptyHint('暂无每日计划')
|
||||
else
|
||||
...dailyJobs.map(_buildJobCard),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_buildSectionTitle('每周'),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
if (weeklyJobs.isEmpty)
|
||||
_buildEmptyHint('暂无每周计划')
|
||||
else
|
||||
...weeklyJobs.map(_buildJobCard),
|
||||
if (state.canCreateMore) ...[
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_buildCreateButton(),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyHint(String text) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(color: AppColors.borderSecondary),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
color: AppColors.slate500,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -48,122 +120,95 @@ class _FeaturesScreenState extends State<FeaturesScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDailyList() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildFeatureCard(
|
||||
icon: Icons.alarm,
|
||||
iconColor: const Color(0xFF14B8A6),
|
||||
iconBg: const Color(0xFFECFEFF),
|
||||
iconBorder: const Color(0xFFC9F4F2),
|
||||
title: '会议提醒',
|
||||
subtitle: '每次会议前 15 分钟提醒',
|
||||
value: _dailyReminderEnabled,
|
||||
onChanged: (v) => setState(() => _dailyReminderEnabled = v),
|
||||
Widget _buildJobCard(AutomationJobModel job) {
|
||||
return AppPressable(
|
||||
onTap: () async {
|
||||
await context.push(AppRoutes.settingsJobDetail(job.id));
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
_cubit.loadJobs();
|
||||
},
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(color: AppColors.borderSecondary),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_buildFeatureCard(
|
||||
icon: Icons.summarize,
|
||||
iconColor: const Color(0xFF2563EB),
|
||||
iconBg: const Color(0xFFEEF6FF),
|
||||
iconBorder: const Color(0xFFDCEAFF),
|
||||
title: '每日摘要',
|
||||
subtitle: '每天 18:00 发送当日摘要',
|
||||
value: _dailySummaryEnabled,
|
||||
onChanged: (v) => setState(() => _dailySummaryEnabled = v),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: AppSpacing.xxl + AppSpacing.lg,
|
||||
height: AppSpacing.xxl + AppSpacing.lg,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceTertiary,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.auto_awesome,
|
||||
size: AppSpacing.lg,
|
||||
color: AppColors.blue500,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
job.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
_buildSubtitle(job),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.slate500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
AppToggleSwitch(
|
||||
value: job.isActive,
|
||||
onChanged: (next) {
|
||||
if (job.isSystem) {
|
||||
Toast.show(context, '系统预置任务状态不可修改', type: ToastType.info);
|
||||
return;
|
||||
}
|
||||
_cubit.updateJobStatus(id: job.id, enabled: next);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWeeklyList() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildFeatureCard(
|
||||
icon: Icons.calendar_view_week,
|
||||
iconColor: AppColors.success,
|
||||
iconBg: const Color(0xFFECFDF5),
|
||||
iconBorder: const Color(0xFFCDEEDC),
|
||||
title: '周报生成',
|
||||
subtitle: '每周一自动生成周报',
|
||||
value: _weeklyReportEnabled,
|
||||
onChanged: (v) => setState(() => _weeklyReportEnabled = v),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_buildFeatureCard(
|
||||
icon: Icons.article,
|
||||
iconColor: AppColors.warning,
|
||||
iconBg: const Color(0xFFFFF7ED),
|
||||
iconBorder: const Color(0xFFFDE6CD),
|
||||
title: '每周摘要',
|
||||
subtitle: '每周日发送本周活动汇总',
|
||||
value: _weeklyDigestEnabled,
|
||||
onChanged: (v) => setState(() => _weeklyDigestEnabled = v),
|
||||
),
|
||||
],
|
||||
);
|
||||
String _buildSubtitle(AutomationJobModel job) {
|
||||
final statusText = job.isActive ? '已启用' : '未启用';
|
||||
final sourceText = job.isSystem ? '系统预置' : '自定义';
|
||||
return '$sourceText • $statusText';
|
||||
}
|
||||
|
||||
Widget _buildFeatureCard({
|
||||
required IconData icon,
|
||||
required Color iconColor,
|
||||
required Color iconBg,
|
||||
required Color iconBorder,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required bool value,
|
||||
required ValueChanged<bool> onChanged,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.borderSecondary),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: iconBg,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: iconBorder),
|
||||
),
|
||||
child: Icon(icon, size: 18, color: iconColor),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.slate900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: AppColors.slate500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
AppToggleSwitch(value: value, onChanged: onChanged),
|
||||
],
|
||||
),
|
||||
Widget _buildCreateButton() {
|
||||
return AppButton(
|
||||
text: '创建任务',
|
||||
onPressed: () async {
|
||||
await context.push(AppRoutes.settingsJobNew);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
_cubit.loadJobs();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,783 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import 'package:social_app/features/auth/presentation/bloc/auth_event.dart';
|
||||
import 'package:social_app/features/auth/presentation/bloc/auth_state.dart';
|
||||
import 'package:social_app/features/friends/data/friends_api.dart';
|
||||
import 'package:social_app/features/settings/data/settings_api.dart';
|
||||
import 'package:social_app/features/settings/data/services/automation_jobs_api.dart';
|
||||
import 'package:social_app/features/settings/data/services/settings_user_cache.dart';
|
||||
import 'package:social_app/features/users/data/models/user_response.dart';
|
||||
import 'package:social_app/features/home/ui/navigation/home_return_policy.dart';
|
||||
@@ -34,12 +35,15 @@ class SettingsScreen extends StatefulWidget {
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final FriendsApi _friendsApi = sl<FriendsApi>();
|
||||
final AutomationJobsApi _automationJobsApi = sl<AutomationJobsApi>();
|
||||
final SettingsUserCache _userCache = sl<SettingsUserCache>();
|
||||
|
||||
UserResponse? _user;
|
||||
bool _isLoading = true;
|
||||
int _friendsCount = 0;
|
||||
String? _firstFriendName;
|
||||
int _enabledJobsCount = 0;
|
||||
String? _firstEnabledJobTitle;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -83,6 +87,21 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
} catch (e) {
|
||||
// Keep profile available even when contacts fail.
|
||||
}
|
||||
|
||||
try {
|
||||
final jobs = await _automationJobsApi.list();
|
||||
final enabledJobs = jobs.where((job) => job.isActive).toList();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_enabledJobsCount = enabledJobs.length;
|
||||
_firstEnabledJobTitle = enabledJobs.isNotEmpty
|
||||
? enabledJobs.first.title
|
||||
: null;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Keep profile available even when automation jobs fail.
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -298,6 +317,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
return '已添加 $_friendsCount 位联系人';
|
||||
}
|
||||
|
||||
String _buildAutomationSubtitle() {
|
||||
if (_enabledJobsCount == 0) {
|
||||
return '暂无启用计划';
|
||||
}
|
||||
if (_enabledJobsCount == 1) {
|
||||
return '已启用:${_firstEnabledJobTitle ?? '周期计划'}';
|
||||
}
|
||||
return '已启用 $_enabledJobsCount 个计划';
|
||||
}
|
||||
|
||||
Widget _buildQuickActions(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
@@ -314,9 +343,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
Expanded(
|
||||
child: _buildActionCard(
|
||||
icon: Icons.auto_awesome,
|
||||
iconColor: AppColors.violet500,
|
||||
iconColor: AppColors.blue500,
|
||||
title: '周期计划',
|
||||
subtitle: '已启用:会议提醒',
|
||||
subtitle: _buildAutomationSubtitle(),
|
||||
onTap: () => context.push(AppRoutes.settingsFeatures),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user