马良AI写作初始化仓库
This commit is contained in:
611
AINoval/lib/screens/admin/admin_dashboard_screen.dart
Normal file
611
AINoval/lib/screens/admin/admin_dashboard_screen.dart
Normal file
@@ -0,0 +1,611 @@
|
||||
/// 管理后台仪表板
|
||||
/// 包含所有管理功能的导航
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../utils/web_theme.dart';
|
||||
import 'widgets/admin_sidebar.dart';
|
||||
import 'llm_observability_screen.dart';
|
||||
import 'public_model_management_screen.dart';
|
||||
import 'system_presets_management_screen.dart';
|
||||
import 'public_templates_management_screen.dart';
|
||||
import 'enhanced_templates_management_screen.dart';
|
||||
import 'user_management_screen.dart';
|
||||
import 'role_management_screen.dart';
|
||||
import 'subscription_management_screen.dart';
|
||||
import 'billing_audit_screen.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:ainoval/services/api_service/repositories/impl/admin/llm_observability_repository_impl.dart';
|
||||
import 'package:ainoval/widgets/analytics/analytics_card.dart';
|
||||
import 'package:ainoval/widgets/analytics/model_usage_chart.dart';
|
||||
import 'package:ainoval/models/analytics_data.dart';
|
||||
import 'package:ainoval/models/admin/llm_observability_models.dart';
|
||||
|
||||
class AdminDashboardScreen extends StatefulWidget {
|
||||
const AdminDashboardScreen({super.key});
|
||||
|
||||
@override
|
||||
State<AdminDashboardScreen> createState() => _AdminDashboardScreenState();
|
||||
}
|
||||
|
||||
class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
|
||||
int _selectedIndex = 0;
|
||||
|
||||
final List<Widget> _screens = [
|
||||
const AdminOverviewScreen(), // 0: 仪表板
|
||||
const LLMObservabilityScreen(), // 1: LLM可观测性
|
||||
const UserManagementScreen(), // 2: 用户管理(替换占位页)
|
||||
const RoleManagementScreen(), // 3: 角色管理(替换占位页)
|
||||
const SubscriptionManagementScreen(), // 4: 订阅管理(替换占位页)
|
||||
const PublicModelManagementScreen(), // 5: 公共模型
|
||||
const SystemPresetsManagementScreen(), // 6: 系统预设
|
||||
const PublicTemplatesManagementScreen(), // 7: 公共模板
|
||||
const AdminSystemSettingsScreen(), // 8: 系统配置
|
||||
const EnhancedTemplatesManagementScreen(), // 9: 增强模板
|
||||
const BillingAuditScreen(), // 10: 计费审计
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: WebTheme.getBackgroundColor(context),
|
||||
body: Row(
|
||||
children: [
|
||||
AdminSidebar(
|
||||
selectedIndex: _selectedIndex,
|
||||
onItemSelected: (index) {
|
||||
setState(() {
|
||||
_selectedIndex = index;
|
||||
});
|
||||
},
|
||||
),
|
||||
VerticalDivider(
|
||||
thickness: 1,
|
||||
width: 1,
|
||||
color: WebTheme.getBorderColor(context),
|
||||
),
|
||||
Expanded(
|
||||
child: _screens[_selectedIndex],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// AdminNavigationItem 类已移除,现在使用 AdminSidebar 统一管理导航
|
||||
|
||||
/// 管理后台概览页面
|
||||
class AdminOverviewScreen extends StatefulWidget {
|
||||
const AdminOverviewScreen({super.key});
|
||||
|
||||
@override
|
||||
State<AdminOverviewScreen> createState() => _AdminOverviewScreenState();
|
||||
}
|
||||
|
||||
class _AdminOverviewScreenState extends State<AdminOverviewScreen> {
|
||||
late LLMObservabilityRepositoryImpl _repository;
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
|
||||
Map<String, dynamic> _overview = const {};
|
||||
List<ModelUsageData> _modelUsage = const [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_repository = GetIt.instance<LLMObservabilityRepositoryImpl>();
|
||||
_loadData();
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final results = await Future.wait([
|
||||
_repository.getOverviewStatistics(),
|
||||
_repository.getModelStatistics(),
|
||||
]);
|
||||
|
||||
final overview = results[0] as Map<String, dynamic>;
|
||||
final modelStats = results[1] as List<ModelStatistics>;
|
||||
|
||||
setState(() {
|
||||
_overview = overview;
|
||||
_modelUsage = _buildModelUsageFromStats(modelStats);
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
});
|
||||
} finally {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
List<ModelUsageData> _buildModelUsageFromStats(List<ModelStatistics> stats) {
|
||||
if (stats.isEmpty) return const [];
|
||||
// 优先使用 Token 占比,没有则按调用次数占比
|
||||
int totalTokens = 0;
|
||||
for (final s in stats) {
|
||||
totalTokens += s.statistics.totalTokens;
|
||||
}
|
||||
|
||||
final bool useTokens = totalTokens > 0;
|
||||
final int totalBase = useTokens
|
||||
? totalTokens
|
||||
: stats.fold<int>(0, (acc, s) => acc + s.statistics.totalCalls);
|
||||
|
||||
if (totalBase == 0) return const [];
|
||||
|
||||
final List<ModelUsageData> result = [];
|
||||
const palette = ['#3B82F6', '#8B5CF6', '#10B981', '#F59E0B', '#EF4444', '#06B6D4'];
|
||||
for (int i = 0; i < stats.length; i++) {
|
||||
final s = stats[i];
|
||||
final int base = useTokens ? s.statistics.totalTokens : s.statistics.totalCalls;
|
||||
final int pct = ((base / totalBase) * 100).round();
|
||||
result.add(ModelUsageData(
|
||||
modelName: s.modelName,
|
||||
percentage: pct,
|
||||
totalTokens: useTokens ? base : s.statistics.totalTokens,
|
||||
color: palette[i % palette.length],
|
||||
));
|
||||
}
|
||||
// 只取前8个,避免图例过长
|
||||
result.sort((a, b) => b.totalTokens.compareTo(a.totalTokens));
|
||||
return result.take(8).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: WebTheme.getBackgroundColor(context),
|
||||
appBar: AppBar(
|
||||
backgroundColor: WebTheme.getBackgroundColor(context),
|
||||
foregroundColor: WebTheme.getTextColor(context),
|
||||
title: Text(
|
||||
'管理后台仪表板',
|
||||
style: TextStyle(color: WebTheme.getTextColor(context)),
|
||||
),
|
||||
centerTitle: true,
|
||||
elevation: 0,
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _loadData,
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: '刷新',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1600),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: _error != null
|
||||
? Center(child: Text('加载失败: $_error'))
|
||||
: (_loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildOverviewStatsRow(context),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 模型占比图
|
||||
Expanded(
|
||||
child: AnalyticsCard(
|
||||
title: '模型占比',
|
||||
value: '',
|
||||
child: ModelUsageChart(
|
||||
data: _modelUsage,
|
||||
viewMode: AnalyticsViewMode.daily,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// 模块入口卡片区
|
||||
Expanded(
|
||||
child: GridView.count(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 16,
|
||||
childAspectRatio: 1.6,
|
||||
children: const [
|
||||
OverviewCard(
|
||||
title: 'LLM可观测性',
|
||||
description: '监控AI模型调用日志、性能指标和错误统计',
|
||||
icon: Icons.visibility,
|
||||
count: '—',
|
||||
),
|
||||
OverviewCard(
|
||||
title: '用户管理',
|
||||
description: '管理用户账户,查看用户统计信息',
|
||||
icon: Icons.people,
|
||||
count: '—',
|
||||
),
|
||||
OverviewCard(
|
||||
title: '公共模型',
|
||||
description: '管理和配置系统可用的AI模型',
|
||||
icon: Icons.cloud,
|
||||
count: '—',
|
||||
),
|
||||
OverviewCard(
|
||||
title: '系统预设',
|
||||
description: '管理系统级别的预设配置',
|
||||
icon: Icons.smart_button,
|
||||
count: '—',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: GridView.count(
|
||||
crossAxisCount: 3,
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 16,
|
||||
childAspectRatio: 1.2,
|
||||
children: const [
|
||||
OverviewCard(
|
||||
title: '公共模板',
|
||||
description: '管理公共提示词模板库',
|
||||
icon: Icons.article,
|
||||
count: '—',
|
||||
),
|
||||
OverviewCard(
|
||||
title: '增强模板',
|
||||
description: '管理用户提交的增强模板',
|
||||
icon: Icons.auto_awesome,
|
||||
count: '—',
|
||||
),
|
||||
OverviewCard(
|
||||
title: '订阅管理',
|
||||
description: '订阅套餐、配额与结算',
|
||||
icon: Icons.subscriptions,
|
||||
count: '—',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOverviewStatsRow(BuildContext context) {
|
||||
final totalCalls = (_overview['totalCalls'] ?? 0).toString();
|
||||
final successfulCalls = (_overview['successfulCalls'] ?? 0).toString();
|
||||
final failedCalls = (_overview['failedCalls'] ?? 0).toString();
|
||||
final successRate = ((_overview['successRate'] ?? 0.0) as num).toDouble();
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AnalyticsOverviewCard(
|
||||
title: '总调用次数',
|
||||
value: totalCalls,
|
||||
icon: Icons.analytics,
|
||||
subtitle: '统计范围内的模型调用总数',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: AnalyticsOverviewCard(
|
||||
title: '成功次数',
|
||||
value: successfulCalls,
|
||||
icon: Icons.check_circle,
|
||||
subtitle: '无错误完成的调用次数',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: AnalyticsOverviewCard(
|
||||
title: '失败次数',
|
||||
value: failedCalls,
|
||||
icon: Icons.error_outline,
|
||||
subtitle: '发生错误的调用次数',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: AnalyticsOverviewCard(
|
||||
title: '成功率',
|
||||
value: '${successRate.toStringAsFixed(1)}%',
|
||||
icon: Icons.percent,
|
||||
subtitle: '成功调用占比',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class OverviewCard extends StatelessWidget {
|
||||
final String title;
|
||||
final String description;
|
||||
final IconData icon;
|
||||
final String? count;
|
||||
|
||||
const OverviewCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.icon,
|
||||
this.count,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
color: WebTheme.getCardColor(context),
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 32,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
const Spacer(),
|
||||
if (count != null)
|
||||
Text(
|
||||
count!,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
description,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 占位页面 - 用户管理
|
||||
class AdminUsersScreen extends StatelessWidget {
|
||||
const AdminUsersScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: WebTheme.getBackgroundColor(context),
|
||||
appBar: AppBar(
|
||||
backgroundColor: WebTheme.getBackgroundColor(context),
|
||||
foregroundColor: WebTheme.getTextColor(context),
|
||||
title: Text(
|
||||
'用户管理',
|
||||
style: TextStyle(color: WebTheme.getTextColor(context)),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.people_outline,
|
||||
size: 64,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'用户管理页面',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'此功能正在开发中...',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 占位页面 - 角色管理
|
||||
class AdminRolesScreen extends StatelessWidget {
|
||||
const AdminRolesScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: WebTheme.getBackgroundColor(context),
|
||||
appBar: AppBar(
|
||||
backgroundColor: WebTheme.getBackgroundColor(context),
|
||||
foregroundColor: WebTheme.getTextColor(context),
|
||||
title: Text(
|
||||
'角色管理',
|
||||
style: TextStyle(color: WebTheme.getTextColor(context)),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.security_outlined,
|
||||
size: 64,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'角色管理页面',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'此功能正在开发中...',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 占位页面 - 订阅管理
|
||||
class AdminSubscriptionScreen extends StatelessWidget {
|
||||
const AdminSubscriptionScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: WebTheme.getBackgroundColor(context),
|
||||
appBar: AppBar(
|
||||
backgroundColor: WebTheme.getBackgroundColor(context),
|
||||
foregroundColor: WebTheme.getTextColor(context),
|
||||
title: Text(
|
||||
'订阅管理',
|
||||
style: TextStyle(color: WebTheme.getTextColor(context)),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.subscriptions_outlined,
|
||||
size: 64,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'订阅管理页面',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'此功能正在开发中...',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 占位页面 - 系统设置
|
||||
class AdminSystemSettingsScreen extends StatelessWidget {
|
||||
const AdminSystemSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: WebTheme.getBackgroundColor(context),
|
||||
appBar: AppBar(
|
||||
backgroundColor: WebTheme.getBackgroundColor(context),
|
||||
foregroundColor: WebTheme.getTextColor(context),
|
||||
title: Text(
|
||||
'系统配置',
|
||||
style: TextStyle(color: WebTheme.getTextColor(context)),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.settings_outlined,
|
||||
size: 64,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'系统配置页面',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'此功能正在开发中...',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
284
AINoval/lib/screens/admin/admin_login_screen.dart
Normal file
284
AINoval/lib/screens/admin/admin_login_screen.dart
Normal file
@@ -0,0 +1,284 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../../blocs/admin/admin_bloc.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import '../../services/api_service/repositories/impl/admin_repository_impl.dart';
|
||||
import 'admin_dashboard_screen.dart';
|
||||
import '../../services/permission_service.dart';
|
||||
import '../../models/admin/admin_models.dart' show AdminUser;
|
||||
import '../../utils/logger.dart';
|
||||
|
||||
class AdminLoginScreen extends StatefulWidget {
|
||||
const AdminLoginScreen({super.key});
|
||||
|
||||
@override
|
||||
State<AdminLoginScreen> createState() => _AdminLoginScreenState();
|
||||
}
|
||||
|
||||
class _AdminLoginScreenState extends State<AdminLoginScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _usernameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
bool _isLoading = false;
|
||||
bool _obscurePassword = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _handleLogin() async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final username = _usernameController.text.trim();
|
||||
final password = _passwordController.text;
|
||||
|
||||
// 使用真实的管理员API登录
|
||||
final adminRepository = GetIt.instance<AdminRepositoryImpl>();
|
||||
final authResponse = await adminRepository.adminLogin(username, password);
|
||||
|
||||
// 设置管理员认证信息
|
||||
AppConfig.setAuthToken(authResponse.token);
|
||||
AppConfig.setUserId(authResponse.userId);
|
||||
AppConfig.setUsername(authResponse.username);
|
||||
|
||||
// 保存管理员信息到本地,用于权限校验和会话持久化
|
||||
try {
|
||||
final permissionService = PermissionService();
|
||||
await permissionService.saveAdminInfo(
|
||||
AdminUser(
|
||||
id: authResponse.userId,
|
||||
username: authResponse.username,
|
||||
email: '', // 登录响应暂未提供邮箱信息,可按需调整
|
||||
displayName: authResponse.displayName,
|
||||
accountStatus: 'ACTIVE',
|
||||
credits: 0,
|
||||
roles: authResponse.roles.isNotEmpty ? authResponse.roles : ['ADMIN'],
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: null,
|
||||
),
|
||||
authResponse.token,
|
||||
);
|
||||
} catch (e) {
|
||||
// 若保存失败,仅记录日志,不阻断登录流程
|
||||
AppLogger.w('AdminLogin', '保存管理员信息失败', e);
|
||||
}
|
||||
|
||||
// 跳转到管理后台
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => BlocProvider(
|
||||
create: (context) => GetIt.instance<AdminBloc>(),
|
||||
child: const AdminDashboardScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
String errorMessage = '登录失败';
|
||||
if (e.toString().contains('用户名或密码错误')) {
|
||||
errorMessage = '用户名或密码错误,或无管理员权限';
|
||||
} else if (e.toString().contains('connection')) {
|
||||
errorMessage = '无法连接到服务器,请检查网络连接';
|
||||
} else {
|
||||
errorMessage = '登录失败: ${e.toString()}';
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(errorMessage),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
Theme.of(context).colorScheme.primary.withOpacity(0.1),
|
||||
Theme.of(context).colorScheme.secondary.withOpacity(0.1),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Card(
|
||||
elevation: 8,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Container(
|
||||
width: 400,
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Logo和标题
|
||||
Icon(
|
||||
Icons.admin_panel_settings,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'AI小说助手',
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'管理后台',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// 用户名输入框
|
||||
TextFormField(
|
||||
controller: _usernameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '用户名',
|
||||
prefixIcon: const Icon(Icons.person),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return '请输入用户名';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
enabled: !_isLoading,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 密码输入框
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: '密码',
|
||||
prefixIcon: const Icon(Icons.lock),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword ? Icons.visibility : Icons.visibility_off,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入密码';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
enabled: !_isLoading,
|
||||
onFieldSubmitted: (_) => _handleLogin(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 登录按钮
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isLoading ? null : _handleLogin,
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text(
|
||||
'登录',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 提示信息
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'开发环境默认账号: admin / admin123',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
165
AINoval/lib/screens/admin/billing_audit_screen.dart
Normal file
165
AINoval/lib/screens/admin/billing_audit_screen.dart
Normal file
@@ -0,0 +1,165 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:ainoval/models/admin/billing_models.dart';
|
||||
import 'package:ainoval/services/api_service/repositories/impl/admin/billing_repository_impl.dart';
|
||||
|
||||
class BillingAuditScreen extends StatefulWidget {
|
||||
const BillingAuditScreen({super.key});
|
||||
|
||||
@override
|
||||
State<BillingAuditScreen> createState() => _BillingAuditScreenState();
|
||||
}
|
||||
|
||||
class _BillingAuditScreenState extends State<BillingAuditScreen> {
|
||||
late BillingRepositoryImpl _repo;
|
||||
int _page = 0;
|
||||
final int _size = 20;
|
||||
String? _status;
|
||||
String? _userId;
|
||||
bool _loading = false;
|
||||
List<CreditTransactionModel> _items = const [];
|
||||
int _total = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_repo = GetIt.instance<BillingRepositoryImpl>();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() { _loading = true; });
|
||||
try {
|
||||
final results = await Future.wait([
|
||||
_repo.listTransactions(page: _page, size: _size, status: _status, userId: _userId),
|
||||
_repo.countTransactions(status: _status, userId: _userId),
|
||||
]);
|
||||
setState(() {
|
||||
_items = results[0] as List<CreditTransactionModel>;
|
||||
_total = results[1] as int;
|
||||
});
|
||||
} finally {
|
||||
if (mounted) setState(() { _loading = false; });
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _reverse(CreditTransactionModel tx) async {
|
||||
final controller = TextEditingController();
|
||||
final reason = await showDialog<String>(context: context, builder: (ctx) {
|
||||
return AlertDialog(
|
||||
title: const Text('输入冲正原因'),
|
||||
content: TextField(controller: controller, decoration: const InputDecoration(hintText: '原因...')),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('取消')),
|
||||
ElevatedButton(onPressed: () => Navigator.pop(ctx, controller.text.trim()), child: const Text('确认')),
|
||||
],
|
||||
);
|
||||
});
|
||||
if (reason == null || reason.isEmpty) return;
|
||||
await _repo.reverse(tx.traceId, operatorUserId: 'admin', reason: reason);
|
||||
await _load();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: WebTheme.getBackgroundColor(context),
|
||||
appBar: AppBar(
|
||||
backgroundColor: WebTheme.getBackgroundColor(context),
|
||||
foregroundColor: WebTheme.getTextColor(context),
|
||||
title: Text('计费审计', style: TextStyle(color: WebTheme.getTextColor(context))),
|
||||
actions: [
|
||||
IconButton(onPressed: _load, icon: const Icon(Icons.refresh)),
|
||||
],
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(children: [
|
||||
SizedBox(
|
||||
width: 200,
|
||||
child: DropdownButtonFormField<String>(
|
||||
decoration: const InputDecoration(labelText: '状态'),
|
||||
value: _status,
|
||||
items: const [
|
||||
DropdownMenuItem(value: null, child: Text('全部')),
|
||||
DropdownMenuItem(value: 'PENDING', child: Text('PENDING')),
|
||||
DropdownMenuItem(value: 'FAILED', child: Text('FAILED')),
|
||||
DropdownMenuItem(value: 'DEDUCTED', child: Text('DEDUCTED')),
|
||||
DropdownMenuItem(value: 'COMPENSATED', child: Text('COMPENSATED')),
|
||||
],
|
||||
onChanged: (v) { setState(() { _status = v; _page = 0; }); _load(); },
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
SizedBox(
|
||||
width: 260,
|
||||
child: TextField(
|
||||
decoration: const InputDecoration(labelText: '用户ID'),
|
||||
onSubmitted: (v) { setState(() { _userId = v.trim().isEmpty ? null : v.trim(); _page = 0; }); _load(); },
|
||||
),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: SingleChildScrollView(
|
||||
child: DataTable(
|
||||
columns: const [
|
||||
DataColumn(label: Text('TraceID')),
|
||||
DataColumn(label: Text('User')),
|
||||
DataColumn(label: Text('Model')),
|
||||
DataColumn(label: Text('Feature')),
|
||||
DataColumn(label: Text('In/Out')),
|
||||
DataColumn(label: Text('Credits')),
|
||||
DataColumn(label: Text('Status')),
|
||||
DataColumn(label: Text('Action')),
|
||||
],
|
||||
rows: _items.map((tx) {
|
||||
final model = [tx.provider ?? '-', tx.modelId ?? '-'].where((e) => e != '-').join(':');
|
||||
final io = '${tx.inputTokens ?? 0}/${tx.outputTokens ?? 0}';
|
||||
final canReverse = tx.status == 'DEDUCTED' || tx.status == 'COMPENSATED';
|
||||
return DataRow(cells: [
|
||||
DataCell(Text(tx.traceId, overflow: TextOverflow.ellipsis)),
|
||||
DataCell(Text(tx.userId ?? '-')),
|
||||
DataCell(Text(model.isEmpty ? '-' : model)),
|
||||
DataCell(Text(tx.featureType ?? '-')),
|
||||
DataCell(Text(io)),
|
||||
DataCell(Text('${tx.creditsDeducted ?? 0}')),
|
||||
DataCell(Text(tx.status)),
|
||||
DataCell(Row(children: [
|
||||
if (canReverse) ElevatedButton.icon(onPressed: () => _reverse(tx), icon: const Icon(Icons.undo), label: const Text('冲正')),
|
||||
])),
|
||||
]);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Text('共 $_total 条'),
|
||||
const SizedBox(width: 16),
|
||||
IconButton(
|
||||
onPressed: _page > 0 ? () { setState(() { _page--; }); _load(); } : null,
|
||||
icon: const Icon(Icons.chevron_left),
|
||||
),
|
||||
Text('${_page + 1}'),
|
||||
IconButton(
|
||||
onPressed: ((_page + 1) * _size) < _total ? () { setState(() { _page++; }); _load(); } : null,
|
||||
icon: const Icon(Icons.chevron_right),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1313
AINoval/lib/screens/admin/enhanced_templates_management_screen.dart
Normal file
1313
AINoval/lib/screens/admin/enhanced_templates_management_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
2332
AINoval/lib/screens/admin/llm_observability_screen.dart
Normal file
2332
AINoval/lib/screens/admin/llm_observability_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
727
AINoval/lib/screens/admin/public_model_management_screen.dart
Normal file
727
AINoval/lib/screens/admin/public_model_management_screen.dart
Normal file
@@ -0,0 +1,727 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import '../../config/provider_icons.dart';
|
||||
import '../../models/public_model_config.dart';
|
||||
import '../../services/api_service/repositories/impl/admin_repository_impl.dart';
|
||||
import '../../utils/logger.dart';
|
||||
import '../../utils/web_theme.dart';
|
||||
import '../../widgets/common/error_view.dart';
|
||||
import '../../widgets/common/loading_indicator.dart';
|
||||
import 'widgets/add_public_model_dialog.dart';
|
||||
import 'widgets/edit_public_model_dialog.dart';
|
||||
import 'widgets/public_model_provider_group_card.dart';
|
||||
import 'widgets/validation_results_dialog.dart';
|
||||
import '../../widgets/common/top_toast.dart';
|
||||
|
||||
/// 公共模型管理页面
|
||||
/// 提供完整的公共AI模型配置管理功能,包括:
|
||||
/// - 按供应商分组显示所有可用提供商
|
||||
/// - 在每个提供商分组下显示已配置的公共模型
|
||||
/// - 添加/编辑/删除模型配置
|
||||
/// - API Key池管理
|
||||
/// - 模型验证和状态管理
|
||||
class PublicModelManagementScreen extends StatefulWidget {
|
||||
const PublicModelManagementScreen({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<PublicModelManagementScreen> createState() => _PublicModelManagementScreenState();
|
||||
}
|
||||
|
||||
/// 公共模型管理内容主体,可以在不同布局中复用
|
||||
class PublicModelManagementBody extends StatefulWidget {
|
||||
const PublicModelManagementBody({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<PublicModelManagementBody> createState() => _PublicModelManagementBodyState();
|
||||
}
|
||||
|
||||
class _PublicModelManagementScreenState extends State<PublicModelManagementScreen> {
|
||||
final GlobalKey<_PublicModelManagementBodyState> _bodyKey = GlobalKey<_PublicModelManagementBodyState>();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: WebTheme.getBackgroundColor(context),
|
||||
foregroundColor: WebTheme.getTextColor(context),
|
||||
title: Text(
|
||||
'公共模型管理',
|
||||
style: TextStyle(color: WebTheme.getTextColor(context)),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => _bodyKey.currentState?._refreshData(),
|
||||
icon: Icon(Icons.refresh, color: WebTheme.getTextColor(context)),
|
||||
tooltip: '刷新',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => _showAddModelDialog(context),
|
||||
icon: Icon(Icons.add, color: WebTheme.getTextColor(context)),
|
||||
tooltip: '添加模型',
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: WebTheme.getBackgroundColor(context),
|
||||
body: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1600),
|
||||
child: PublicModelManagementBody(key: _bodyKey),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAddModelDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AddPublicModelDialog(
|
||||
onSuccess: () => _bodyKey.currentState?._refreshData(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PublicModelManagementBodyState extends State<PublicModelManagementBody> {
|
||||
List<PublicModelConfigDetails> _modelConfigs = [];
|
||||
List<String> _availableProviders = [];
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
String _searchQuery = '';
|
||||
String _filterValue = 'all';
|
||||
Map<String, bool> _expandedProviders = {};
|
||||
|
||||
late final AdminRepositoryImpl _adminRepository;
|
||||
final String _tag = 'PublicModelManagementScreen';
|
||||
|
||||
// 缓存机制
|
||||
DateTime? _lastLoadTime;
|
||||
static const Duration _cacheValidDuration = Duration(minutes: 3);
|
||||
bool _isInitialLoad = true;
|
||||
|
||||
bool get _shouldRefreshConfigs {
|
||||
if (_lastLoadTime == null || _isInitialLoad) return true;
|
||||
return DateTime.now().difference(_lastLoadTime!) > _cacheValidDuration;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_adminRepository = AdminRepositoryImpl();
|
||||
_loadData();
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
// 先加载可用供应商,然后加载模型配置
|
||||
await _loadAvailableProviders();
|
||||
await _loadModelConfigs();
|
||||
}
|
||||
|
||||
Future<void> _loadAvailableProviders() async {
|
||||
if (!mounted) return;
|
||||
|
||||
// 开始加载可用供应商
|
||||
|
||||
try {
|
||||
AppLogger.d(_tag, '开始加载可用供应商列表');
|
||||
final providers = await _adminRepository.getAvailableProviders();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_availableProviders = providers;
|
||||
// 默认展开所有供应商
|
||||
for (final provider in providers) {
|
||||
_expandedProviders[provider] ??= true;
|
||||
}
|
||||
});
|
||||
|
||||
AppLogger.d(_tag, '成功加载 ${providers.length} 个供应商');
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '加载供应商列表失败', e);
|
||||
// 忽略加载状态更新,无需标记供应商加载中
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadModelConfigs() async {
|
||||
if (!_shouldRefreshConfigs) {
|
||||
AppLogger.d(_tag, '使用缓存数据,跳过重新加载');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
AppLogger.d(_tag, '开始加载公共模型配置列表');
|
||||
_lastLoadTime = DateTime.now();
|
||||
_isInitialLoad = false;
|
||||
|
||||
final configs = await _adminRepository.getPublicModelConfigDetails();
|
||||
|
||||
AppLogger.d(_tag, '📊 原始配置数据: ${configs.length} 个');
|
||||
for (int i = 0; i < configs.length && i < 3; i++) {
|
||||
final config = configs[i];
|
||||
AppLogger.d(_tag, '📊 配置 $i: provider=${config.provider}, modelId=${config.modelId}, enabled=${config.enabled}, id=${config.id}');
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_modelConfigs = configs;
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
// 提示:可为公共模型打标签以用于后端选择策略(示例:"jsonify"/"cheap"/"fast")
|
||||
// - jsonify:适配“文本→JSON结构化工具”阶段优先选择
|
||||
// - cheap:成本优先
|
||||
// - fast:时延优先
|
||||
// 管理员可在“编辑模型”中为配置添加上述 tags,后端会在第二阶段依据标签和 priority 挑选。
|
||||
|
||||
AppLogger.d(_tag, '✅ 成功加载 ${configs.length} 个公共模型配置,界面状态已更新');
|
||||
|
||||
// 检查分组结果
|
||||
final grouped = _groupConfigsByProvider();
|
||||
AppLogger.d(_tag, '📊 分组结果: ${grouped.length} 个供应商,${grouped.values.expand((list) => list).length} 个配置');
|
||||
grouped.forEach((provider, configList) {
|
||||
AppLogger.d(_tag, '📊 供应商 $provider: ${configList.length} 个配置');
|
||||
});
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.e(_tag, '加载公共模型配置失败', e);
|
||||
AppLogger.e(_tag, '错误堆栈', stackTrace);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = '加载公共模型配置失败: ${e.toString()}';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSearch(String query) {
|
||||
setState(() {
|
||||
_searchQuery = query.toLowerCase();
|
||||
});
|
||||
}
|
||||
|
||||
void _handleFilterChange(String value) {
|
||||
setState(() {
|
||||
_filterValue = value;
|
||||
});
|
||||
}
|
||||
|
||||
void _handleToggleProvider(String provider) {
|
||||
setState(() {
|
||||
_expandedProviders[provider] = !(_expandedProviders[provider] ?? true);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _handleValidate(String configId) async {
|
||||
try {
|
||||
AppLogger.d(_tag, '开始验证模型配置: $configId');
|
||||
|
||||
TopToast.info(context, '正在验证模型配置...');
|
||||
|
||||
final withKeys = await _adminRepository.validatePublicModelConfigAndFetchWithKeys(configId);
|
||||
|
||||
if (!mounted) return;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => ValidationResultsDialog(config: withKeys),
|
||||
);
|
||||
|
||||
AppLogger.d(_tag, '模型配置验证成功: $configId');
|
||||
_refreshData();
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '模型配置验证失败', e);
|
||||
TopToast.error(context, '验证失败: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleToggleStatus(String configId, bool enabled) async {
|
||||
try {
|
||||
AppLogger.d(_tag, '切换模型配置状态: $configId -> $enabled');
|
||||
|
||||
await _adminRepository.togglePublicModelConfigStatus(configId, enabled);
|
||||
|
||||
TopToast.success(context, enabled ? '模型已启用' : '模型已禁用');
|
||||
|
||||
AppLogger.d(_tag, '模型配置状态切换成功: $configId');
|
||||
_refreshData();
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '切换模型配置状态失败', e);
|
||||
TopToast.error(context, '操作失败: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
void _handleEdit(String configId) {
|
||||
final config = _modelConfigs.firstWhereOrNull((c) => c.id == configId);
|
||||
if (config == null) return;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => EditPublicModelDialog(
|
||||
config: config,
|
||||
onSuccess: _refreshData,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleCopy(String configId) {
|
||||
final config = _modelConfigs.firstWhereOrNull((c) => c.id == configId);
|
||||
if (config == null) return;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AddPublicModelDialog(
|
||||
onSuccess: _refreshData,
|
||||
selectedProvider: config.provider,
|
||||
sourceConfig: config, // 传递源配置用于复制
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleDelete(String configId) {
|
||||
final config = _modelConfigs.firstWhereOrNull((c) => c.id == configId);
|
||||
if (config == null) return;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: WebTheme.getCardColor(context),
|
||||
title: Text(
|
||||
'确认删除',
|
||||
style: TextStyle(color: WebTheme.getTextColor(context)),
|
||||
),
|
||||
content: Text(
|
||||
'确定要删除模型配置 "${config.displayName ?? config.modelId}" 吗?此操作不可恢复。',
|
||||
style: TextStyle(color: WebTheme.getTextColor(context)),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text('取消', style: TextStyle(color: WebTheme.getTextColor(context))),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_deleteModelConfig(configId);
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('删除'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteModelConfig(String configId) async {
|
||||
try {
|
||||
AppLogger.d(_tag, '开始删除模型配置: $configId');
|
||||
|
||||
TopToast.info(context, '正在删除模型配置...');
|
||||
|
||||
await _adminRepository.deletePublicModelConfig(configId);
|
||||
|
||||
TopToast.success(context, '模型配置删除成功');
|
||||
|
||||
AppLogger.d(_tag, '模型配置删除成功: $configId');
|
||||
_refreshData();
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '删除模型配置失败', e);
|
||||
TopToast.error(context, '删除失败: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
void _handleAddModel(String provider) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AddPublicModelDialog(
|
||||
onSuccess: _refreshData,
|
||||
selectedProvider: provider,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _refreshData() {
|
||||
_lastLoadTime = null; // 使缓存失效
|
||||
_loadData();
|
||||
}
|
||||
|
||||
// 按提供商分组配置 - 显示所有可用提供商
|
||||
Map<String, List<PublicModelConfigDetails>> _groupConfigsByProvider() {
|
||||
final Map<String, List<PublicModelConfigDetails>> grouped = {};
|
||||
|
||||
// 首先为所有可用提供商创建空列表
|
||||
for (final provider in _availableProviders) {
|
||||
grouped[provider] = [];
|
||||
}
|
||||
|
||||
// 然后将配置分组到对应的提供商
|
||||
for (final config in _modelConfigs) {
|
||||
final provider = config.provider;
|
||||
if (grouped.containsKey(provider)) {
|
||||
grouped[provider]!.add(config);
|
||||
} else {
|
||||
// 如果配置的提供商不在可用列表中,也要显示
|
||||
grouped[provider] = [config];
|
||||
}
|
||||
}
|
||||
|
||||
// 应用搜索和过滤
|
||||
if (_searchQuery.isNotEmpty || _filterValue != 'all') {
|
||||
final filteredGrouped = <String, List<PublicModelConfigDetails>>{};
|
||||
|
||||
for (final entry in grouped.entries) {
|
||||
final provider = entry.key;
|
||||
final configs = entry.value;
|
||||
|
||||
// 检查提供商名称是否匹配搜索
|
||||
final providerMatches = _searchQuery.isEmpty ||
|
||||
provider.toLowerCase().contains(_searchQuery) ||
|
||||
ProviderIcons.getProviderDisplayName(provider).toLowerCase().contains(_searchQuery);
|
||||
|
||||
// 过滤配置
|
||||
final filteredConfigs = configs.where((config) {
|
||||
final matchesSearch = _searchQuery.isEmpty ||
|
||||
(config.displayName?.toLowerCase().contains(_searchQuery) ?? false) ||
|
||||
config.modelId.toLowerCase().contains(_searchQuery);
|
||||
|
||||
bool matchesFilter = true;
|
||||
if (_filterValue == 'enabled') {
|
||||
matchesFilter = config.enabled == true;
|
||||
} else if (_filterValue == 'disabled') {
|
||||
matchesFilter = config.enabled != true;
|
||||
} else if (_filterValue == 'validated') {
|
||||
matchesFilter = config.isValidated == true;
|
||||
} else if (_filterValue == 'unvalidated') {
|
||||
matchesFilter = config.isValidated != true;
|
||||
}
|
||||
|
||||
return matchesSearch && matchesFilter;
|
||||
}).toList();
|
||||
|
||||
// 如果提供商匹配搜索或者有匹配的配置,则显示该提供商
|
||||
if (providerMatches || filteredConfigs.isNotEmpty) {
|
||||
filteredGrouped[provider] = filteredConfigs;
|
||||
}
|
||||
}
|
||||
|
||||
return filteredGrouped;
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
// 获取提供商信息
|
||||
Map<String, dynamic> _getProviderInfo(String provider) {
|
||||
return {
|
||||
'name': ProviderIcons.getProviderDisplayName(provider),
|
||||
'description': _getProviderDescription(provider),
|
||||
'color': ProviderIcons.getProviderColor(provider),
|
||||
};
|
||||
}
|
||||
|
||||
// 获取提供商描述
|
||||
String _getProviderDescription(String provider) {
|
||||
switch (provider.toLowerCase()) {
|
||||
case 'openai':
|
||||
return 'Advanced language models for various applications';
|
||||
case 'anthropic':
|
||||
return 'Constitutional AI models focused on safety';
|
||||
case 'google':
|
||||
case 'gemini':
|
||||
return 'Gemini models and PaLM-based systems';
|
||||
case 'openrouter':
|
||||
return 'Unified API for multiple AI models';
|
||||
case 'ollama':
|
||||
return 'Local AI models runner';
|
||||
case 'microsoft':
|
||||
case 'azure':
|
||||
return 'Microsoft Azure OpenAI Service';
|
||||
case 'meta':
|
||||
case 'llama':
|
||||
return 'Large Language Model Meta AI';
|
||||
case 'deepseek':
|
||||
return 'DeepSeek AI language models';
|
||||
case 'zhipu':
|
||||
case 'glm':
|
||||
return 'GLM and ChatGLM models';
|
||||
case 'qwen':
|
||||
case 'tongyi':
|
||||
return 'Alibaba Tongyi Qianwen models';
|
||||
case 'doubao':
|
||||
case 'bytedance':
|
||||
return 'ByteDance Doubao AI models';
|
||||
case 'mistral':
|
||||
return 'Mistral AI language models';
|
||||
case 'perplexity':
|
||||
return 'Perplexity AI search and reasoning';
|
||||
case 'huggingface':
|
||||
case 'hf':
|
||||
return 'Hugging Face model hub and inference';
|
||||
case 'stability':
|
||||
return 'Stability AI generative models';
|
||||
case 'xai':
|
||||
case 'grok':
|
||||
return 'xAI Grok conversational AI';
|
||||
case 'siliconcloud':
|
||||
case 'siliconflow':
|
||||
return 'SiliconCloud AI model services';
|
||||
default:
|
||||
return 'AI model provider';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
// 搜索和过滤头部
|
||||
_buildHeader(),
|
||||
|
||||
// 内容区域
|
||||
Expanded(
|
||||
child: _buildContent(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getCardColor(context),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 搜索框
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
onChanged: _handleSearch,
|
||||
style: TextStyle(color: WebTheme.getTextColor(context)),
|
||||
decoration: InputDecoration(
|
||||
hintText: '搜索模型或提供商...',
|
||||
hintStyle: TextStyle(color: WebTheme.getSecondaryTextColor(context)),
|
||||
prefixIcon: Icon(Icons.search, color: WebTheme.getSecondaryTextColor(context)),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: WebTheme.getBorderColor(context)),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: WebTheme.getTextColor(context)),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// 过滤下拉框
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: WebTheme.getBorderColor(context)),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: DropdownButton<String>(
|
||||
value: _filterValue,
|
||||
onChanged: (value) => _handleFilterChange(value!),
|
||||
dropdownColor: WebTheme.getCardColor(context),
|
||||
style: TextStyle(color: WebTheme.getTextColor(context)),
|
||||
underline: Container(),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'all', child: Text('全部')),
|
||||
DropdownMenuItem(value: 'enabled', child: Text('已启用')),
|
||||
DropdownMenuItem(value: 'disabled', child: Text('已禁用')),
|
||||
DropdownMenuItem(value: 'validated', child: Text('已验证')),
|
||||
DropdownMenuItem(value: 'unvalidated', child: Text('未验证')),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// 刷新按钮
|
||||
IconButton(
|
||||
onPressed: _refreshData,
|
||||
icon: Icon(Icons.refresh, color: WebTheme.getTextColor(context)),
|
||||
tooltip: '刷新',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 统计信息
|
||||
Row(
|
||||
children: [
|
||||
_buildStatChip(
|
||||
'总配置: ${_modelConfigs.length}',
|
||||
Colors.blue,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildStatChip(
|
||||
'供应商: ${_availableProviders.length}',
|
||||
Colors.green,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildStatChip(
|
||||
'已启用: ${_modelConfigs.where((c) => c.enabled == true).length}',
|
||||
Colors.orange,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatChip(String label, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: color.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent() {
|
||||
AppLogger.d(_tag, '🎨 构建内容: isLoading=$_isLoading, modelConfigs.length=${_modelConfigs.length}, availableProviders.length=${_availableProviders.length}, error=$_error');
|
||||
|
||||
if (_isLoading && _modelConfigs.isEmpty) {
|
||||
AppLogger.d(_tag, '🎨 显示加载指示器');
|
||||
return const Center(child: LoadingIndicator());
|
||||
}
|
||||
|
||||
if (_error != null && _modelConfigs.isEmpty && _availableProviders.isEmpty) {
|
||||
AppLogger.d(_tag, '🎨 显示错误视图: $_error');
|
||||
return ErrorView(
|
||||
error: _error!,
|
||||
onRetry: _refreshData,
|
||||
);
|
||||
}
|
||||
|
||||
final groupedConfigs = _groupConfigsByProvider();
|
||||
AppLogger.d(_tag, '🎨 分组配置: ${groupedConfigs.length} 个供应商');
|
||||
|
||||
if (groupedConfigs.isEmpty) {
|
||||
AppLogger.d(_tag, '🎨 显示空状态 (搜索: $_searchQuery, 过滤: $_filterValue)');
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.cloud_off,
|
||||
size: 64,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_searchQuery.isNotEmpty || _filterValue != 'all'
|
||||
? '没有找到匹配的供应商或模型配置'
|
||||
: '暂无可用的AI供应商',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// 添加调试信息
|
||||
if (_modelConfigs.isNotEmpty || _availableProviders.isNotEmpty)
|
||||
Column(
|
||||
children: [
|
||||
Text(
|
||||
'调试信息: 模型配置=${_modelConfigs.length}, 供应商=${_availableProviders.length}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'搜索="$_searchQuery", 过滤="$_filterValue"',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_searchQuery.isEmpty && _filterValue == 'all')
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _handleAddModel(''),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('添加公共模型'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: WebTheme.getTextColor(context),
|
||||
foregroundColor: WebTheme.getBackgroundColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
AppLogger.d(_tag, '🎨 显示供应商列表: ${groupedConfigs.length} 个');
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: groupedConfigs.length,
|
||||
itemBuilder: (context, index) {
|
||||
final provider = groupedConfigs.keys.elementAt(index);
|
||||
final configs = groupedConfigs[provider]!;
|
||||
final providerInfo = _getProviderInfo(provider);
|
||||
final isExpanded = _expandedProviders[provider] ?? true;
|
||||
|
||||
AppLogger.d(_tag, '🎨 构建供应商卡片 $index: $provider (${configs.length} 个配置)');
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: PublicModelProviderGroupCard(
|
||||
provider: provider,
|
||||
providerName: providerInfo['name'],
|
||||
description: providerInfo['description'],
|
||||
configs: configs,
|
||||
isExpanded: isExpanded,
|
||||
onToggleExpanded: () => _handleToggleProvider(provider),
|
||||
onAddModel: () => _handleAddModel(provider),
|
||||
onValidate: _handleValidate,
|
||||
onEdit: _handleEdit,
|
||||
onDelete: _handleDelete,
|
||||
onToggleStatus: _handleToggleStatus,
|
||||
onCopy: _handleCopy,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,874 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import '../../models/prompt_models.dart';
|
||||
import '../../services/api_service/repositories/impl/admin_repository_impl.dart';
|
||||
import '../../utils/logger.dart';
|
||||
import '../../services/api_service/repositories/impl/admin_repository_templates_extension.dart';
|
||||
import '../../widgets/common/loading_indicator.dart';
|
||||
import 'widgets/public_template_card.dart';
|
||||
import 'widgets/add_official_template_dialog.dart';
|
||||
import 'widgets/template_statistics_dialog.dart';
|
||||
import 'widgets/template_details_dialog.dart'; // Added import for TemplateDetailsDialog
|
||||
import 'widgets/edit_template_dialog.dart';
|
||||
|
||||
/// 公共模板管理页面
|
||||
class PublicTemplatesManagementScreen extends StatefulWidget {
|
||||
const PublicTemplatesManagementScreen({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<PublicTemplatesManagementScreen> createState() => _PublicTemplatesManagementScreenState();
|
||||
}
|
||||
|
||||
class _PublicTemplatesManagementScreenState extends State<PublicTemplatesManagementScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const PublicTemplatesManagementBody();
|
||||
}
|
||||
}
|
||||
|
||||
/// 公共模板管理页面主体
|
||||
class PublicTemplatesManagementBody extends StatefulWidget {
|
||||
const PublicTemplatesManagementBody({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<PublicTemplatesManagementBody> createState() => _PublicTemplatesManagementBodyState();
|
||||
}
|
||||
|
||||
class _PublicTemplatesManagementBodyState extends State<PublicTemplatesManagementBody>
|
||||
with TickerProviderStateMixin {
|
||||
final AdminRepositoryImpl _adminRepository = AdminRepositoryImpl();
|
||||
late TabController _tabController;
|
||||
|
||||
List<PromptTemplate> _templates = [];
|
||||
List<PromptTemplate> _selectedTemplates = [];
|
||||
bool _isLoading = true;
|
||||
bool _batchMode = false;
|
||||
String? _error;
|
||||
String _searchQuery = '';
|
||||
String _currentTab = 'ALL';
|
||||
AIFeatureType? _filterFeatureType;
|
||||
bool? _filterVerified;
|
||||
bool? _filterIsPublic;
|
||||
String _sortOption = 'LATEST';
|
||||
int _pageSize = 30;
|
||||
int _currentPage = 1;
|
||||
Timer? _searchDebounce;
|
||||
|
||||
static const List<String> _tabs = ['ALL', 'OFFICIAL', 'USER_SUBMITTED', 'PENDING_REVIEW'];
|
||||
static const Map<String, String> _tabLabels = {
|
||||
'ALL': '全部模板',
|
||||
'OFFICIAL': '官方模板',
|
||||
'USER_SUBMITTED': '用户提交',
|
||||
'PENDING_REVIEW': '待审核',
|
||||
};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: _tabs.length, vsync: this);
|
||||
_tabController.addListener(_onTabChanged);
|
||||
_loadTemplates();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchDebounce?.cancel();
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onTabChanged() {
|
||||
if (!_tabController.indexIsChanging) {
|
||||
setState(() {
|
||||
_currentTab = _tabs[_tabController.index];
|
||||
_selectedTemplates.clear();
|
||||
_batchMode = false;
|
||||
});
|
||||
_loadTemplates();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1600),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildHeader(),
|
||||
_buildTabBar(),
|
||||
Expanded(
|
||||
child: _buildContent(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 搜索框
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: TextField(
|
||||
onChanged: (value) {
|
||||
_searchDebounce?.cancel();
|
||||
_searchDebounce = Timer(const Duration(milliseconds: 400), () {
|
||||
setState(() {
|
||||
_searchQuery = value;
|
||||
_currentPage = 1;
|
||||
});
|
||||
_loadTemplates();
|
||||
});
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: '搜索模板名称或描述...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// 功能类型筛选
|
||||
SizedBox(
|
||||
width: 280,
|
||||
child: DropdownButtonFormField<AIFeatureType?>(
|
||||
value: _filterFeatureType,
|
||||
decoration: InputDecoration(
|
||||
labelText: '功能类型',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
items: [
|
||||
const DropdownMenuItem<AIFeatureType?>(
|
||||
value: null,
|
||||
child: Text('全部类型'),
|
||||
),
|
||||
..._buildFeatureTypeOptions(),
|
||||
],
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_filterFeatureType = value;
|
||||
_currentPage = 1;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 160,
|
||||
child: DropdownButtonFormField<bool?>(
|
||||
value: _filterVerified,
|
||||
decoration: InputDecoration(
|
||||
labelText: '认证',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem<bool?>(value: null, child: Text('全部')),
|
||||
DropdownMenuItem<bool?>(value: true, child: Text('认证')),
|
||||
DropdownMenuItem<bool?>(value: false, child: Text('未认证')),
|
||||
],
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
_filterVerified = v;
|
||||
_currentPage = 1;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 160,
|
||||
child: DropdownButtonFormField<bool?>(
|
||||
value: _filterIsPublic,
|
||||
decoration: InputDecoration(
|
||||
labelText: '可见性',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem<bool?>(value: null, child: Text('全部')),
|
||||
DropdownMenuItem<bool?>(value: true, child: Text('公开')),
|
||||
DropdownMenuItem<bool?>(value: false, child: Text('私有')),
|
||||
],
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
_filterIsPublic = v;
|
||||
_currentPage = 1;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 180,
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: _sortOption,
|
||||
decoration: InputDecoration(
|
||||
labelText: '排序',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'LATEST', child: Text('最新')),
|
||||
DropdownMenuItem(value: 'MOST_USED', child: Text('使用最多')),
|
||||
DropdownMenuItem(value: 'RATING', child: Text('评分最高')),
|
||||
],
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
_sortOption = v ?? 'LATEST';
|
||||
_currentPage = 1;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// 批量操作开关
|
||||
if (_templates.isNotEmpty) ...[
|
||||
FilterChip(
|
||||
label: Text('批量操作${_batchMode ? ' (${_selectedTemplates.length})' : ''}'),
|
||||
selected: _batchMode,
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
_batchMode = selected;
|
||||
if (!selected) {
|
||||
_selectedTemplates.clear();
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
|
||||
// 批量操作按钮
|
||||
if (_batchMode && _selectedTemplates.isNotEmpty) ...[
|
||||
ElevatedButton.icon(
|
||||
onPressed: _batchPublish,
|
||||
icon: const Icon(Icons.publish),
|
||||
label: const Text('批量发布'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _batchSetVerified,
|
||||
icon: const Icon(Icons.verified),
|
||||
label: const Text('批量认证'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.green,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
|
||||
// 添加官方模板按钮
|
||||
ElevatedButton.icon(
|
||||
onPressed: _showAddOfficialTemplateDialog,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('添加官方模板'),
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// 刷新按钮
|
||||
IconButton(
|
||||
onPressed: _loadTemplates,
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: '刷新',
|
||||
),
|
||||
|
||||
// 统计按钮
|
||||
IconButton(
|
||||
onPressed: _showStatisticsDialog,
|
||||
icon: const Icon(Icons.analytics),
|
||||
tooltip: '查看统计',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabBar() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: TabBar(
|
||||
controller: _tabController,
|
||||
isScrollable: true,
|
||||
tabs: _tabs.map((tab) => Tab(
|
||||
text: _tabLabels[tab],
|
||||
)).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent() {
|
||||
if (_isLoading) {
|
||||
return const Center(child: LoadingIndicator());
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'加载失败',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_error!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).textTheme.bodyMedium?.color?.withOpacity(0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _loadTemplates,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_templates.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.article_outlined,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'暂无模板',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'当前筛选条件下没有找到模板',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _showAddOfficialTemplateDialog,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('添加官方模板'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return TabBarView(
|
||||
controller: _tabController,
|
||||
children: _tabs.map((tab) => _buildTemplateList()).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTemplateList() {
|
||||
final filteredTemplates = _getFilteredTemplates();
|
||||
final visibleCount = (_currentPage * _pageSize).clamp(0, filteredTemplates.length);
|
||||
final items = filteredTemplates.take(visibleCount).toList();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final template = items[index];
|
||||
return PublicTemplateCard(
|
||||
template: template,
|
||||
isSelected: _selectedTemplates.contains(template),
|
||||
batchMode: _batchMode,
|
||||
onTap: () => _onTemplateCardTap(template),
|
||||
onEdit: () => _showEditTemplateDialog(template),
|
||||
onDuplicate: () => _duplicatePublicTemplate(template),
|
||||
onReview: () => _showTemplateReviewDialog(template),
|
||||
onPublish: () => _publishTemplate(template),
|
||||
onSetVerified: () => _setTemplateVerified(template),
|
||||
onDelete: () => _deleteTemplate(template),
|
||||
onSelectionChanged: (selected) => _onTemplateSelectionChanged(template, selected),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (visibleCount < filteredTemplates.length)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_currentPage += 1;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.expand_more),
|
||||
label: Text('加载更多(${filteredTemplates.length - visibleCount})'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _duplicatePublicTemplate(PromptTemplate template) async {
|
||||
final controller = TextEditingController(text: '${template.name} (复制)');
|
||||
final newName = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('复制模板'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '新模板名称',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(null),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(controller.text.trim()),
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (newName == null || newName.isEmpty) return;
|
||||
|
||||
try {
|
||||
final now = DateTime.now();
|
||||
// 使用增强模板模型创建,以便包含 userId 等关键字段
|
||||
final enhanced = _convertToEnhancedTemplate(template).copyWith(
|
||||
id: '',
|
||||
name: newName,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
usageCount: 0,
|
||||
favoriteCount: 0,
|
||||
isFavorite: false,
|
||||
isDefault: false,
|
||||
shareCode: null,
|
||||
// 复制来源
|
||||
authorId: template.authorId ?? (template.isPublic ? 'system' : null),
|
||||
);
|
||||
|
||||
await _adminRepository.createOfficialEnhancedTemplate(enhanced);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('已复制为新模板: ${enhanced.name}')),
|
||||
);
|
||||
_loadTemplates();
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.e('PublicTemplatesManagement', '复制模板失败', e);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('复制失败: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<PromptTemplate> _getFilteredTemplates() {
|
||||
List<PromptTemplate> filteredTemplates = List.from(_templates);
|
||||
|
||||
// 注意:标签页筛选现在主要在API调用层面完成
|
||||
// OFFICIAL -> getVerifiedTemplates() 获取已验证模板
|
||||
// USER_SUBMITTED -> getPublicTemplates() 获取所有公共模板(用户提交的)
|
||||
// PENDING_REVIEW -> getPendingTemplates() 获取待审核模板
|
||||
// ALL -> getPublicTemplates() 获取所有公共模板
|
||||
|
||||
// 根据搜索条件筛选
|
||||
if (_searchQuery.isNotEmpty) {
|
||||
filteredTemplates = filteredTemplates.where((template) {
|
||||
final query = _searchQuery.toLowerCase();
|
||||
return template.name.toLowerCase().contains(query) ||
|
||||
(template.description?.toLowerCase().contains(query) ?? false);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// 功能类型筛选
|
||||
if (_filterFeatureType != null) {
|
||||
filteredTemplates = filteredTemplates
|
||||
.where((t) => t.featureType == _filterFeatureType)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// 认证筛选
|
||||
if (_filterVerified != null) {
|
||||
filteredTemplates = filteredTemplates.where((t) => t.isVerified == _filterVerified).toList();
|
||||
}
|
||||
|
||||
// 可见性筛选
|
||||
if (_filterIsPublic != null) {
|
||||
filteredTemplates = filteredTemplates.where((t) => t.isPublic == _filterIsPublic).toList();
|
||||
}
|
||||
|
||||
// 排序
|
||||
switch (_sortOption) {
|
||||
case 'MOST_USED':
|
||||
filteredTemplates.sort((a, b) => (b.useCount ?? 0).compareTo(a.useCount ?? 0));
|
||||
break;
|
||||
case 'RATING':
|
||||
filteredTemplates.sort((a, b) => (b.averageRating ?? 0).compareTo(a.averageRating ?? 0));
|
||||
break;
|
||||
case 'LATEST':
|
||||
default:
|
||||
filteredTemplates.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
|
||||
break;
|
||||
}
|
||||
|
||||
return filteredTemplates;
|
||||
}
|
||||
|
||||
List<DropdownMenuItem<AIFeatureType>> _buildFeatureTypeOptions() {
|
||||
final Set<AIFeatureType> featureTypes =
|
||||
_templates.map((t) => t.featureType).toSet();
|
||||
final Map<AIFeatureType, String> labels = {
|
||||
AIFeatureType.textExpansion: '文本扩写',
|
||||
AIFeatureType.textRefactor: '文本润色',
|
||||
AIFeatureType.textSummary: '文本总结',
|
||||
AIFeatureType.sceneToSummary: '场景转摘要',
|
||||
AIFeatureType.summaryToScene: '摘要转场景',
|
||||
AIFeatureType.aiChat: 'AI对话',
|
||||
AIFeatureType.novelGeneration: '小说生成',
|
||||
AIFeatureType.professionalFictionContinuation: '专业续写',
|
||||
AIFeatureType.sceneBeatGeneration: '场景节拍生成',
|
||||
};
|
||||
|
||||
final List<AIFeatureType> sorted = featureTypes.toList()
|
||||
..sort((a, b) => (labels[a] ?? a.name).compareTo(labels[b] ?? b.name));
|
||||
|
||||
return sorted
|
||||
.map((ft) => DropdownMenuItem<AIFeatureType>(
|
||||
value: ft,
|
||||
child: Text(labels[ft] ?? ft.name),
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// 数据加载
|
||||
Future<void> _loadTemplates() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
List<PromptTemplate> templates;
|
||||
|
||||
switch (_currentTab) {
|
||||
case 'OFFICIAL':
|
||||
templates = await _adminRepository.getVerifiedTemplates();
|
||||
break;
|
||||
case 'PENDING_REVIEW':
|
||||
templates = await _adminRepository.getPendingTemplates();
|
||||
break;
|
||||
case 'USER_SUBMITTED':
|
||||
templates = await _adminRepository.getAllUserTemplates(
|
||||
page: 0,
|
||||
size: 100, // 暂时设置较大值,后续可以实现真正的分页
|
||||
search: _searchQuery.isEmpty ? null : _searchQuery,
|
||||
);
|
||||
break;
|
||||
case 'ALL':
|
||||
default:
|
||||
templates = await _adminRepository.getPublicTemplates(
|
||||
search: _searchQuery.isEmpty ? null : _searchQuery,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_templates = templates;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
AppLogger.e('PublicTemplatesManagement', '加载模板失败', e);
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 事件处理
|
||||
void _onTemplateCardTap(PromptTemplate template) {
|
||||
if (_batchMode) {
|
||||
_onTemplateSelectionChanged(template, !_selectedTemplates.contains(template));
|
||||
} else {
|
||||
_showTemplateDetails(template);
|
||||
}
|
||||
}
|
||||
|
||||
void _onTemplateSelectionChanged(PromptTemplate template, bool selected) {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
_selectedTemplates.add(template);
|
||||
} else {
|
||||
_selectedTemplates.remove(template);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 对话框显示
|
||||
void _showAddOfficialTemplateDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AddOfficialTemplateDialog(
|
||||
onSuccess: _loadTemplates,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showEditTemplateDialog(PromptTemplate template) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => EditTemplateDialog(
|
||||
template: template,
|
||||
onSuccess: _loadTemplates,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 将PromptTemplate转换为EnhancedUserPromptTemplate
|
||||
EnhancedUserPromptTemplate _convertToEnhancedTemplate(PromptTemplate template) {
|
||||
return EnhancedUserPromptTemplate(
|
||||
id: template.id,
|
||||
userId: template.authorId ?? '',
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
featureType: template.featureType,
|
||||
systemPrompt: '', // PromptTemplate没有单独的systemPrompt字段
|
||||
userPrompt: template.content,
|
||||
tags: template.templateTags ?? [],
|
||||
categories: [],
|
||||
isPublic: template.isPublic,
|
||||
shareCode: null,
|
||||
isFavorite: template.isFavorite,
|
||||
isDefault: template.isDefault,
|
||||
usageCount: template.useCount?.toInt() ?? 0,
|
||||
rating: template.averageRating ?? 0.0,
|
||||
ratingCount: template.ratingCount ?? 0,
|
||||
createdAt: template.createdAt,
|
||||
updatedAt: template.updatedAt,
|
||||
lastUsedAt: null,
|
||||
isVerified: template.isVerified,
|
||||
authorId: template.authorId,
|
||||
version: 1,
|
||||
language: 'zh',
|
||||
favoriteCount: 0,
|
||||
reviewedAt: null,
|
||||
reviewedBy: null,
|
||||
reviewComment: null,
|
||||
);
|
||||
}
|
||||
|
||||
void _showTemplateReviewDialog(PromptTemplate template) {
|
||||
// TODO: 实现PromptTemplate的审核对话框
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('模板审核功能开发中: ${template.name}')),
|
||||
);
|
||||
}
|
||||
|
||||
void _showTemplateDetails(PromptTemplate template) {
|
||||
// 将PromptTemplate转换为EnhancedUserPromptTemplate以兼容现有对话框
|
||||
final enhancedTemplate = _convertToEnhancedTemplate(template);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => TemplateDetailsDialog(
|
||||
template: enhancedTemplate,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showStatisticsDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => TemplateStatisticsDialog(),
|
||||
);
|
||||
}
|
||||
|
||||
// 操作方法
|
||||
Future<void> _publishTemplate(PromptTemplate template) async {
|
||||
try {
|
||||
await _adminRepository.publishTemplate(template.id);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('模板 "${template.name}" 发布成功')),
|
||||
);
|
||||
_loadTemplates();
|
||||
} catch (e) {
|
||||
AppLogger.e('PublicTemplatesManagement', '发布模板失败', e);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('发布失败: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _setTemplateVerified(PromptTemplate template) async {
|
||||
try {
|
||||
await _adminRepository.setTemplateVerified(template.id, true);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('模板 "${template.name}" 已设为认证')),
|
||||
);
|
||||
_loadTemplates();
|
||||
} catch (e) {
|
||||
AppLogger.e('PublicTemplatesManagement', '设置模板认证失败', e);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('设置认证失败: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteTemplate(PromptTemplate template) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('确认删除'),
|
||||
content: Text('确定要删除模板 "${template.name}" 吗?此操作不可撤销。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('删除'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
try {
|
||||
await _adminRepository.deleteTemplate(template.id);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('模板 "${template.name}" 删除成功')),
|
||||
);
|
||||
_loadTemplates();
|
||||
} catch (e) {
|
||||
AppLogger.e('PublicTemplatesManagement', '删除模板失败', e);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('删除失败: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 批量操作
|
||||
Future<void> _batchPublish() async {
|
||||
if (_selectedTemplates.isEmpty) return;
|
||||
|
||||
try {
|
||||
for (final template in _selectedTemplates) {
|
||||
await _adminRepository.publishTemplate(template.id);
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('成功发布 ${_selectedTemplates.length} 个模板')),
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_selectedTemplates.clear();
|
||||
_batchMode = false;
|
||||
});
|
||||
_loadTemplates();
|
||||
} catch (e) {
|
||||
AppLogger.e('PublicTemplatesManagement', '批量发布模板失败', e);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('批量发布失败: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _batchSetVerified() async {
|
||||
if (_selectedTemplates.isEmpty) return;
|
||||
|
||||
try {
|
||||
for (final template in _selectedTemplates) {
|
||||
await _adminRepository.setTemplateVerified(template.id, true);
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('成功设置 ${_selectedTemplates.length} 个模板为认证')),
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_selectedTemplates.clear();
|
||||
_batchMode = false;
|
||||
});
|
||||
_loadTemplates();
|
||||
} catch (e) {
|
||||
AppLogger.e('PublicTemplatesManagement', '批量设置模板认证失败', e);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('批量设置认证失败: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
138
AINoval/lib/screens/admin/role_management_screen.dart
Normal file
138
AINoval/lib/screens/admin/role_management_screen.dart
Normal file
@@ -0,0 +1,138 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../blocs/admin/admin_bloc.dart';
|
||||
import '../../utils/web_theme.dart';
|
||||
import 'widgets/role_management_table.dart';
|
||||
|
||||
class RoleManagementScreen extends StatefulWidget {
|
||||
const RoleManagementScreen({super.key});
|
||||
|
||||
@override
|
||||
State<RoleManagementScreen> createState() => _RoleManagementScreenState();
|
||||
}
|
||||
|
||||
class _RoleManagementScreenState extends State<RoleManagementScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 加载角色数据
|
||||
context.read<AdminBloc>().add(LoadRoles());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: WebTheme.getBackgroundColor(context),
|
||||
body: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1600),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 页面标题
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
child: Text(
|
||||
'角色管理',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 内容区域
|
||||
Expanded(
|
||||
child: BlocBuilder<AdminBloc, AdminState>(
|
||||
builder: (context, state) {
|
||||
if (state is AdminLoading) {
|
||||
return Center(
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (state is AdminError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: Colors.red,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'加载失败:${state.message}',
|
||||
style: TextStyle(
|
||||
color: WebTheme.getTextColor(context),
|
||||
fontSize: 16,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
context.read<AdminBloc>().add(LoadRoles());
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: WebTheme.getTextColor(context),
|
||||
foregroundColor: WebTheme.getBackgroundColor(context),
|
||||
),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else if (state is RolesLoaded) {
|
||||
return RoleManagementTable(roles: state.roles);
|
||||
} else {
|
||||
// 初始状态或其他状态,显示空状态
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.admin_panel_settings_outlined,
|
||||
size: 64,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'暂无角色数据',
|
||||
style: TextStyle(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
context.read<AdminBloc>().add(LoadRoles());
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: WebTheme.getTextColor(context),
|
||||
foregroundColor: WebTheme.getBackgroundColor(context),
|
||||
),
|
||||
child: const Text('加载角色'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
138
AINoval/lib/screens/admin/subscription_management_screen.dart
Normal file
138
AINoval/lib/screens/admin/subscription_management_screen.dart
Normal file
@@ -0,0 +1,138 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../blocs/subscription/subscription_bloc.dart';
|
||||
import '../../utils/web_theme.dart';
|
||||
import 'widgets/subscription_plan_table.dart';
|
||||
|
||||
class SubscriptionManagementScreen extends StatefulWidget {
|
||||
const SubscriptionManagementScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SubscriptionManagementScreen> createState() => _SubscriptionManagementScreenState();
|
||||
}
|
||||
|
||||
class _SubscriptionManagementScreenState extends State<SubscriptionManagementScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 加载订阅计划数据
|
||||
context.read<SubscriptionBloc>().add(LoadSubscriptionPlans());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: WebTheme.getBackgroundColor(context),
|
||||
body: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1600),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 页面标题
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
child: Text(
|
||||
'订阅管理',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 内容区域
|
||||
Expanded(
|
||||
child: BlocBuilder<SubscriptionBloc, SubscriptionState>(
|
||||
builder: (context, state) {
|
||||
if (state is SubscriptionLoading) {
|
||||
return Center(
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (state is SubscriptionError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: Colors.red,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'加载失败:${state.message}',
|
||||
style: TextStyle(
|
||||
color: WebTheme.getTextColor(context),
|
||||
fontSize: 16,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
context.read<SubscriptionBloc>().add(LoadSubscriptionPlans());
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: WebTheme.getTextColor(context),
|
||||
foregroundColor: WebTheme.getBackgroundColor(context),
|
||||
),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else if (state is SubscriptionPlansLoaded) {
|
||||
return SubscriptionPlanTable(plans: state.plans);
|
||||
} else {
|
||||
// 初始状态或其他状态,显示空状态
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.subscriptions_outlined,
|
||||
size: 64,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'暂无订阅计划数据',
|
||||
style: TextStyle(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
context.read<SubscriptionBloc>().add(LoadSubscriptionPlans());
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: WebTheme.getTextColor(context),
|
||||
foregroundColor: WebTheme.getBackgroundColor(context),
|
||||
),
|
||||
child: const Text('加载订阅计划'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
758
AINoval/lib/screens/admin/system_presets_management_screen.dart
Normal file
758
AINoval/lib/screens/admin/system_presets_management_screen.dart
Normal file
@@ -0,0 +1,758 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import '../../models/preset_models.dart';
|
||||
import '../../services/api_service/repositories/impl/admin_repository_impl.dart';
|
||||
import '../../utils/logger.dart';
|
||||
import '../../utils/web_theme.dart';
|
||||
import '../../widgets/common/error_view.dart';
|
||||
import '../../widgets/common/loading_indicator.dart';
|
||||
import 'widgets/add_system_preset_dialog.dart';
|
||||
import 'widgets/edit_system_preset_dialog.dart';
|
||||
import 'widgets/system_preset_card.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// 系统预设管理页面
|
||||
/// 提供完整的系统AI预设管理功能,包括:
|
||||
/// - 按功能类型分组显示系统预设
|
||||
/// - 添加/编辑/删除系统预设
|
||||
/// - 预设可见性管理
|
||||
/// - 批量操作功能
|
||||
/// - 使用统计查看
|
||||
class SystemPresetsManagementScreen extends StatefulWidget {
|
||||
const SystemPresetsManagementScreen({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<SystemPresetsManagementScreen> createState() => _SystemPresetsManagementScreenState();
|
||||
}
|
||||
|
||||
/// 系统预设管理内容主体,可以在不同布局中复用
|
||||
class SystemPresetsManagementBody extends StatefulWidget {
|
||||
const SystemPresetsManagementBody({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<SystemPresetsManagementBody> createState() => _SystemPresetsManagementBodyState();
|
||||
}
|
||||
|
||||
class _SystemPresetsManagementScreenState extends State<SystemPresetsManagementScreen> {
|
||||
final GlobalKey<_SystemPresetsManagementBodyState> _bodyKey = GlobalKey<_SystemPresetsManagementBodyState>();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: WebTheme.getBackgroundColor(context),
|
||||
foregroundColor: WebTheme.getTextColor(context),
|
||||
title: Text(
|
||||
'系统预设管理',
|
||||
style: TextStyle(color: WebTheme.getTextColor(context)),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => _bodyKey.currentState?._refreshData(),
|
||||
icon: Icon(Icons.refresh, color: WebTheme.getTextColor(context)),
|
||||
tooltip: '刷新',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => _bodyKey.currentState?._showStatistics(),
|
||||
icon: Icon(Icons.analytics, color: WebTheme.getTextColor(context)),
|
||||
tooltip: '统计信息',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => _showAddPresetDialog(context),
|
||||
icon: Icon(Icons.add, color: WebTheme.getTextColor(context)),
|
||||
tooltip: '添加系统预设',
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: WebTheme.getBackgroundColor(context),
|
||||
body: SystemPresetsManagementBody(key: _bodyKey),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAddPresetDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AddSystemPresetDialog(
|
||||
onSuccess: () => _bodyKey.currentState?._refreshData(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SystemPresetsManagementBodyState extends State<SystemPresetsManagementBody> {
|
||||
List<AIPromptPreset> _systemPresets = [];
|
||||
Map<String, List<AIPromptPreset>> _presetsByFeatureType = {};
|
||||
Map<String, dynamic> _statistics = {};
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
String _selectedFeatureType = 'ALL';
|
||||
List<String> _selectedPresets = [];
|
||||
bool _batchMode = false;
|
||||
|
||||
final AdminRepositoryImpl _adminRepository = AdminRepositoryImpl();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadData();
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
await Future.wait([
|
||||
_loadSystemPresets(),
|
||||
_loadStatistics(),
|
||||
]);
|
||||
} catch (e) {
|
||||
AppLogger.e('SystemPresetsManagement', '加载系统预设数据失败', e);
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
});
|
||||
} finally {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadSystemPresets() async {
|
||||
try {
|
||||
final presets = await _adminRepository.getSystemPresets(
|
||||
featureType: _selectedFeatureType == 'ALL' ? null : _selectedFeatureType,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_systemPresets = presets;
|
||||
_presetsByFeatureType = _groupPresetsByFeatureType(presets);
|
||||
});
|
||||
} catch (e) {
|
||||
AppLogger.e('SystemPresetsManagement', '加载系统预设失败', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadStatistics() async {
|
||||
try {
|
||||
final stats = await _adminRepository.getSystemPresetsStatistics();
|
||||
setState(() {
|
||||
_statistics = stats;
|
||||
});
|
||||
} catch (e) {
|
||||
AppLogger.e('SystemPresetsManagement', '加载统计信息失败', e);
|
||||
// 统计信息加载失败不影响主要功能
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, List<AIPromptPreset>> _groupPresetsByFeatureType(List<AIPromptPreset> presets) {
|
||||
return groupBy(presets, (preset) => preset.aiFeatureType);
|
||||
}
|
||||
|
||||
void _refreshData() {
|
||||
_loadData();
|
||||
}
|
||||
|
||||
void _showStatistics() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => _StatisticsDialog(statistics: _statistics),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
return const Center(child: LoadingIndicator());
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
return ErrorView(
|
||||
error: _error!,
|
||||
onRetry: _refreshData,
|
||||
);
|
||||
}
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1600),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildToolbar(),
|
||||
_buildFilterTabs(),
|
||||
Expanded(
|
||||
child: _buildPresetsList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildToolbar() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getCardColor(context),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (_batchMode) ...[
|
||||
Expanded(
|
||||
child: Text(
|
||||
'已选择 ${_selectedPresets.length} 个预设',
|
||||
style: TextStyle(
|
||||
color: WebTheme.getTextColor(context),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _selectedPresets.isNotEmpty ? () => _batchToggleVisibility(true) : null,
|
||||
icon: const Icon(Icons.visibility),
|
||||
tooltip: '批量显示在快捷访问',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _selectedPresets.isNotEmpty ? () => _batchToggleVisibility(false) : null,
|
||||
icon: const Icon(Icons.visibility_off),
|
||||
tooltip: '批量隐藏快捷访问',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _selectedPresets.isNotEmpty ? _batchExport : null,
|
||||
icon: const Icon(Icons.file_download),
|
||||
tooltip: '导出选中预设',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_batchMode = false;
|
||||
_selectedPresets.clear();
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: '退出批量模式',
|
||||
),
|
||||
] else ...[
|
||||
Expanded(
|
||||
child: Text(
|
||||
'系统预设总数: ${_systemPresets.length}',
|
||||
style: TextStyle(
|
||||
color: WebTheme.getTextColor(context),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _importPresets,
|
||||
icon: const Icon(Icons.file_upload),
|
||||
tooltip: '导入预设',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_batchMode = true;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.checklist),
|
||||
tooltip: '批量操作',
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterTabs() {
|
||||
final featureTypes = ['ALL', ..._presetsByFeatureType.keys.toList()..sort()];
|
||||
|
||||
return Container(
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getCardColor(context),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: featureTypes.length,
|
||||
itemBuilder: (context, index) {
|
||||
final featureType = featureTypes[index];
|
||||
final isSelected = _selectedFeatureType == featureType;
|
||||
final count = featureType == 'ALL'
|
||||
? _systemPresets.length
|
||||
: _presetsByFeatureType[featureType]?.length ?? 0;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text('$featureType ($count)'),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setState(() {
|
||||
_selectedFeatureType = featureType;
|
||||
});
|
||||
_loadSystemPresets();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPresetsList() {
|
||||
if (_systemPresets.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.smart_button_outlined,
|
||||
size: 64,
|
||||
color: WebTheme.getTextColor(context).withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'暂无系统预设',
|
||||
style: TextStyle(
|
||||
color: WebTheme.getTextColor(context).withOpacity(0.7),
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'点击右上角的加号创建第一个系统预设',
|
||||
style: TextStyle(
|
||||
color: WebTheme.getTextColor(context).withOpacity(0.5),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final displayPresets = _selectedFeatureType == 'ALL'
|
||||
? _systemPresets
|
||||
: _presetsByFeatureType[_selectedFeatureType] ?? [];
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: displayPresets.length,
|
||||
itemBuilder: (context, index) {
|
||||
final preset = displayPresets[index];
|
||||
return SystemPresetCard(
|
||||
preset: preset,
|
||||
isSelected: _selectedPresets.contains(preset.presetId),
|
||||
batchMode: _batchMode,
|
||||
onTap: () => _handlePresetTap(preset),
|
||||
onEdit: () => _editPreset(preset),
|
||||
onDelete: () => _deletePreset(preset),
|
||||
onToggleVisibility: () => _togglePresetVisibility(preset),
|
||||
onViewStats: () => _viewPresetStats(preset),
|
||||
onViewDetails: () => _viewPresetDetails(preset),
|
||||
onSelectionChanged: (selected) {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
_selectedPresets.add(preset.presetId);
|
||||
} else {
|
||||
_selectedPresets.remove(preset.presetId);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _viewPresetDetails(AIPromptPreset preset) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text('预设内容 - ${preset.presetName ?? ''}'),
|
||||
content: SizedBox(
|
||||
width: 700,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildPromptPreviewSection('系统提示词 (System Prompt)', preset.systemPrompt),
|
||||
const SizedBox(height: 16),
|
||||
_buildPromptPreviewSection('用户提示词 (User Prompt)', preset.userPrompt.isNotEmpty ? preset.userPrompt : '(未设置)'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('关闭'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPromptPreviewSection(String title, String content) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.code, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
const Spacer(),
|
||||
if (content.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
tooltip: '复制',
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: content));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('已复制到剪贴板')),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.withOpacity(0.08),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey.withOpacity(0.2)),
|
||||
),
|
||||
child: SelectableText(
|
||||
content.isNotEmpty ? content : '(空)',
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 13, height: 1.4),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _handlePresetTap(AIPromptPreset preset) {
|
||||
if (_batchMode) {
|
||||
final isSelected = _selectedPresets.contains(preset.presetId);
|
||||
setState(() {
|
||||
if (isSelected) {
|
||||
_selectedPresets.remove(preset.presetId);
|
||||
} else {
|
||||
_selectedPresets.add(preset.presetId);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
_editPreset(preset);
|
||||
}
|
||||
}
|
||||
|
||||
void _editPreset(AIPromptPreset preset) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => EditSystemPresetDialog(
|
||||
preset: preset,
|
||||
onSuccess: _refreshData,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deletePreset(AIPromptPreset preset) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('确认删除'),
|
||||
content: Text('确定要删除系统预设 "${preset.presetName}" 吗?此操作不可撤销。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('删除'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
try {
|
||||
await _adminRepository.deleteSystemPreset(preset.presetId);
|
||||
_refreshData();
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('系统预设 "${preset.presetName}" 已删除')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.e('SystemPresetsManagement', '删除系统预设失败', e);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('删除失败: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _togglePresetVisibility(AIPromptPreset preset) async {
|
||||
try {
|
||||
await _adminRepository.toggleSystemPresetQuickAccess(preset.presetId);
|
||||
_refreshData();
|
||||
|
||||
if (mounted) {
|
||||
final status = !preset.showInQuickAccess ? '显示在快捷访问' : '隐藏快捷访问';
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('预设 "${preset.presetName}" 已$status')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.e('SystemPresetsManagement', '切换预设可见性失败', e);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('操作失败: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _viewPresetStats(AIPromptPreset preset) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => _PresetStatsDialog(presetId: preset.presetId),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _batchToggleVisibility(bool visible) async {
|
||||
try {
|
||||
await _adminRepository.batchUpdateSystemPresetsVisibility(_selectedPresets, visible);
|
||||
_refreshData();
|
||||
|
||||
if (mounted) {
|
||||
final action = visible ? '显示在快捷访问' : '隐藏快捷访问';
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('已将 ${_selectedPresets.length} 个预设$action')),
|
||||
);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_selectedPresets.clear();
|
||||
_batchMode = false;
|
||||
});
|
||||
} catch (e) {
|
||||
AppLogger.e('SystemPresetsManagement', '批量更新可见性失败', e);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('批量操作失败: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _batchExport() async {
|
||||
try {
|
||||
final presets = await _adminRepository.exportSystemPresets(_selectedPresets);
|
||||
// TODO: 实现文件导出功能
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('已导出 ${presets.length} 个系统预设')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.e('SystemPresetsManagement', '导出预设失败', e);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('导出失败: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _importPresets() async {
|
||||
// TODO: 实现预设导入功能
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('导入功能开发中...')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 统计信息对话框
|
||||
class _StatisticsDialog extends StatelessWidget {
|
||||
final Map<String, dynamic> statistics;
|
||||
|
||||
const _StatisticsDialog({required this.statistics});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('系统预设统计'),
|
||||
content: SizedBox(
|
||||
width: 400,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildStatItem('总预设数', statistics['totalSystemPresets']?.toString() ?? '0'),
|
||||
_buildStatItem('快捷访问预设', statistics['quickAccessCount']?.toString() ?? '0'),
|
||||
_buildStatItem('总使用次数', statistics['totalUsage']?.toString() ?? '0'),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
const Text('按功能类型分布:', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
if (statistics['byFeatureType'] is Map<String, dynamic>)
|
||||
...(statistics['byFeatureType'] as Map<String, dynamic>).entries.map(
|
||||
(entry) => Padding(
|
||||
padding: const EdgeInsets.only(left: 16, bottom: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(entry.key),
|
||||
Text(entry.value.toString()),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('关闭'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatItem(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
Text(value, style: const TextStyle(fontSize: 16)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 预设统计对话框
|
||||
class _PresetStatsDialog extends StatefulWidget {
|
||||
final String presetId;
|
||||
|
||||
const _PresetStatsDialog({required this.presetId});
|
||||
|
||||
@override
|
||||
State<_PresetStatsDialog> createState() => _PresetStatsDialogState();
|
||||
}
|
||||
|
||||
class _PresetStatsDialogState extends State<_PresetStatsDialog> {
|
||||
Map<String, dynamic>? _details;
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
|
||||
final AdminRepositoryImpl _adminRepository = AdminRepositoryImpl();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadDetails();
|
||||
}
|
||||
|
||||
Future<void> _loadDetails() async {
|
||||
try {
|
||||
final details = await _adminRepository.getSystemPresetDetails(widget.presetId);
|
||||
setState(() {
|
||||
_details = details;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('预设详情'),
|
||||
content: SizedBox(
|
||||
width: 400,
|
||||
height: 300,
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(child: Text('加载失败: $_error'))
|
||||
: _buildDetails(),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('关闭'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetails() {
|
||||
if (_details == null) return const SizedBox();
|
||||
|
||||
final preset = _details!['preset'] as Map<String, dynamic>?;
|
||||
final statistics = _details!['statistics'] as Map<String, dynamic>?;
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (preset != null) ...[
|
||||
Text('预设名称: ${preset['presetName'] ?? ''}'),
|
||||
Text('功能类型: ${preset['aiFeatureType'] ?? ''}'),
|
||||
Text('创建时间: ${preset['createdAt'] ?? ''}'),
|
||||
Text('最后更新: ${preset['updatedAt'] ?? ''}'),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
const Text('使用统计:', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
],
|
||||
|
||||
if (statistics != null) ...[
|
||||
Text('使用次数: ${statistics['useCount'] ?? 0}'),
|
||||
Text('最后使用: ${statistics['lastUsedAt'] ?? '从未使用'}'),
|
||||
Text('创建天数: ${statistics['daysSinceCreated'] ?? 0}'),
|
||||
if (statistics['daysSinceLastUsed'] != null)
|
||||
Text('上次使用距今: ${statistics['daysSinceLastUsed']} 天'),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
138
AINoval/lib/screens/admin/user_management_screen.dart
Normal file
138
AINoval/lib/screens/admin/user_management_screen.dart
Normal file
@@ -0,0 +1,138 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../blocs/admin/admin_bloc.dart';
|
||||
import '../../utils/web_theme.dart';
|
||||
import 'widgets/user_management_table.dart';
|
||||
|
||||
class UserManagementScreen extends StatefulWidget {
|
||||
const UserManagementScreen({super.key});
|
||||
|
||||
@override
|
||||
State<UserManagementScreen> createState() => _UserManagementScreenState();
|
||||
}
|
||||
|
||||
class _UserManagementScreenState extends State<UserManagementScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 加载用户数据
|
||||
context.read<AdminBloc>().add(LoadUsers());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: WebTheme.getBackgroundColor(context),
|
||||
body: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1600),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 页面标题
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
child: Text(
|
||||
'用户管理',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 内容区域
|
||||
Expanded(
|
||||
child: BlocBuilder<AdminBloc, AdminState>(
|
||||
builder: (context, state) {
|
||||
if (state is AdminLoading) {
|
||||
return Center(
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (state is AdminError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: Colors.red,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'加载失败:${state.message}',
|
||||
style: TextStyle(
|
||||
color: WebTheme.getTextColor(context),
|
||||
fontSize: 16,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
context.read<AdminBloc>().add(LoadUsers());
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: WebTheme.getTextColor(context),
|
||||
foregroundColor: WebTheme.getBackgroundColor(context),
|
||||
),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else if (state is UsersLoaded) {
|
||||
return UserManagementTable(users: state.users);
|
||||
} else {
|
||||
// 初始状态或其他状态,显示空状态
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.people_outline,
|
||||
size: 64,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'暂无用户数据',
|
||||
style: TextStyle(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
context.read<AdminBloc>().add(LoadUsers());
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: WebTheme.getTextColor(context),
|
||||
foregroundColor: WebTheme.getBackgroundColor(context),
|
||||
),
|
||||
child: const Text('加载用户'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../models/prompt_models.dart';
|
||||
import '../../../services/api_service/repositories/impl/admin_repository_impl.dart';
|
||||
import '../../../utils/web_theme.dart';
|
||||
import '../../../widgets/common/dialog_container.dart';
|
||||
import '../../../widgets/common/dialog_header.dart';
|
||||
|
||||
/// 添加增强模板对话框
|
||||
class AddEnhancedTemplateDialog extends StatefulWidget {
|
||||
final EnhancedUserPromptTemplate? template;
|
||||
final VoidCallback? onSuccess;
|
||||
final ValueChanged<EnhancedUserPromptTemplate>? onUpdated;
|
||||
|
||||
const AddEnhancedTemplateDialog({
|
||||
Key? key,
|
||||
this.template,
|
||||
this.onSuccess,
|
||||
this.onUpdated,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<AddEnhancedTemplateDialog> createState() => _AddEnhancedTemplateDialogState();
|
||||
}
|
||||
|
||||
class _AddEnhancedTemplateDialogState extends State<AddEnhancedTemplateDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameController = TextEditingController();
|
||||
final _descriptionController = TextEditingController();
|
||||
final _systemPromptController = TextEditingController();
|
||||
final _userPromptController = TextEditingController();
|
||||
final _tagsController = TextEditingController();
|
||||
|
||||
String _featureType = 'TEXT_EXPANSION';
|
||||
String _language = 'zh';
|
||||
bool _isVerified = false;
|
||||
bool _isLoading = false;
|
||||
|
||||
// 功能类型由 AIFeatureTypeHelper.allFeatures 动态提供
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 如果是编辑模式,填充现有数据
|
||||
if (widget.template != null) {
|
||||
final template = widget.template!;
|
||||
_nameController.text = template.name;
|
||||
_descriptionController.text = template.description ?? '';
|
||||
_systemPromptController.text = template.systemPrompt;
|
||||
_userPromptController.text = template.userPrompt;
|
||||
_tagsController.text = template.tags.join(', ');
|
||||
_featureType = template.featureType.toApiString();
|
||||
_language = template.language ?? 'zh';
|
||||
_isVerified = template.isVerified;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_descriptionController.dispose();
|
||||
_systemPromptController.dispose();
|
||||
_userPromptController.dispose();
|
||||
_tagsController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DialogContainer(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DialogHeader(
|
||||
title: widget.template != null ? '编辑模板' : '添加官方模板',
|
||||
onClose: () => Navigator.of(context).pop(),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildBasicInfo(),
|
||||
const SizedBox(height: 24),
|
||||
_buildPromptContent(),
|
||||
const SizedBox(height: 24),
|
||||
_buildAdvancedSettings(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildActions(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBasicInfo() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'基础信息',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '模板名称 *',
|
||||
hintText: '请输入模板名称',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return '请输入模板名称';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _descriptionController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '模板描述',
|
||||
hintText: '请输入模板描述',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
value: _featureType,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '功能类型 *',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: AIFeatureTypeHelper.allFeatures.map((type) {
|
||||
final api = type.toApiString();
|
||||
return DropdownMenuItem(
|
||||
value: api,
|
||||
child: Text(type.displayName),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_featureType = value;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _tagsController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '标签',
|
||||
hintText: '请输入标签,用逗号分隔',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPromptContent() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'提示词内容',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _systemPromptController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '系统提示词',
|
||||
hintText: '请输入系统提示词内容',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 5,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return '请输入系统提示词内容';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _userPromptController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '用户提示词',
|
||||
hintText: '请输入用户提示词内容',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 5,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return '请输入用户提示词内容';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAdvancedSettings() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'高级设置',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
value: _language,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '语言',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'zh', child: Text('中文')),
|
||||
DropdownMenuItem(value: 'en', child: Text('English')),
|
||||
DropdownMenuItem(value: 'ja', child: Text('日本語')),
|
||||
DropdownMenuItem(value: 'ko', child: Text('한국어')),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_language = value;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CheckboxListTile(
|
||||
title: const Text('设为官方认证模板'),
|
||||
subtitle: const Text('官方认证的模板会显示认证标识'),
|
||||
value: _isVerified,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_isVerified = value ?? false;
|
||||
});
|
||||
},
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActions() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getCardColor(context),
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _isLoading ? null : () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : _createTemplate,
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(widget.template != null ? '更新' : '创建'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _createTemplate() async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
EnhancedUserPromptTemplate? saved;
|
||||
// 解析标签
|
||||
List<String> tags = [];
|
||||
if (_tagsController.text.trim().isNotEmpty) {
|
||||
tags = _tagsController.text
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.where((tag) => tag.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
final adminRepository = AdminRepositoryImpl();
|
||||
|
||||
if (widget.template != null) {
|
||||
// 编辑模式 - 更新现有模板
|
||||
final updatedTemplate = widget.template!.copyWith(
|
||||
name: _nameController.text.trim(),
|
||||
description: _descriptionController.text.trim().isNotEmpty
|
||||
? _descriptionController.text.trim()
|
||||
: null,
|
||||
featureType: _getFeatureTypeFromString(_featureType),
|
||||
systemPrompt: _systemPromptController.text.trim(),
|
||||
userPrompt: _userPromptController.text.trim(),
|
||||
tags: tags,
|
||||
language: _language,
|
||||
isVerified: _isVerified,
|
||||
);
|
||||
|
||||
saved = await adminRepository.updateEnhancedTemplate(
|
||||
widget.template!.id,
|
||||
updatedTemplate,
|
||||
);
|
||||
} else {
|
||||
// 创建模式 - 新建模板
|
||||
final now = DateTime.now();
|
||||
final template = EnhancedUserPromptTemplate(
|
||||
id: '', // 将由后端生成
|
||||
userId: 'admin', // 管理员创建
|
||||
name: _nameController.text.trim(),
|
||||
description: _descriptionController.text.trim().isNotEmpty
|
||||
? _descriptionController.text.trim()
|
||||
: null,
|
||||
featureType: _getFeatureTypeFromString(_featureType),
|
||||
systemPrompt: _systemPromptController.text.trim(),
|
||||
userPrompt: _userPromptController.text.trim(),
|
||||
tags: tags,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isPublic: true, // 官方模板默认为公开
|
||||
isVerified: _isVerified,
|
||||
version: 1,
|
||||
language: _language,
|
||||
);
|
||||
|
||||
saved = await adminRepository.createOfficialEnhancedTemplate(template);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
if (widget.onUpdated != null) {
|
||||
widget.onUpdated!(saved);
|
||||
} else {
|
||||
widget.onSuccess?.call();
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(widget.template != null ? '模板更新成功' : '官方模板创建成功')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('创建失败: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AIFeatureType _getFeatureTypeFromString(String featureType) {
|
||||
switch (featureType) {
|
||||
case 'TEXT_EXPANSION':
|
||||
return AIFeatureType.textExpansion;
|
||||
case 'TEXT_REFACTOR':
|
||||
return AIFeatureType.textRefactor;
|
||||
case 'TEXT_SUMMARY':
|
||||
return AIFeatureType.textSummary;
|
||||
case 'AI_CHAT':
|
||||
return AIFeatureType.aiChat;
|
||||
case 'NOVEL_GENERATION':
|
||||
return AIFeatureType.novelGeneration;
|
||||
case 'PROFESSIONAL_FICTION_CONTINUATION':
|
||||
return AIFeatureType.professionalFictionContinuation;
|
||||
case 'SCENE_BEAT_GENERATION':
|
||||
return AIFeatureType.sceneBeatGeneration;
|
||||
case 'SCENE_TO_SUMMARY':
|
||||
return AIFeatureType.sceneToSummary;
|
||||
case 'SUMMARY_TO_SCENE':
|
||||
return AIFeatureType.summaryToScene;
|
||||
case 'NOVEL_COMPOSE':
|
||||
return AIFeatureType.novelCompose;
|
||||
case 'SETTING_TREE_GENERATION':
|
||||
return AIFeatureType.settingTreeGeneration;
|
||||
default:
|
||||
return AIFeatureType.textExpansion;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../models/prompt_models.dart';
|
||||
import '../../../services/api_service/repositories/impl/admin_repository_impl.dart';
|
||||
import '../../../utils/logger.dart';
|
||||
|
||||
/// 添加官方模板对话框
|
||||
class AddOfficialTemplateDialog extends StatefulWidget {
|
||||
final VoidCallback? onSuccess;
|
||||
|
||||
const AddOfficialTemplateDialog({
|
||||
Key? key,
|
||||
this.onSuccess,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<AddOfficialTemplateDialog> createState() => _AddOfficialTemplateDialogState();
|
||||
}
|
||||
|
||||
class _AddOfficialTemplateDialogState extends State<AddOfficialTemplateDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameController = TextEditingController();
|
||||
final _descriptionController = TextEditingController();
|
||||
final _templateContentController = TextEditingController();
|
||||
final _authorNameController = TextEditingController();
|
||||
final _versionController = TextEditingController(text: '1.0.0');
|
||||
final _tagsController = TextEditingController();
|
||||
|
||||
String _selectedFeatureType = 'CHAT';
|
||||
bool _isPublic = true;
|
||||
bool _isVerified = true;
|
||||
bool _isLoading = false;
|
||||
|
||||
final AdminRepositoryImpl _adminRepository = AdminRepositoryImpl();
|
||||
// 功能类型动态来源:AIFeatureTypeHelper.allFeatures
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_descriptionController.dispose();
|
||||
_templateContentController.dispose();
|
||||
_authorNameController.dispose();
|
||||
_versionController.dispose();
|
||||
_tagsController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
child: Container(
|
||||
width: 700,
|
||||
constraints: const BoxConstraints(maxHeight: 800),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.verified, size: 24, color: Colors.blue),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'添加官方模板',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Expanded(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildBasicInfoSection(),
|
||||
const SizedBox(height: 24),
|
||||
_buildTemplateContentSection(),
|
||||
const SizedBox(height: 24),
|
||||
_buildSettingsSection(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
_buildActionButtons(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBasicInfoSection() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'基本信息',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '模板名称 *',
|
||||
hintText: '请输入模板名称',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return '请输入模板名称';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: TextFormField(
|
||||
controller: _versionController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '版本号 *',
|
||||
hintText: '1.0.0',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return '请输入版本号';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextFormField(
|
||||
controller: _descriptionController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '模板描述',
|
||||
hintText: '请输入模板描述',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 2,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: _selectedFeatureType,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '功能类型 *',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: AIFeatureTypeHelper.allFeatures.map((t) {
|
||||
final api = t.toApiString();
|
||||
return DropdownMenuItem(
|
||||
value: api,
|
||||
child: Text(t.displayName),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_selectedFeatureType = value;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _authorNameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '作者名称',
|
||||
hintText: '请输入作者名称',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextFormField(
|
||||
controller: _tagsController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '标签',
|
||||
hintText: '请输入标签,用逗号分隔',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTemplateContentSection() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'模板内容',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextFormField(
|
||||
controller: _templateContentController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '模板内容 *',
|
||||
hintText: '请输入模板内容,支持变量占位符如 {{变量名}}',
|
||||
border: OutlineInputBorder(),
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
maxLines: 10,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return '请输入模板内容';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'提示:可以使用 {{变量名}} 作为占位符,用户使用时可以填入具体内容',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSettingsSection() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'设置选项',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
CheckboxListTile(
|
||||
title: const Text('立即发布'),
|
||||
subtitle: const Text('创建后立即发布到公共模板库'),
|
||||
value: _isPublic,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_isPublic = value ?? false;
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
CheckboxListTile(
|
||||
title: const Text('设为认证'),
|
||||
subtitle: const Text('标记为官方认证模板'),
|
||||
value: _isVerified,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_isVerified = value ?? false;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButtons() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _isLoading ? null : () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : _createOfficialTemplate,
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('创建'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _createOfficialTemplate() async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final tags = _tagsController.text
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.where((tag) => tag.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
final now = DateTime.now();
|
||||
final template = PromptTemplate(
|
||||
id: '', // 将由后端生成
|
||||
name: _nameController.text.trim(),
|
||||
content: _templateContentController.text.trim(),
|
||||
featureType: _getFeatureTypeEnum(_selectedFeatureType),
|
||||
isPublic: _isPublic,
|
||||
isVerified: _isVerified,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
description: _descriptionController.text.trim().isEmpty
|
||||
? null : _descriptionController.text.trim(),
|
||||
authorName: _authorNameController.text.trim().isEmpty
|
||||
? null : _authorNameController.text.trim(),
|
||||
templateTags: tags.isEmpty ? null : tags,
|
||||
);
|
||||
|
||||
await _adminRepository.createOfficialTemplate(template);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('官方模板 "${template.name}" 创建成功')),
|
||||
);
|
||||
widget.onSuccess?.call();
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.e('AddOfficialTemplateDialog', '创建官方模板失败', e);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('创建失败: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AIFeatureType _getFeatureTypeEnum(String featureType) {
|
||||
try {
|
||||
return AIFeatureTypeHelper.fromApiString(featureType.toUpperCase());
|
||||
} catch (_) {
|
||||
return AIFeatureType.aiChat;
|
||||
}
|
||||
}
|
||||
}
|
||||
722
AINoval/lib/screens/admin/widgets/add_public_model_dialog.dart
Normal file
722
AINoval/lib/screens/admin/widgets/add_public_model_dialog.dart
Normal file
@@ -0,0 +1,722 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../models/prompt_models.dart';
|
||||
import '../../../models/public_model_config.dart';
|
||||
import '../../../services/api_service/repositories/impl/admin_repository_impl.dart';
|
||||
import '../../../utils/logger.dart';
|
||||
import '../../../utils/web_theme.dart';
|
||||
import 'validation_results_dialog.dart';
|
||||
|
||||
/// 添加公共模型对话框 - 直接配置表单
|
||||
class AddPublicModelDialog extends StatefulWidget {
|
||||
const AddPublicModelDialog({
|
||||
super.key,
|
||||
required this.onSuccess,
|
||||
this.selectedProvider,
|
||||
this.sourceConfig,
|
||||
});
|
||||
|
||||
final VoidCallback onSuccess;
|
||||
final String? selectedProvider;
|
||||
final PublicModelConfigDetails? sourceConfig;
|
||||
|
||||
@override
|
||||
State<AddPublicModelDialog> createState() => _AddPublicModelDialogState();
|
||||
}
|
||||
|
||||
class _AddPublicModelDialogState extends State<AddPublicModelDialog> {
|
||||
final String _tag = 'AddPublicModelDialog';
|
||||
late final AdminRepositoryImpl _adminRepository;
|
||||
|
||||
// 表单数据
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _providerController = TextEditingController();
|
||||
final _modelIdController = TextEditingController();
|
||||
final _displayNameController = TextEditingController();
|
||||
final _apiEndpointController = TextEditingController();
|
||||
final _apiKeysController = TextEditingController();
|
||||
final _keyNotesController = TextEditingController();
|
||||
final _descriptionController = TextEditingController();
|
||||
final _tagsController = TextEditingController();
|
||||
final _creditRateController = TextEditingController(text: '1.0');
|
||||
final _maxConcurrentController = TextEditingController(text: '-1');
|
||||
final _dailyLimitController = TextEditingController(text: '-1');
|
||||
final _hourlyLimitController = TextEditingController(text: '-1');
|
||||
final _priorityController = TextEditingController(text: '0');
|
||||
|
||||
final Set<AIFeatureType> _selectedFeatures = {AIFeatureType.aiChat};
|
||||
bool _enabled = true;
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_adminRepository = AdminRepositoryImpl();
|
||||
|
||||
// 如果指定了提供商,预填充
|
||||
if (widget.selectedProvider != null) {
|
||||
_providerController.text = widget.selectedProvider!;
|
||||
}
|
||||
|
||||
// 如果有源配置,预填充数据(复制模式)
|
||||
if (widget.sourceConfig != null) {
|
||||
_initializeFromSource(widget.sourceConfig!);
|
||||
}
|
||||
}
|
||||
|
||||
void _initializeFromSource(PublicModelConfigDetails source) {
|
||||
// 基本信息 - 添加副本标识
|
||||
_providerController.text = source.provider;
|
||||
_modelIdController.text = '${source.modelId}-copy';
|
||||
_displayNameController.text = '${source.displayName ?? source.modelId} (副本)';
|
||||
_apiEndpointController.text = source.apiEndpoint ?? '';
|
||||
_enabled = source.enabled ?? true;
|
||||
|
||||
// 配置信息
|
||||
_descriptionController.text = source.description ?? '';
|
||||
_tagsController.text = source.tags?.join(', ') ?? '';
|
||||
_creditRateController.text = source.creditRateMultiplier?.toString() ?? '1.0';
|
||||
_maxConcurrentController.text = source.maxConcurrentRequests?.toString() ?? '-1';
|
||||
_dailyLimitController.text = source.dailyRequestLimit?.toString() ?? '-1';
|
||||
_hourlyLimitController.text = source.hourlyRequestLimit?.toString() ?? '-1';
|
||||
_priorityController.text = source.priority?.toString() ?? '0';
|
||||
|
||||
// 功能授权
|
||||
_selectedFeatures.clear();
|
||||
if (source.enabledForFeatures != null) {
|
||||
for (final featureStr in source.enabledForFeatures!) {
|
||||
final feature = AIFeatureTypeHelper.fromApiString(featureStr);
|
||||
_selectedFeatures.add(feature);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有选中任何功能,默认选中AI聊天
|
||||
if (_selectedFeatures.isEmpty) {
|
||||
_selectedFeatures.add(AIFeatureType.aiChat);
|
||||
}
|
||||
|
||||
// 加载完整的配置信息包括API Keys
|
||||
_loadFullConfigWithApiKeys(source.id!);
|
||||
}
|
||||
|
||||
Future<void> _loadFullConfigWithApiKeys(String configId) async {
|
||||
try {
|
||||
final fullConfig = await _adminRepository.getPublicModelConfigById(configId);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
// 复制实际的API Keys,每行一个
|
||||
if (fullConfig.apiKeyStatuses?.isNotEmpty == true) {
|
||||
final apiKeys = fullConfig.apiKeyStatuses!
|
||||
.map((status) => status.apiKey ?? '')
|
||||
.where((key) => key.isNotEmpty)
|
||||
.join('\n');
|
||||
_apiKeysController.text = apiKeys;
|
||||
_keyNotesController.text = fullConfig.apiKeyStatuses!
|
||||
.map((status) => status.note ?? '')
|
||||
.join('\n');
|
||||
} else {
|
||||
_apiKeysController.text = '';
|
||||
_keyNotesController.text = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '加载源配置API Keys失败', e);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
// 如果加载失败,留空让用户重新配置
|
||||
_apiKeysController.text = '';
|
||||
_keyNotesController.text = '';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_providerController.dispose();
|
||||
_modelIdController.dispose();
|
||||
_displayNameController.dispose();
|
||||
_apiEndpointController.dispose();
|
||||
_apiKeysController.dispose();
|
||||
_keyNotesController.dispose();
|
||||
_descriptionController.dispose();
|
||||
_tagsController.dispose();
|
||||
_creditRateController.dispose();
|
||||
_maxConcurrentController.dispose();
|
||||
_dailyLimitController.dispose();
|
||||
_hourlyLimitController.dispose();
|
||||
_priorityController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 保存配置
|
||||
Future<void> _saveConfig({required bool validate}) async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_providerController.text.isEmpty) {
|
||||
_showSnackBar('请输入提供商', isError: true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_modelIdController.text.isEmpty) {
|
||||
_showSnackBar('请输入模型ID', isError: true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_apiKeysController.text.trim().isEmpty) {
|
||||
_showSnackBar('请至少输入一个API Key', isError: true);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// 解析API Keys
|
||||
final apiKeyLines = _apiKeysController.text.split('\n').where((line) => line.trim().isNotEmpty).toList();
|
||||
final noteLines = _keyNotesController.text.split('\n');
|
||||
|
||||
final apiKeys = <ApiKeyRequest>[];
|
||||
for (int i = 0; i < apiKeyLines.length; i++) {
|
||||
final note = i < noteLines.length ? noteLines[i].trim() : '';
|
||||
apiKeys.add(ApiKeyRequest(
|
||||
apiKey: apiKeyLines[i].trim(),
|
||||
note: note.isEmpty ? null : note,
|
||||
));
|
||||
}
|
||||
|
||||
// 解析标签
|
||||
final tags = _tagsController.text.split(',').map((tag) => tag.trim()).where((tag) => tag.isNotEmpty).toList();
|
||||
|
||||
// 使用扩展方法转换功能类型枚举为字符串
|
||||
final enabledFeaturesStrings = AIFeatureTypeHelper.toApiStringList(_selectedFeatures);
|
||||
|
||||
final request = PublicModelConfigRequest(
|
||||
provider: _providerController.text,
|
||||
modelId: _modelIdController.text,
|
||||
displayName: _displayNameController.text.isEmpty ? null : _displayNameController.text,
|
||||
enabled: _enabled,
|
||||
apiKeys: apiKeys,
|
||||
apiEndpoint: _apiEndpointController.text.isEmpty ? null : _apiEndpointController.text,
|
||||
enabledForFeatures: enabledFeaturesStrings,
|
||||
creditRateMultiplier: double.tryParse(_creditRateController.text),
|
||||
maxConcurrentRequests: int.tryParse(_maxConcurrentController.text),
|
||||
dailyRequestLimit: int.tryParse(_dailyLimitController.text),
|
||||
hourlyRequestLimit: int.tryParse(_hourlyLimitController.text),
|
||||
priority: int.tryParse(_priorityController.text),
|
||||
description: _descriptionController.text.isEmpty ? null : _descriptionController.text,
|
||||
tags: tags,
|
||||
);
|
||||
|
||||
// 调用API创建配置,传递验证参数
|
||||
final result = await _adminRepository.createPublicModelConfig(request, validate: validate);
|
||||
|
||||
AppLogger.i(_tag, validate ? '✅ 创建并验证模型配置成功' : '✅ 创建模型配置成功');
|
||||
|
||||
if (validate) {
|
||||
// 拉取包含Key的明细并弹出结果
|
||||
try {
|
||||
final withKeys = await _adminRepository.getPublicModelConfigById(result.id!);
|
||||
if (mounted) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => ValidationResultsDialog(config: withKeys),
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
} else {
|
||||
_showSnackBar('模型配置创建成功!', isError: false);
|
||||
}
|
||||
|
||||
widget.onSuccess();
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, validate ? '创建并验证模型配置失败' : '创建模型配置失败', e);
|
||||
if (mounted) {
|
||||
_showSnackBar(validate ? '创建并验证失败: ${e.toString()}' : '创建失败: ${e.toString()}', isError: true);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
backgroundColor: WebTheme.getCardColor(context),
|
||||
child: Container(
|
||||
width: 900,
|
||||
height: 700,
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 头部
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
widget.sourceConfig != null ? '复制公共模型配置' : '添加公共模型配置',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
if (widget.sourceConfig != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.orange.withOpacity(0.3)),
|
||||
),
|
||||
child: Text(
|
||||
'基于: ${widget.sourceConfig!.displayName ?? widget.sourceConfig!.modelId}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.orange,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: Icon(Icons.close, color: WebTheme.getTextColor(context)),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 表单内容
|
||||
Expanded(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 基本信息 - 两列布局
|
||||
_buildSectionTitle('基本信息'),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildTextField(
|
||||
controller: _providerController,
|
||||
label: '提供商 *',
|
||||
hint: '如: openai, anthropic',
|
||||
validator: (value) => value?.trim().isEmpty == true ? '请输入提供商名称' : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildTextField(
|
||||
controller: _modelIdController,
|
||||
label: '模型ID *',
|
||||
hint: '如: gpt-4, claude-3-opus',
|
||||
validator: (value) => value?.trim().isEmpty == true ? '请输入模型ID' : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildTextField(
|
||||
controller: _displayNameController,
|
||||
label: '显示名称 *',
|
||||
hint: '用户界面显示的名称',
|
||||
validator: (value) => value?.trim().isEmpty == true ? '请输入显示名称' : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildTextField(
|
||||
controller: _apiEndpointController,
|
||||
label: 'API Endpoint',
|
||||
hint: '可选,自定义API地址',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// API Keys配置
|
||||
_buildSectionTitle('API Keys配置'),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildTextField(
|
||||
controller: _apiKeysController,
|
||||
label: 'API Keys *',
|
||||
hint: '每行一个API Key',
|
||||
maxLines: 3,
|
||||
validator: (value) => value?.trim().isEmpty == true ? '请至少输入一个API Key' : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildTextField(
|
||||
controller: _keyNotesController,
|
||||
label: 'Key备注',
|
||||
hint: '每行一个备注(可选)',
|
||||
maxLines: 3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 功能授权
|
||||
_buildSectionTitle('功能授权'),
|
||||
_buildFeatureSelection(),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 限制配置 - 三列布局
|
||||
_buildSectionTitle('限制配置'),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildTextField(
|
||||
controller: _creditRateController,
|
||||
label: '积分倍数',
|
||||
hint: '默认 1.0',
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (value) {
|
||||
if (value?.isNotEmpty == true) {
|
||||
final parsed = double.tryParse(value!);
|
||||
if (parsed == null || parsed <= 0) return '请输入大于0的数字';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildTextField(
|
||||
controller: _maxConcurrentController,
|
||||
label: '最大并发',
|
||||
hint: '-1表示无限制',
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildTextField(
|
||||
controller: _priorityController,
|
||||
label: '优先级',
|
||||
hint: '数字越大优先级越高',
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildTextField(
|
||||
controller: _dailyLimitController,
|
||||
label: '每日请求限制',
|
||||
hint: '-1表示无限制',
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildTextField(
|
||||
controller: _hourlyLimitController,
|
||||
label: '每小时请求限制',
|
||||
hint: '-1表示无限制',
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 启用状态开关
|
||||
Expanded(
|
||||
child: SwitchListTile(
|
||||
title: Text(
|
||||
'启用状态',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
value: _enabled,
|
||||
onChanged: (value) => setState(() => _enabled = value),
|
||||
activeColor: Colors.green,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 其他信息
|
||||
_buildSectionTitle('其他信息'),
|
||||
_buildTextField(
|
||||
controller: _descriptionController,
|
||||
label: '描述',
|
||||
hint: '模型用途、特点等描述信息',
|
||||
maxLines: 2,
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
_buildTextField(
|
||||
controller: _tagsController,
|
||||
label: '标签',
|
||||
hint: '用逗号分隔,如: 高性能,推荐,beta',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 底部按钮
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _isLoading ? null : () => Navigator.of(context).pop(),
|
||||
child: Text(
|
||||
'取消',
|
||||
style: TextStyle(color: WebTheme.getSecondaryTextColor(context)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : () => _saveConfig(validate: false),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: WebTheme.getSecondaryTextColor(context),
|
||||
foregroundColor: WebTheme.getBackgroundColor(context),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('仅保存'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : () => _saveConfig(validate: true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blue,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Text('保存并验证'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionTitle(String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextField({
|
||||
required TextEditingController controller,
|
||||
required String label,
|
||||
String? hint,
|
||||
int maxLines = 1,
|
||||
TextInputType? keyboardType,
|
||||
String? Function(String?)? validator,
|
||||
}) {
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
style: TextStyle(color: WebTheme.getTextColor(context), fontSize: 13),
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
hintText: hint,
|
||||
labelStyle: TextStyle(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
fontSize: 12,
|
||||
),
|
||||
hintStyle: TextStyle(
|
||||
color: WebTheme.getSecondaryTextColor(context).withValues(alpha: 0.7),
|
||||
fontSize: 12,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: const BorderSide(
|
||||
color: Colors.blue,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
filled: true,
|
||||
fillColor: WebTheme.getBackgroundColor(context),
|
||||
isDense: true,
|
||||
),
|
||||
maxLines: maxLines,
|
||||
keyboardType: keyboardType,
|
||||
validator: validator,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFeatureSelection() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: WebTheme.getBorderColor(context)),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
color: WebTheme.getBackgroundColor(context),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'选择授权功能 (至少选择一个)',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: AIFeatureTypeHelper.allFeatures.map((featureType) {
|
||||
final bool isSelected = _selectedFeatures.contains(featureType);
|
||||
return FilterChip(
|
||||
label: Text(
|
||||
featureType.displayName,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isSelected ? Colors.white : WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
tooltip: _getFeatureDescription(featureType),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
_selectedFeatures.add(featureType);
|
||||
} else {
|
||||
_selectedFeatures.remove(featureType);
|
||||
}
|
||||
});
|
||||
},
|
||||
selectedColor: Colors.blue,
|
||||
backgroundColor: WebTheme.getCardColor(context),
|
||||
showCheckmark: false,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
if (_selectedFeatures.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
'请至少选择一个功能',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getFeatureDescription(AIFeatureType type) {
|
||||
switch (type) {
|
||||
case AIFeatureType.aiChat:
|
||||
return 'AI对话功能';
|
||||
case AIFeatureType.textExpansion:
|
||||
return '文本内容扩展';
|
||||
case AIFeatureType.textRefactor:
|
||||
return '文本结构重构';
|
||||
case AIFeatureType.textSummary:
|
||||
return '文本内容总结';
|
||||
case AIFeatureType.sceneToSummary:
|
||||
return '场景生成摘要';
|
||||
case AIFeatureType.summaryToScene:
|
||||
return '摘要生成场景';
|
||||
case AIFeatureType.novelGeneration:
|
||||
return '小说内容生成';
|
||||
case AIFeatureType.professionalFictionContinuation:
|
||||
return '专业小说续写';
|
||||
case AIFeatureType.sceneBeatGeneration:
|
||||
return '场景节拍生成';
|
||||
case AIFeatureType.novelCompose:
|
||||
return '设定编排(大纲/章节/组合)';
|
||||
case AIFeatureType.settingTreeGeneration:
|
||||
return '设定树生成';
|
||||
}
|
||||
}
|
||||
|
||||
void _showSnackBar(String message, {bool isError = false}) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: isError ? Colors.red : Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
439
AINoval/lib/screens/admin/widgets/add_system_preset_dialog.dart
Normal file
439
AINoval/lib/screens/admin/widgets/add_system_preset_dialog.dart
Normal file
@@ -0,0 +1,439 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:convert';
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
import '../../../models/preset_models.dart';
|
||||
import '../../../models/prompt_models.dart';
|
||||
import '../../../models/context_selection_models.dart';
|
||||
import '../../../models/ai_request_models.dart';
|
||||
import '../../../services/api_service/repositories/impl/admin_repository_impl.dart';
|
||||
import '../../../utils/logger.dart';
|
||||
import '../../../widgets/common/form_dialog_template.dart';
|
||||
|
||||
/// 添加系统预设对话框
|
||||
class AddSystemPresetDialog extends StatefulWidget {
|
||||
final VoidCallback? onSuccess;
|
||||
|
||||
const AddSystemPresetDialog({
|
||||
Key? key,
|
||||
this.onSuccess,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<AddSystemPresetDialog> createState() => _AddSystemPresetDialogState();
|
||||
}
|
||||
|
||||
class _AddSystemPresetDialogState extends State<AddSystemPresetDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameController = TextEditingController();
|
||||
final _descriptionController = TextEditingController();
|
||||
final _systemPromptController = TextEditingController();
|
||||
final _userPromptController = TextEditingController();
|
||||
final _tagsController = TextEditingController();
|
||||
|
||||
String _selectedFeatureType = 'AI_CHAT';
|
||||
bool _showInQuickAccess = false;
|
||||
bool _enableSmartContext = true;
|
||||
double _temperature = 0.7;
|
||||
double _topP = 0.9;
|
||||
String? _selectedTemplateId;
|
||||
late ContextSelectionData _contextSelectionData;
|
||||
bool _isLoading = false;
|
||||
|
||||
final AdminRepositoryImpl _adminRepository = AdminRepositoryImpl();
|
||||
|
||||
// 功能类型由 AIFeatureTypeHelper.allFeatures 动态提供
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_descriptionController.dispose();
|
||||
_systemPromptController.dispose();
|
||||
_userPromptController.dispose();
|
||||
_tagsController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_contextSelectionData = FormFieldFactory.createPresetTemplateContextData();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
child: Container(
|
||||
width: 600,
|
||||
constraints: const BoxConstraints(maxHeight: 700),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.smart_button, size: 24),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'添加系统预设',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Expanded(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildBasicInfoSection(),
|
||||
const SizedBox(height: 24),
|
||||
_buildPromptSection(),
|
||||
const SizedBox(height: 24),
|
||||
_buildSettingsSection(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
_buildActionButtons(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBasicInfoSection() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'基本信息',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '预设名称 *',
|
||||
hintText: '请输入预设名称',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return '请输入预设名称';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextFormField(
|
||||
controller: _descriptionController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '预设描述',
|
||||
hintText: '请输入预设描述',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 2,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedFeatureType,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '功能类型 *',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: AIFeatureTypeHelper.allFeatures.map((t) {
|
||||
final api = t.toApiString();
|
||||
return DropdownMenuItem(
|
||||
value: api,
|
||||
child: Text(t.displayName),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_selectedFeatureType = value;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextFormField(
|
||||
controller: _tagsController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '标签',
|
||||
hintText: '请输入标签,用逗号分隔',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPromptSection() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'提示词配置',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextFormField(
|
||||
controller: _systemPromptController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '系统提示词 *',
|
||||
hintText: '请输入系统提示词',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 5,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return '请输入系统提示词';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextFormField(
|
||||
controller: _userPromptController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '用户提示词',
|
||||
hintText: '请输入用户提示词',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 3,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSettingsSection() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'设置选项',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
CheckboxListTile(
|
||||
title: const Text('显示在快捷访问'),
|
||||
subtitle: const Text('用户可以在快捷访问列表中看到此预设'),
|
||||
value: _showInQuickAccess,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_showInQuickAccess = value ?? false;
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
CheckboxListTile(
|
||||
title: const Text('启用智能上下文'),
|
||||
value: _enableSmartContext,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
_enableSmartContext = v ?? true;
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
// 温度
|
||||
FormFieldFactory.createTemperatureSliderField(
|
||||
context: context,
|
||||
value: _temperature,
|
||||
onChanged: (v) => setState(() => _temperature = v),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
// Top-P
|
||||
FormFieldFactory.createTopPSliderField(
|
||||
context: context,
|
||||
value: _topP,
|
||||
onChanged: (v) => setState(() => _topP = v),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
// 上下文选择
|
||||
FormFieldFactory.createContextSelectionField(
|
||||
contextData: _contextSelectionData,
|
||||
onSelectionChanged: (d) => setState(() => _contextSelectionData = d),
|
||||
title: '上下文选择',
|
||||
description: '选择参与提示词生成的上下文信息',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButtons() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _isLoading ? null : () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : _createPreset,
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('创建'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _createPreset() async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final tags = _tagsController.text
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.where((tag) => tag.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
final now = DateTime.now();
|
||||
// 构建 requestData 与哈希
|
||||
final requestJson = _buildRequestDataJson();
|
||||
final newHash = _generatePresetHash(requestJson);
|
||||
|
||||
final preset = AIPromptPreset(
|
||||
presetId: '',
|
||||
userId: 'system',
|
||||
presetName: _nameController.text.trim(),
|
||||
presetDescription: _descriptionController.text.trim().isNotEmpty
|
||||
? _descriptionController.text.trim()
|
||||
: null,
|
||||
aiFeatureType: _selectedFeatureType,
|
||||
systemPrompt: _systemPromptController.text.trim(),
|
||||
userPrompt: _userPromptController.text.trim(),
|
||||
presetTags: tags.isEmpty ? null : tags,
|
||||
presetHash: newHash,
|
||||
requestData: requestJson,
|
||||
isSystem: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
showInQuickAccess: _showInQuickAccess,
|
||||
isFavorite: false,
|
||||
isPublic: false,
|
||||
useCount: 0,
|
||||
);
|
||||
|
||||
await _adminRepository.createSystemPreset(preset);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('系统预设 "${preset.presetName}" 创建成功')),
|
||||
);
|
||||
widget.onSuccess?.call();
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.e('AddSystemPresetDialog', '创建系统预设失败', e);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('创建失败: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _buildRequestDataJson() {
|
||||
final reqType = _mapFeatureTypeToRequestType(_selectedFeatureType);
|
||||
final request = UniversalAIRequest(
|
||||
requestType: reqType,
|
||||
userId: 'system',
|
||||
novelId: _contextSelectionData.novelId,
|
||||
instructions: _userPromptController.text.trim().isNotEmpty
|
||||
? _userPromptController.text.trim()
|
||||
: null,
|
||||
contextSelections: _contextSelectionData,
|
||||
enableSmartContext: _enableSmartContext,
|
||||
parameters: {
|
||||
'enableSmartContext': _enableSmartContext,
|
||||
'temperature': _temperature,
|
||||
'topP': _topP,
|
||||
if (_selectedTemplateId != null) 'promptTemplateId': _selectedTemplateId,
|
||||
},
|
||||
metadata: {
|
||||
'source': 'admin_system_preset_creator',
|
||||
},
|
||||
);
|
||||
return jsonEncode(request.toApiJson());
|
||||
}
|
||||
|
||||
AIRequestType _mapFeatureTypeToRequestType(String featureType) {
|
||||
try {
|
||||
final ft = AIFeatureTypeHelper.fromApiString(featureType.toUpperCase());
|
||||
switch (ft) {
|
||||
case AIFeatureType.textExpansion:
|
||||
return AIRequestType.expansion;
|
||||
case AIFeatureType.textSummary:
|
||||
return AIRequestType.summary;
|
||||
case AIFeatureType.textRefactor:
|
||||
return AIRequestType.refactor;
|
||||
case AIFeatureType.aiChat:
|
||||
return AIRequestType.chat;
|
||||
case AIFeatureType.sceneToSummary:
|
||||
return AIRequestType.sceneSummary;
|
||||
case AIFeatureType.novelGeneration:
|
||||
return AIRequestType.generation;
|
||||
case AIFeatureType.novelCompose:
|
||||
return AIRequestType.novelCompose;
|
||||
default:
|
||||
return AIRequestType.expansion;
|
||||
}
|
||||
} catch (_) {
|
||||
return AIRequestType.expansion;
|
||||
}
|
||||
}
|
||||
|
||||
String _generatePresetHash(String requestDataJson) {
|
||||
try {
|
||||
final bytes = utf8.encode(requestDataJson);
|
||||
final digest = sha256.convert(bytes);
|
||||
return digest.toString();
|
||||
} catch (_) {
|
||||
return DateTime.now().millisecondsSinceEpoch.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
102
AINoval/lib/screens/admin/widgets/admin_data_table.dart
Normal file
102
AINoval/lib/screens/admin/widgets/admin_data_table.dart
Normal file
@@ -0,0 +1,102 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AdminDataTable extends StatelessWidget {
|
||||
final String title;
|
||||
final List<String> headers;
|
||||
final List<List<String>> rows;
|
||||
final List<VoidCallback>? actions;
|
||||
final List<String>? actionLabels;
|
||||
|
||||
const AdminDataTable({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.headers,
|
||||
required this.rows,
|
||||
this.actions,
|
||||
this.actionLabels,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题栏
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(8),
|
||||
topRight: Radius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (actions != null && actionLabels != null)
|
||||
Row(
|
||||
children: List.generate(
|
||||
actions!.length,
|
||||
(index) => Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: ElevatedButton(
|
||||
onPressed: actions![index],
|
||||
child: Text(actionLabels![index]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 数据表格
|
||||
if (rows.isNotEmpty)
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: DataTable(
|
||||
columns: headers
|
||||
.map((header) => DataColumn(
|
||||
label: Text(
|
||||
header,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
rows: rows
|
||||
.map((row) => DataRow(
|
||||
cells: row
|
||||
.map((cell) => DataCell(Text(cell)))
|
||||
.toList(),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'暂无数据',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
212
AINoval/lib/screens/admin/widgets/admin_sidebar.dart
Normal file
212
AINoval/lib/screens/admin/widgets/admin_sidebar.dart
Normal file
@@ -0,0 +1,212 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../utils/web_theme.dart';
|
||||
import '../../../widgets/common/permission_guard.dart';
|
||||
import '../../../services/permission_service.dart';
|
||||
|
||||
class AdminSidebar extends StatelessWidget {
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onItemSelected;
|
||||
|
||||
const AdminSidebar({
|
||||
super.key,
|
||||
required this.selectedIndex,
|
||||
required this.onItemSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 250,
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getCardColor(context),
|
||||
border: Border(
|
||||
right: BorderSide(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 标题
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.admin_panel_settings,
|
||||
size: 48,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'AI小说助手',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Text(
|
||||
'管理后台',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
height: 1,
|
||||
),
|
||||
// 菜单项
|
||||
Expanded(
|
||||
child: ListView(
|
||||
children: [
|
||||
PermissionGuard.permission(
|
||||
PermissionService.STATISTICS_VIEW,
|
||||
child: _buildMenuItem(
|
||||
context,
|
||||
icon: Icons.dashboard,
|
||||
title: '仪表板',
|
||||
index: 0,
|
||||
),
|
||||
),
|
||||
PermissionGuard.permission(
|
||||
PermissionService.STATISTICS_VIEW,
|
||||
child: _buildMenuItem(
|
||||
context,
|
||||
icon: Icons.visibility,
|
||||
title: 'LLM可观测性',
|
||||
index: 1,
|
||||
),
|
||||
),
|
||||
PermissionGuard.permission(
|
||||
PermissionService.USER_MANAGEMENT,
|
||||
child: _buildMenuItem(
|
||||
context,
|
||||
icon: Icons.people,
|
||||
title: '用户管理',
|
||||
index: 2,
|
||||
),
|
||||
),
|
||||
PermissionGuard.permission(
|
||||
PermissionService.USER_MANAGEMENT,
|
||||
child: _buildMenuItem(
|
||||
context,
|
||||
icon: Icons.security,
|
||||
title: '角色管理',
|
||||
index: 3,
|
||||
),
|
||||
),
|
||||
PermissionGuard.permission(
|
||||
PermissionService.SUBSCRIPTION_MANAGEMENT,
|
||||
child: _buildMenuItem(
|
||||
context,
|
||||
icon: Icons.subscriptions,
|
||||
title: '订阅管理',
|
||||
index: 4,
|
||||
),
|
||||
),
|
||||
PermissionGuard.permission(
|
||||
PermissionService.MODEL_MANAGEMENT,
|
||||
child: _buildMenuItem(
|
||||
context,
|
||||
icon: Icons.cloud,
|
||||
title: '公共模型',
|
||||
index: 5,
|
||||
),
|
||||
),
|
||||
PermissionGuard.permission(
|
||||
PermissionService.PRESET_MANAGEMENT,
|
||||
child: _buildMenuItem(
|
||||
context,
|
||||
icon: Icons.smart_button,
|
||||
title: '系统预设',
|
||||
index: 6,
|
||||
),
|
||||
),
|
||||
PermissionGuard.permission(
|
||||
PermissionService.TEMPLATE_MANAGEMENT,
|
||||
child: _buildMenuItem(
|
||||
context,
|
||||
icon: Icons.article,
|
||||
title: '公共模板',
|
||||
index: 7,
|
||||
),
|
||||
),
|
||||
PermissionGuard.permission(
|
||||
PermissionService.SYSTEM_CONFIG,
|
||||
child: _buildMenuItem(
|
||||
context,
|
||||
icon: Icons.settings,
|
||||
title: '系统配置',
|
||||
index: 8,
|
||||
),
|
||||
),
|
||||
PermissionGuard.permission(
|
||||
PermissionService.TEMPLATE_MANAGEMENT,
|
||||
child: _buildMenuItem(
|
||||
context,
|
||||
icon: Icons.auto_awesome,
|
||||
title: '增强模板',
|
||||
index: 9,
|
||||
),
|
||||
),
|
||||
PermissionGuard.permission(
|
||||
PermissionService.SYSTEM_CONFIG,
|
||||
child: _buildMenuItem(
|
||||
context,
|
||||
icon: Icons.receipt_long,
|
||||
title: '计费审计',
|
||||
index: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMenuItem(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required int index,
|
||||
}) {
|
||||
final isSelected = selectedIndex == index;
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
icon,
|
||||
color: isSelected
|
||||
? WebTheme.getTextColor(context)
|
||||
: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
title: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: isSelected
|
||||
? WebTheme.getTextColor(context)
|
||||
: WebTheme.getSecondaryTextColor(context),
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
selected: isSelected,
|
||||
selectedTileColor: WebTheme.getTextColor(context).withOpacity(0.1),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
onTap: () => onItemSelected(index),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
424
AINoval/lib/screens/admin/widgets/batch_operation_dialog.dart
Normal file
424
AINoval/lib/screens/admin/widgets/batch_operation_dialog.dart
Normal file
@@ -0,0 +1,424 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../models/prompt_models.dart';
|
||||
import '../../../utils/web_theme.dart';
|
||||
import '../../../widgets/common/dialog_container.dart';
|
||||
import '../../../widgets/common/dialog_header.dart';
|
||||
|
||||
/// 批量操作确认对话框
|
||||
class BatchOperationDialog extends StatefulWidget {
|
||||
final String operation;
|
||||
final String title;
|
||||
final String description;
|
||||
final List<EnhancedUserPromptTemplate> templates;
|
||||
final Function(String? comment) onConfirm;
|
||||
final Color? actionColor;
|
||||
final bool requiresComment;
|
||||
final String? commentHint;
|
||||
|
||||
const BatchOperationDialog({
|
||||
Key? key,
|
||||
required this.operation,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.templates,
|
||||
required this.onConfirm,
|
||||
this.actionColor,
|
||||
this.requiresComment = false,
|
||||
this.commentHint,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<BatchOperationDialog> createState() => _BatchOperationDialogState();
|
||||
}
|
||||
|
||||
class _BatchOperationDialogState extends State<BatchOperationDialog> {
|
||||
final _commentController = TextEditingController();
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_commentController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DialogContainer(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DialogHeader(
|
||||
title: widget.title,
|
||||
onClose: () => Navigator.of(context).pop(),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildWarningSection(),
|
||||
const SizedBox(height: 24),
|
||||
_buildTemplatesList(),
|
||||
if (widget.requiresComment || widget.commentHint != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
_buildCommentSection(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildActions(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWarningSection() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: (widget.actionColor ?? Colors.orange).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: (widget.actionColor ?? Colors.orange).withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning,
|
||||
color: widget.actionColor ?? Colors.orange,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'批量操作确认',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: widget.actionColor ?? Colors.orange,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.description,
|
||||
style: TextStyle(
|
||||
color: WebTheme.getTextColor(context).withOpacity(0.8),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTemplatesList() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.list, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'影响的模板 (${widget.templates.length} 个)',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxHeight: 200),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.withOpacity(0.3)),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: widget.templates.length,
|
||||
separatorBuilder: (context, index) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final template = widget.templates[index];
|
||||
return ListTile(
|
||||
dense: true,
|
||||
leading: CircleAvatar(
|
||||
radius: 12,
|
||||
backgroundColor: WebTheme.getPrimaryColor(context).withOpacity(0.1),
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
template.name,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
_getFeatureTypeLabel(template.featureType.toApiString()),
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
trailing: _buildTemplateStatusBadge(template),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTemplateStatusBadge(EnhancedUserPromptTemplate template) {
|
||||
String status;
|
||||
Color color;
|
||||
|
||||
if (template.isVerified) {
|
||||
status = '认证';
|
||||
color = Colors.green;
|
||||
} else if (template.isPublic) {
|
||||
status = '公开';
|
||||
color = Colors.blue;
|
||||
} else {
|
||||
status = '私有';
|
||||
color = Colors.grey;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: color.withOpacity(0.3)),
|
||||
),
|
||||
child: Text(
|
||||
status,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCommentSection() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.comment, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
widget.requiresComment ? '操作备注 *' : '操作备注(可选)',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _commentController,
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.commentHint ?? '请输入操作备注...',
|
||||
border: const OutlineInputBorder(),
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
maxLines: 3,
|
||||
),
|
||||
if (widget.requiresComment)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
'* 此操作需要填写备注信息',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.red.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActions() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getCardColor(context),
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _isLoading ? null : () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : _handleConfirm,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: widget.actionColor ?? WebTheme.getPrimaryColor(context),
|
||||
foregroundColor: WebTheme.white,
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: Text('确认${widget.operation}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleConfirm() async {
|
||||
// 检查是否需要备注且未填写
|
||||
if (widget.requiresComment && _commentController.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('请填写操作备注')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final comment = _commentController.text.trim();
|
||||
await widget.onConfirm(comment.isNotEmpty ? comment : null);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('操作失败: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _getFeatureTypeLabel(String? featureType) {
|
||||
switch (featureType) {
|
||||
case 'AI_CHAT':
|
||||
return 'AI聊天';
|
||||
case 'TEXT_EXPANSION':
|
||||
return '文本扩写';
|
||||
case 'TEXT_REFACTOR':
|
||||
return '文本润色';
|
||||
case 'TEXT_SUMMARY':
|
||||
return '文本总结';
|
||||
case 'SCENE_TO_SUMMARY':
|
||||
return '场景转摘要';
|
||||
case 'SUMMARY_TO_SCENE':
|
||||
return '摘要转场景';
|
||||
case 'NOVEL_GENERATION':
|
||||
return '小说生成';
|
||||
case 'PROFESSIONAL_FICTION_CONTINUATION':
|
||||
return '专业续写';
|
||||
case 'SCENE_BEAT_GENERATION':
|
||||
return '场景节拍生成';
|
||||
default:
|
||||
return featureType ?? '未知';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 批量操作类型枚举
|
||||
enum BatchOperationType {
|
||||
review,
|
||||
verify,
|
||||
publish,
|
||||
delete,
|
||||
export,
|
||||
}
|
||||
|
||||
/// 批量操作配置
|
||||
class BatchOperationConfig {
|
||||
final BatchOperationType type;
|
||||
final String title;
|
||||
final String description;
|
||||
final Color actionColor;
|
||||
final bool requiresComment;
|
||||
final String? commentHint;
|
||||
|
||||
const BatchOperationConfig({
|
||||
required this.type,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.actionColor,
|
||||
this.requiresComment = false,
|
||||
this.commentHint,
|
||||
});
|
||||
|
||||
static const Map<BatchOperationType, BatchOperationConfig> configs = {
|
||||
BatchOperationType.review: BatchOperationConfig(
|
||||
type: BatchOperationType.review,
|
||||
title: '批量审核',
|
||||
description: '您即将批量审核选中的模板。审核通过的模板将被发布为公共模板。',
|
||||
actionColor: Colors.green,
|
||||
requiresComment: false,
|
||||
commentHint: '可以添加审核意见(可选)',
|
||||
),
|
||||
BatchOperationType.verify: BatchOperationConfig(
|
||||
type: BatchOperationType.verify,
|
||||
title: '批量认证',
|
||||
description: '您即将为选中的模板添加官方认证标识。认证后的模板将显示认证徽章。',
|
||||
actionColor: Colors.blue,
|
||||
),
|
||||
BatchOperationType.publish: BatchOperationConfig(
|
||||
type: BatchOperationType.publish,
|
||||
title: '批量发布',
|
||||
description: '您即将批量发布选中的模板。发布后的模板将对所有用户可见。',
|
||||
actionColor: Colors.indigo,
|
||||
),
|
||||
BatchOperationType.delete: BatchOperationConfig(
|
||||
type: BatchOperationType.delete,
|
||||
title: '批量删除',
|
||||
description: '您即将永久删除选中的模板。此操作不可撤销,请谨慎操作!',
|
||||
actionColor: Colors.red,
|
||||
requiresComment: true,
|
||||
commentHint: '请说明删除原因',
|
||||
),
|
||||
BatchOperationType.export: BatchOperationConfig(
|
||||
type: BatchOperationType.export,
|
||||
title: '批量导出',
|
||||
description: '您即将导出选中的模板数据。导出的数据可用于备份或迁移。',
|
||||
actionColor: Colors.orange,
|
||||
),
|
||||
};
|
||||
}
|
||||
175
AINoval/lib/screens/admin/widgets/credit_operation_dialog.dart
Normal file
175
AINoval/lib/screens/admin/widgets/credit_operation_dialog.dart
Normal file
@@ -0,0 +1,175 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../../models/admin/admin_models.dart';
|
||||
|
||||
class CreditOperationDialog extends StatefulWidget {
|
||||
final AdminUser user;
|
||||
final bool isAdd; // true为添加积分,false为扣减积分
|
||||
|
||||
const CreditOperationDialog({
|
||||
super.key,
|
||||
required this.user,
|
||||
required this.isAdd,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CreditOperationDialog> createState() => _CreditOperationDialogState();
|
||||
}
|
||||
|
||||
class _CreditOperationDialogState extends State<CreditOperationDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _amountController = TextEditingController();
|
||||
final _reasonController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_amountController.dispose();
|
||||
_reasonController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
widget.isAdd ? '添加积分' : '扣减积分',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 400,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 用户信息
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.person,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.user.username,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'当前积分: ${widget.user.credits}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 积分数量输入
|
||||
TextFormField(
|
||||
controller: _amountController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '积分数量',
|
||||
hintText: '请输入${widget.isAdd ? "添加" : "扣减"}的积分数量',
|
||||
prefixIcon: const Icon(Icons.monetization_on),
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入积分数量';
|
||||
}
|
||||
final amount = int.tryParse(value);
|
||||
if (amount == null || amount <= 0) {
|
||||
return '请输入有效的积分数量';
|
||||
}
|
||||
if (!widget.isAdd && amount > widget.user.credits) {
|
||||
return '扣减积分不能超过用户当前积分';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 操作原因输入
|
||||
TextFormField(
|
||||
controller: _reasonController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '操作原因',
|
||||
hintText: '请输入${widget.isAdd ? "添加" : "扣减"}积分的原因',
|
||||
prefixIcon: const Icon(Icons.note),
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 3,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return '请输入操作原因';
|
||||
}
|
||||
if (value.trim().length < 5) {
|
||||
return '操作原因至少需要5个字符';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: _handleSubmit,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: widget.isAdd
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
child: Text(
|
||||
widget.isAdd ? '添加积分' : '扣减积分',
|
||||
style: TextStyle(
|
||||
color: widget.isAdd
|
||||
? Theme.of(context).colorScheme.onPrimary
|
||||
: Theme.of(context).colorScheme.onError,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _handleSubmit() {
|
||||
if (_formKey.currentState?.validate() == true) {
|
||||
final amount = int.parse(_amountController.text);
|
||||
final reason = _reasonController.text.trim();
|
||||
|
||||
Navigator.of(context).pop({
|
||||
'amount': amount,
|
||||
'reason': reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
720
AINoval/lib/screens/admin/widgets/edit_public_model_dialog.dart
Normal file
720
AINoval/lib/screens/admin/widgets/edit_public_model_dialog.dart
Normal file
@@ -0,0 +1,720 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../models/prompt_models.dart';
|
||||
import '../../../models/public_model_config.dart';
|
||||
import '../../../services/api_service/repositories/impl/admin_repository_impl.dart';
|
||||
import '../../../utils/logger.dart';
|
||||
import '../../../utils/web_theme.dart';
|
||||
import 'validation_results_dialog.dart';
|
||||
|
||||
/// 编辑公共模型对话框
|
||||
class EditPublicModelDialog extends StatefulWidget {
|
||||
const EditPublicModelDialog({
|
||||
super.key,
|
||||
required this.config,
|
||||
required this.onSuccess,
|
||||
});
|
||||
|
||||
final PublicModelConfigDetails config;
|
||||
final VoidCallback onSuccess;
|
||||
|
||||
@override
|
||||
State<EditPublicModelDialog> createState() => _EditPublicModelDialogState();
|
||||
}
|
||||
|
||||
class _EditPublicModelDialogState extends State<EditPublicModelDialog> {
|
||||
final String _tag = 'EditPublicModelDialog';
|
||||
late final AdminRepositoryImpl _adminRepository;
|
||||
|
||||
// 表单数据
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _providerController = TextEditingController();
|
||||
final _modelIdController = TextEditingController();
|
||||
final _displayNameController = TextEditingController();
|
||||
final _apiEndpointController = TextEditingController();
|
||||
final _apiKeysController = TextEditingController();
|
||||
final _keyNotesController = TextEditingController();
|
||||
final _descriptionController = TextEditingController();
|
||||
final _tagsController = TextEditingController();
|
||||
final _creditRateController = TextEditingController();
|
||||
final _maxConcurrentController = TextEditingController();
|
||||
final _dailyLimitController = TextEditingController();
|
||||
final _hourlyLimitController = TextEditingController();
|
||||
final _priorityController = TextEditingController();
|
||||
|
||||
final Set<AIFeatureType> _selectedFeatures = {};
|
||||
bool _enabled = true;
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_adminRepository = AdminRepositoryImpl();
|
||||
_initializeForm();
|
||||
}
|
||||
|
||||
void _initializeForm() {
|
||||
final config = widget.config;
|
||||
|
||||
// 基本信息
|
||||
_providerController.text = config.provider;
|
||||
_modelIdController.text = config.modelId;
|
||||
_displayNameController.text = config.displayName ?? '';
|
||||
_apiEndpointController.text = config.apiEndpoint ?? '';
|
||||
_enabled = config.enabled ?? true;
|
||||
|
||||
// 配置信息
|
||||
_descriptionController.text = config.description ?? '';
|
||||
_tagsController.text = config.tags?.join(', ') ?? '';
|
||||
_creditRateController.text = config.creditRateMultiplier?.toString() ?? '1.0';
|
||||
_maxConcurrentController.text = config.maxConcurrentRequests?.toString() ?? '-1';
|
||||
_dailyLimitController.text = config.dailyRequestLimit?.toString() ?? '-1';
|
||||
_hourlyLimitController.text = config.hourlyRequestLimit?.toString() ?? '-1';
|
||||
_priorityController.text = config.priority?.toString() ?? '0';
|
||||
|
||||
// 功能授权
|
||||
if (config.enabledForFeatures != null) {
|
||||
for (final featureStr in config.enabledForFeatures!) {
|
||||
final feature = AIFeatureTypeHelper.fromApiString(featureStr);
|
||||
_selectedFeatures.add(feature);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有选中任何功能,默认选中AI聊天
|
||||
if (_selectedFeatures.isEmpty) {
|
||||
_selectedFeatures.add(AIFeatureType.aiChat);
|
||||
}
|
||||
|
||||
// 加载完整的配置信息包括API Keys
|
||||
_loadFullConfigWithApiKeys();
|
||||
}
|
||||
|
||||
Future<void> _loadFullConfigWithApiKeys() async {
|
||||
try {
|
||||
final fullConfig = await _adminRepository.getPublicModelConfigById(widget.config.id!);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
// 显示实际的API Keys,每行一个
|
||||
if (fullConfig.apiKeyStatuses?.isNotEmpty == true) {
|
||||
final apiKeys = fullConfig.apiKeyStatuses!
|
||||
.map((status) => status.apiKey ?? '')
|
||||
.where((key) => key.isNotEmpty)
|
||||
.join('\n');
|
||||
_apiKeysController.text = apiKeys;
|
||||
_keyNotesController.text = fullConfig.apiKeyStatuses!
|
||||
.map((status) => status.note ?? '')
|
||||
.join('\n');
|
||||
} else {
|
||||
_apiKeysController.text = '';
|
||||
_keyNotesController.text = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '加载完整配置信息失败', e);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
// 如果加载失败,显示占位符
|
||||
_apiKeysController.text = '*** 加载API Keys失败 ***';
|
||||
_keyNotesController.text = widget.config.apiKeyStatuses?.map((status) => status.note ?? '').join('\n') ?? '';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_providerController.dispose();
|
||||
_modelIdController.dispose();
|
||||
_displayNameController.dispose();
|
||||
_apiEndpointController.dispose();
|
||||
_apiKeysController.dispose();
|
||||
_keyNotesController.dispose();
|
||||
_descriptionController.dispose();
|
||||
_tagsController.dispose();
|
||||
_creditRateController.dispose();
|
||||
_maxConcurrentController.dispose();
|
||||
_dailyLimitController.dispose();
|
||||
_hourlyLimitController.dispose();
|
||||
_priorityController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 保存配置
|
||||
Future<void> _saveConfig({required bool validate}) async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_providerController.text.isEmpty) {
|
||||
_showSnackBar('请输入提供商', isError: true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_modelIdController.text.isEmpty) {
|
||||
_showSnackBar('请输入模型ID', isError: true);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// 解析API Keys - 如果用户修改了API Keys
|
||||
List<ApiKeyRequest>? apiKeys;
|
||||
if (_apiKeysController.text.trim() != '*** API Keys已配置 ***') {
|
||||
final apiKeyLines = _apiKeysController.text.split('\n').where((line) => line.trim().isNotEmpty).toList();
|
||||
final noteLines = _keyNotesController.text.split('\n');
|
||||
|
||||
apiKeys = <ApiKeyRequest>[];
|
||||
for (int i = 0; i < apiKeyLines.length; i++) {
|
||||
final note = i < noteLines.length ? noteLines[i].trim() : '';
|
||||
apiKeys.add(ApiKeyRequest(
|
||||
apiKey: apiKeyLines[i].trim(),
|
||||
note: note.isEmpty ? null : note,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 解析标签
|
||||
final tags = _tagsController.text.split(',').map((tag) => tag.trim()).where((tag) => tag.isNotEmpty).toList();
|
||||
|
||||
// 使用扩展方法转换功能类型枚举为字符串
|
||||
final enabledFeaturesStrings = AIFeatureTypeHelper.toApiStringList(_selectedFeatures);
|
||||
|
||||
final request = PublicModelConfigRequest(
|
||||
provider: _providerController.text,
|
||||
modelId: _modelIdController.text,
|
||||
displayName: _displayNameController.text.isEmpty ? null : _displayNameController.text,
|
||||
enabled: _enabled,
|
||||
apiKeys: apiKeys, // 如果为null,后端保持原有API Keys不变
|
||||
apiEndpoint: _apiEndpointController.text.isEmpty ? null : _apiEndpointController.text,
|
||||
enabledForFeatures: enabledFeaturesStrings,
|
||||
creditRateMultiplier: double.tryParse(_creditRateController.text),
|
||||
maxConcurrentRequests: int.tryParse(_maxConcurrentController.text),
|
||||
dailyRequestLimit: int.tryParse(_dailyLimitController.text),
|
||||
hourlyRequestLimit: int.tryParse(_hourlyLimitController.text),
|
||||
priority: int.tryParse(_priorityController.text),
|
||||
description: _descriptionController.text.isEmpty ? null : _descriptionController.text,
|
||||
tags: tags,
|
||||
);
|
||||
|
||||
// 调用API更新配置
|
||||
await _adminRepository.updatePublicModelConfig(widget.config.id!, request, validate: validate);
|
||||
|
||||
AppLogger.i(_tag, validate ? '✅ 更新并验证模型配置成功' : '✅ 更新模型配置成功');
|
||||
|
||||
if (validate) {
|
||||
try {
|
||||
final withKeys = await _adminRepository.getPublicModelConfigById(widget.config.id!);
|
||||
if (mounted) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => ValidationResultsDialog(config: withKeys),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
_showSnackBar('模型配置更新成功,验证完成!', isError: false);
|
||||
}
|
||||
} else {
|
||||
_showSnackBar('模型配置更新成功!', isError: false);
|
||||
}
|
||||
|
||||
widget.onSuccess();
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, validate ? '更新并验证模型配置失败' : '更新模型配置失败', e);
|
||||
if (mounted) {
|
||||
_showSnackBar(validate ? '更新并验证失败: ${e.toString()}' : '更新失败: ${e.toString()}', isError: true);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
backgroundColor: WebTheme.getCardColor(context),
|
||||
child: Container(
|
||||
width: 900,
|
||||
height: 700,
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 头部
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'编辑公共模型配置',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.blue.withOpacity(0.3)),
|
||||
),
|
||||
child: Text(
|
||||
widget.config.provider,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.blue,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: Icon(Icons.close, color: WebTheme.getTextColor(context)),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 表单内容
|
||||
Expanded(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 基本信息 - 两列布局
|
||||
_buildSectionTitle('基本信息'),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildTextField(
|
||||
controller: _providerController,
|
||||
label: '提供商 *',
|
||||
hint: '如: openai, anthropic',
|
||||
validator: (value) => value?.trim().isEmpty == true ? '请输入提供商名称' : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildTextField(
|
||||
controller: _modelIdController,
|
||||
label: '模型ID *',
|
||||
hint: '如: gpt-4, claude-3-opus',
|
||||
validator: (value) => value?.trim().isEmpty == true ? '请输入模型ID' : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildTextField(
|
||||
controller: _displayNameController,
|
||||
label: '显示名称',
|
||||
hint: '用户界面显示的名称',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildTextField(
|
||||
controller: _apiEndpointController,
|
||||
label: 'API Endpoint',
|
||||
hint: '可选,自定义API地址',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// API Keys配置
|
||||
_buildSectionTitle('API Keys配置'),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildTextField(
|
||||
controller: _apiKeysController,
|
||||
label: 'API Keys',
|
||||
hint: '每行一个API Key,或保持不变',
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'提示: 如需修改API Keys,请清空并重新输入',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildTextField(
|
||||
controller: _keyNotesController,
|
||||
label: 'Key备注',
|
||||
hint: '每行一个备注(可选)',
|
||||
maxLines: 3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 功能授权
|
||||
_buildSectionTitle('功能授权'),
|
||||
_buildFeatureSelection(),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 限制配置 - 三列布局
|
||||
_buildSectionTitle('限制配置'),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildTextField(
|
||||
controller: _creditRateController,
|
||||
label: '积分倍数',
|
||||
hint: '默认 1.0',
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (value) {
|
||||
if (value?.isNotEmpty == true) {
|
||||
final parsed = double.tryParse(value!);
|
||||
if (parsed == null || parsed <= 0) return '请输入大于0的数字';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildTextField(
|
||||
controller: _maxConcurrentController,
|
||||
label: '最大并发',
|
||||
hint: '-1表示无限制',
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildTextField(
|
||||
controller: _priorityController,
|
||||
label: '优先级',
|
||||
hint: '数字越大优先级越高',
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildTextField(
|
||||
controller: _dailyLimitController,
|
||||
label: '每日请求限制',
|
||||
hint: '-1表示无限制',
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildTextField(
|
||||
controller: _hourlyLimitController,
|
||||
label: '每小时请求限制',
|
||||
hint: '-1表示无限制',
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 启用状态开关
|
||||
Expanded(
|
||||
child: SwitchListTile(
|
||||
title: Text(
|
||||
'启用状态',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
value: _enabled,
|
||||
onChanged: (value) => setState(() => _enabled = value),
|
||||
activeColor: Colors.green,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 其他信息
|
||||
_buildSectionTitle('其他信息'),
|
||||
_buildTextField(
|
||||
controller: _descriptionController,
|
||||
label: '描述',
|
||||
hint: '模型用途、特点等描述信息',
|
||||
maxLines: 2,
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
_buildTextField(
|
||||
controller: _tagsController,
|
||||
label: '标签',
|
||||
hint: '用逗号分隔,如: 高性能,推荐,beta',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 底部按钮
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _isLoading ? null : () => Navigator.of(context).pop(),
|
||||
child: Text(
|
||||
'取消',
|
||||
style: TextStyle(color: WebTheme.getSecondaryTextColor(context)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : () => _saveConfig(validate: false),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: WebTheme.getSecondaryTextColor(context),
|
||||
foregroundColor: WebTheme.getBackgroundColor(context),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('仅保存'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : () => _saveConfig(validate: true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blue,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Text('保存并验证'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionTitle(String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextField({
|
||||
required TextEditingController controller,
|
||||
required String label,
|
||||
String? hint,
|
||||
int maxLines = 1,
|
||||
TextInputType? keyboardType,
|
||||
String? Function(String?)? validator,
|
||||
}) {
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
style: TextStyle(color: WebTheme.getTextColor(context), fontSize: 13),
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
hintText: hint,
|
||||
labelStyle: TextStyle(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
fontSize: 12,
|
||||
),
|
||||
hintStyle: TextStyle(
|
||||
color: WebTheme.getSecondaryTextColor(context).withValues(alpha: 0.7),
|
||||
fontSize: 12,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: const BorderSide(
|
||||
color: Colors.blue,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
filled: true,
|
||||
fillColor: WebTheme.getBackgroundColor(context),
|
||||
isDense: true,
|
||||
),
|
||||
maxLines: maxLines,
|
||||
keyboardType: keyboardType,
|
||||
validator: validator,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFeatureSelection() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: WebTheme.getBorderColor(context)),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
color: WebTheme.getBackgroundColor(context),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'选择授权功能 (至少选择一个)',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: AIFeatureTypeHelper.allFeatures.map((featureType) {
|
||||
final bool isSelected = _selectedFeatures.contains(featureType);
|
||||
return FilterChip(
|
||||
label: Text(
|
||||
featureType.displayName,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isSelected ? Colors.white : WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
tooltip: _getFeatureDescription(featureType),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
_selectedFeatures.add(featureType);
|
||||
} else {
|
||||
_selectedFeatures.remove(featureType);
|
||||
}
|
||||
});
|
||||
},
|
||||
selectedColor: Colors.blue,
|
||||
backgroundColor: WebTheme.getCardColor(context),
|
||||
showCheckmark: false,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
if (_selectedFeatures.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
'请至少选择一个功能',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getFeatureDescription(AIFeatureType type) {
|
||||
switch (type) {
|
||||
case AIFeatureType.aiChat:
|
||||
return 'AI对话功能';
|
||||
case AIFeatureType.textExpansion:
|
||||
return '文本内容扩展';
|
||||
case AIFeatureType.textRefactor:
|
||||
return '文本结构重构';
|
||||
case AIFeatureType.textSummary:
|
||||
return '文本内容总结';
|
||||
case AIFeatureType.sceneToSummary:
|
||||
return '场景生成摘要';
|
||||
case AIFeatureType.summaryToScene:
|
||||
return '摘要生成场景';
|
||||
case AIFeatureType.novelGeneration:
|
||||
return '小说内容生成';
|
||||
case AIFeatureType.professionalFictionContinuation:
|
||||
return '专业小说续写';
|
||||
case AIFeatureType.sceneBeatGeneration:
|
||||
return '场景节拍生成';
|
||||
case AIFeatureType.novelCompose:
|
||||
return '设定编排(大纲/章节/组合)';
|
||||
case AIFeatureType.settingTreeGeneration:
|
||||
return '设定树生成';
|
||||
}
|
||||
}
|
||||
|
||||
void _showSnackBar(String message, {bool isError = false}) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: isError ? Colors.red : Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
559
AINoval/lib/screens/admin/widgets/edit_system_preset_dialog.dart
Normal file
559
AINoval/lib/screens/admin/widgets/edit_system_preset_dialog.dart
Normal file
@@ -0,0 +1,559 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:convert';
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
import '../../../models/preset_models.dart';
|
||||
import '../../../models/prompt_models.dart';
|
||||
import '../../../models/context_selection_models.dart';
|
||||
import '../../../models/ai_request_models.dart';
|
||||
import '../../../services/api_service/repositories/impl/admin_repository_impl.dart';
|
||||
import '../../../utils/logger.dart';
|
||||
import '../../../widgets/common/form_dialog_template.dart';
|
||||
|
||||
/// 编辑系统预设对话框
|
||||
class EditSystemPresetDialog extends StatefulWidget {
|
||||
final AIPromptPreset preset;
|
||||
final VoidCallback? onSuccess;
|
||||
|
||||
const EditSystemPresetDialog({
|
||||
Key? key,
|
||||
required this.preset,
|
||||
this.onSuccess,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<EditSystemPresetDialog> createState() => _EditSystemPresetDialogState();
|
||||
}
|
||||
|
||||
class _EditSystemPresetDialogState extends State<EditSystemPresetDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _descriptionController;
|
||||
late final TextEditingController _systemPromptController;
|
||||
late final TextEditingController _userPromptController;
|
||||
late final TextEditingController _tagsController;
|
||||
|
||||
late String _selectedFeatureType;
|
||||
late bool _showInQuickAccess;
|
||||
bool _enableSmartContext = true;
|
||||
double _temperature = 0.7;
|
||||
double _topP = 0.9;
|
||||
String? _selectedTemplateId;
|
||||
late ContextSelectionData _contextSelectionData;
|
||||
bool _isLoading = false;
|
||||
|
||||
final AdminRepositoryImpl _adminRepository = AdminRepositoryImpl();
|
||||
// 功能类型选项改为从 AIFeatureTypeHelper.allFeatures 动态获取
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeControllers();
|
||||
}
|
||||
|
||||
void _initializeControllers() {
|
||||
_nameController = TextEditingController(text: widget.preset.presetName ?? '');
|
||||
_descriptionController = TextEditingController(text: widget.preset.presetDescription ?? '');
|
||||
_systemPromptController = TextEditingController(text: widget.preset.systemPrompt);
|
||||
_userPromptController = TextEditingController(text: widget.preset.userPrompt);
|
||||
_tagsController = TextEditingController(
|
||||
text: widget.preset.presetTags?.join(', ') ?? '',
|
||||
);
|
||||
|
||||
// 如果传入的功能类型不在枚举表中,退回到一个安全的默认值,避免 Dropdown 报错
|
||||
final allApi = AIFeatureTypeHelper.allFeatures.map((e) => e.toApiString()).toList();
|
||||
_selectedFeatureType = allApi.contains(widget.preset.aiFeatureType)
|
||||
? widget.preset.aiFeatureType
|
||||
: AIFeatureType.aiChat.toApiString();
|
||||
_showInQuickAccess = widget.preset.showInQuickAccess;
|
||||
|
||||
// 初始化上下文与参数(从请求数据解析)
|
||||
try {
|
||||
final request = widget.preset.parsedRequest;
|
||||
if (request != null) {
|
||||
_enableSmartContext = request.enableSmartContext;
|
||||
_contextSelectionData = request.contextSelections ?? FormFieldFactory.createPresetTemplateContextData();
|
||||
final temp = request.parameters['temperature'];
|
||||
if (temp is num) _temperature = temp.toDouble();
|
||||
final topP = request.parameters['topP'];
|
||||
if (topP is num) _topP = topP.toDouble();
|
||||
final tmpl = request.parameters['promptTemplateId'];
|
||||
if (tmpl is String && tmpl.isNotEmpty) _selectedTemplateId = tmpl;
|
||||
} else {
|
||||
_contextSelectionData = FormFieldFactory.createPresetTemplateContextData();
|
||||
}
|
||||
} catch (e) {
|
||||
_contextSelectionData = FormFieldFactory.createPresetTemplateContextData();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_descriptionController.dispose();
|
||||
_systemPromptController.dispose();
|
||||
_userPromptController.dispose();
|
||||
_tagsController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
child: Container(
|
||||
width: 600,
|
||||
constraints: const BoxConstraints(maxHeight: 700),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.edit, size: 24),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'编辑系统预设',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Expanded(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildPresetInfo(),
|
||||
const SizedBox(height: 24),
|
||||
_buildBasicInfoSection(),
|
||||
const SizedBox(height: 24),
|
||||
_buildPromptSection(),
|
||||
const SizedBox(height: 24),
|
||||
_buildSettingsSection(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
_buildActionButtons(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPresetInfo() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'预设信息',
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildInfoItem('预设ID', widget.preset.presetId),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildInfoItem('使用次数', '${widget.preset.useCount}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildInfoItem('创建时间', _formatDateTime(widget.preset.createdAt) ?? '未知'),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildInfoItem('最后使用', _formatDateTime(widget.preset.lastUsedAt) ?? '从未使用'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoItem(String label, String value) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBasicInfoSection() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'基本信息',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '预设名称 *',
|
||||
hintText: '请输入预设名称',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return '请输入预设名称';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextFormField(
|
||||
controller: _descriptionController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '预设描述',
|
||||
hintText: '请输入预设描述',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 2,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedFeatureType,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '功能类型 *',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: AIFeatureTypeHelper.allFeatures.map((t) {
|
||||
final api = t.toApiString();
|
||||
return DropdownMenuItem(
|
||||
value: api,
|
||||
child: Text(t.displayName),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_selectedFeatureType = value;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextFormField(
|
||||
controller: _tagsController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '标签',
|
||||
hintText: '请输入标签,用逗号分隔',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPromptSection() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'提示词配置',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextFormField(
|
||||
controller: _systemPromptController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '系统提示词 *',
|
||||
hintText: '请输入系统提示词',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 5,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return '请输入系统提示词';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextFormField(
|
||||
controller: _userPromptController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '用户提示词',
|
||||
hintText: '请输入用户提示词',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 3,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSettingsSection() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'设置选项',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
CheckboxListTile(
|
||||
title: const Text('显示在快捷访问'),
|
||||
subtitle: const Text('用户可以在快捷访问列表中看到此预设'),
|
||||
value: _showInQuickAccess,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_showInQuickAccess = value ?? false;
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
CheckboxListTile(
|
||||
title: const Text('启用智能上下文'),
|
||||
value: _enableSmartContext,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
_enableSmartContext = v ?? true;
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
// 温度
|
||||
FormFieldFactory.createTemperatureSliderField(
|
||||
context: context,
|
||||
value: _temperature,
|
||||
onChanged: (v) => setState(() => _temperature = v),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
// Top-P
|
||||
FormFieldFactory.createTopPSliderField(
|
||||
context: context,
|
||||
value: _topP,
|
||||
onChanged: (v) => setState(() => _topP = v),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
// 上下文选择
|
||||
FormFieldFactory.createContextSelectionField(
|
||||
contextData: _contextSelectionData,
|
||||
onSelectionChanged: (d) => setState(() => _contextSelectionData = d),
|
||||
title: '上下文选择',
|
||||
description: '选择参与提示词生成的上下文信息',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButtons() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _isLoading ? null : () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : _updatePreset,
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('保存'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _updatePreset() async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final tags = _tagsController.text
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.where((tag) => tag.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
// 构建统一请求数据(包含上下文与参数)
|
||||
final requestJson = _buildRequestDataJson();
|
||||
final newHash = _generatePresetHash(requestJson);
|
||||
|
||||
final updatedPreset = widget.preset.copyWith(
|
||||
presetName: _nameController.text.trim(),
|
||||
presetDescription: _descriptionController.text.trim().isEmpty
|
||||
? null : _descriptionController.text.trim(),
|
||||
aiFeatureType: _selectedFeatureType,
|
||||
systemPrompt: _systemPromptController.text.trim(),
|
||||
userPrompt: _userPromptController.text.trim().isEmpty
|
||||
? '' : _userPromptController.text.trim(),
|
||||
presetTags: tags.isEmpty ? null : tags,
|
||||
showInQuickAccess: _showInQuickAccess,
|
||||
requestData: requestJson,
|
||||
presetHash: newHash,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
await _adminRepository.updateSystemPreset(updatedPreset);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('系统预设 "${updatedPreset.presetName}" 更新成功')),
|
||||
);
|
||||
widget.onSuccess?.call();
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.e('EditSystemPresetDialog', '更新系统预设失败', e);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('更新失败: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _buildRequestDataJson() {
|
||||
// 将系统预设编辑为一个可回放的 UniversalAIRequest
|
||||
final reqType = _mapFeatureTypeToRequestType(_selectedFeatureType);
|
||||
final request = UniversalAIRequest(
|
||||
requestType: reqType,
|
||||
userId: widget.preset.userId,
|
||||
novelId: _contextSelectionData.novelId,
|
||||
instructions: _userPromptController.text.trim().isNotEmpty
|
||||
? _userPromptController.text.trim()
|
||||
: null,
|
||||
contextSelections: _contextSelectionData,
|
||||
enableSmartContext: _enableSmartContext,
|
||||
parameters: {
|
||||
'enableSmartContext': _enableSmartContext,
|
||||
'temperature': _temperature,
|
||||
'topP': _topP,
|
||||
if (_selectedTemplateId != null) 'promptTemplateId': _selectedTemplateId,
|
||||
},
|
||||
metadata: {
|
||||
'source': 'admin_system_preset_editor',
|
||||
},
|
||||
);
|
||||
return jsonEncode(request.toApiJson());
|
||||
}
|
||||
|
||||
AIRequestType _mapFeatureTypeToRequestType(String featureType) {
|
||||
try {
|
||||
final ft = AIFeatureTypeHelper.fromApiString(featureType.toUpperCase());
|
||||
switch (ft) {
|
||||
case AIFeatureType.textExpansion:
|
||||
return AIRequestType.expansion;
|
||||
case AIFeatureType.textSummary:
|
||||
return AIRequestType.summary;
|
||||
case AIFeatureType.textRefactor:
|
||||
return AIRequestType.refactor;
|
||||
case AIFeatureType.aiChat:
|
||||
return AIRequestType.chat;
|
||||
case AIFeatureType.sceneToSummary:
|
||||
return AIRequestType.sceneSummary;
|
||||
case AIFeatureType.novelGeneration:
|
||||
return AIRequestType.generation;
|
||||
case AIFeatureType.novelCompose:
|
||||
return AIRequestType.novelCompose;
|
||||
default:
|
||||
return AIRequestType.expansion;
|
||||
}
|
||||
} catch (_) {
|
||||
return AIRequestType.expansion;
|
||||
}
|
||||
}
|
||||
|
||||
String _generatePresetHash(String requestDataJson) {
|
||||
try {
|
||||
final bytes = utf8.encode(requestDataJson);
|
||||
final digest = sha256.convert(bytes);
|
||||
return digest.toString();
|
||||
} catch (_) {
|
||||
return DateTime.now().millisecondsSinceEpoch.toString();
|
||||
}
|
||||
}
|
||||
|
||||
String? _formatDateTime(DateTime? dateTime) {
|
||||
if (dateTime == null) return null;
|
||||
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(dateTime);
|
||||
|
||||
if (difference.inDays > 0) {
|
||||
return '${difference.inDays}天前';
|
||||
} else if (difference.inHours > 0) {
|
||||
return '${difference.inHours}小时前';
|
||||
} else if (difference.inMinutes > 0) {
|
||||
return '${difference.inMinutes}分钟前';
|
||||
} else {
|
||||
return '刚刚';
|
||||
}
|
||||
}
|
||||
}
|
||||
744
AINoval/lib/screens/admin/widgets/edit_template_dialog.dart
Normal file
744
AINoval/lib/screens/admin/widgets/edit_template_dialog.dart
Normal file
@@ -0,0 +1,744 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../models/prompt_models.dart';
|
||||
import '../../../services/api_service/repositories/impl/admin_repository_templates_extension.dart';
|
||||
import '../../../services/api_service/repositories/impl/admin_repository_impl.dart';
|
||||
import '../../../utils/web_theme.dart';
|
||||
import '../../../utils/logger.dart';
|
||||
import '../../../widgets/common/dialog_container.dart';
|
||||
import '../../../widgets/common/dialog_header.dart';
|
||||
|
||||
/// 编辑提示词模板对话框
|
||||
class EditTemplateDialog extends StatefulWidget {
|
||||
final PromptTemplate template;
|
||||
final VoidCallback? onSuccess;
|
||||
|
||||
const EditTemplateDialog({
|
||||
Key? key,
|
||||
required this.template,
|
||||
this.onSuccess,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<EditTemplateDialog> createState() => _EditTemplateDialogState();
|
||||
}
|
||||
|
||||
class _EditTemplateDialogState extends State<EditTemplateDialog> with TickerProviderStateMixin {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _descriptionController;
|
||||
late final TextEditingController _systemPromptController;
|
||||
late final TextEditingController _userPromptController;
|
||||
late final TextEditingController _tagsController;
|
||||
|
||||
late AIFeatureType _featureType;
|
||||
late bool _isPublic;
|
||||
late bool _isVerified;
|
||||
late bool _isDefault;
|
||||
bool _isLoading = false;
|
||||
bool _isEdited = false;
|
||||
|
||||
late TabController _tabController;
|
||||
|
||||
final AdminRepositoryImpl _adminRepository = AdminRepositoryImpl();
|
||||
|
||||
final List<AIFeatureType> _featureTypes = [
|
||||
AIFeatureType.textExpansion,
|
||||
AIFeatureType.textRefactor,
|
||||
AIFeatureType.textSummary,
|
||||
AIFeatureType.sceneToSummary,
|
||||
AIFeatureType.summaryToScene,
|
||||
AIFeatureType.aiChat,
|
||||
AIFeatureType.novelGeneration,
|
||||
AIFeatureType.professionalFictionContinuation,
|
||||
AIFeatureType.sceneBeatGeneration,
|
||||
AIFeatureType.novelCompose,
|
||||
AIFeatureType.settingTreeGeneration,
|
||||
];
|
||||
|
||||
final Map<AIFeatureType, String> _featureTypeLabels = {
|
||||
AIFeatureType.textExpansion: '文本扩写',
|
||||
AIFeatureType.textRefactor: '文本润色',
|
||||
AIFeatureType.textSummary: '文本总结',
|
||||
AIFeatureType.sceneToSummary: '场景转摘要',
|
||||
AIFeatureType.summaryToScene: '摘要转场景',
|
||||
AIFeatureType.aiChat: 'AI对话',
|
||||
AIFeatureType.novelGeneration: '小说生成',
|
||||
AIFeatureType.professionalFictionContinuation: '专业续写',
|
||||
AIFeatureType.sceneBeatGeneration: '场景节拍生成',
|
||||
AIFeatureType.novelCompose: '设定编排',
|
||||
AIFeatureType.settingTreeGeneration: '设定树生成',
|
||||
};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
_initializeControllers();
|
||||
}
|
||||
|
||||
void _initializeControllers() {
|
||||
_nameController = TextEditingController(text: widget.template.name);
|
||||
_descriptionController = TextEditingController(text: widget.template.description ?? '');
|
||||
// 将content拆分为systemPrompt和userPrompt,这里简单处理
|
||||
_systemPromptController = TextEditingController(text: '');
|
||||
_userPromptController = TextEditingController(text: widget.template.content);
|
||||
_tagsController = TextEditingController(text: widget.template.templateTags?.join(', ') ?? '');
|
||||
|
||||
_featureType = widget.template.featureType;
|
||||
_isPublic = widget.template.isPublic;
|
||||
_isVerified = widget.template.isVerified;
|
||||
_isDefault = widget.template.isDefault;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
_nameController.dispose();
|
||||
_descriptionController.dispose();
|
||||
_systemPromptController.dispose();
|
||||
_userPromptController.dispose();
|
||||
_tagsController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DialogContainer(
|
||||
maxWidth: 800,
|
||||
height: 700,
|
||||
child: Column(
|
||||
children: [
|
||||
DialogHeader(
|
||||
title: '编辑模板 - ${widget.template.name}',
|
||||
onClose: () => Navigator.of(context).pop(),
|
||||
),
|
||||
_buildTopBar(),
|
||||
_buildTabBar(),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildContentEditor(),
|
||||
_buildPropertiesEditor(),
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildActions(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建顶部标题栏(参考业务组件)
|
||||
Widget _buildTopBar() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getSurfaceColor(context),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 模板标题编辑
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _nameController,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
height: 1.2,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: '输入模板名称...',
|
||||
border: InputBorder.none,
|
||||
hintStyle: TextStyle(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_isEdited = true;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建标签栏
|
||||
Widget _buildTabBar() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getSurfaceColor(context),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: WebTheme.getTextColor(context),
|
||||
unselectedLabelColor: WebTheme.getSecondaryTextColor(context),
|
||||
indicatorColor: WebTheme.getTextColor(context),
|
||||
dividerColor: Colors.transparent,
|
||||
tabs: const [
|
||||
Tab(
|
||||
text: '内容编辑',
|
||||
icon: Icon(Icons.edit, size: 16),
|
||||
),
|
||||
Tab(
|
||||
text: '属性设置',
|
||||
icon: Icon(Icons.settings, size: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建内容编辑器(参考 PromptContentEditor)
|
||||
Widget _buildContentEditor() {
|
||||
return Container(
|
||||
color: WebTheme.getSurfaceColor(context),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 占位符提示
|
||||
_buildPlaceholderChips(),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 系统提示词编辑器
|
||||
_buildSystemPromptEditor(),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 用户提示词编辑器
|
||||
Expanded(
|
||||
child: _buildUserPromptEditor(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建占位符提示
|
||||
Widget _buildPlaceholderChips() {
|
||||
final placeholders = [
|
||||
'content', 'context', 'requirement', 'style', 'length'
|
||||
];
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'可用占位符',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: placeholders.map((placeholder) => _buildPlaceholderChip(placeholder)).toList(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建占位符芯片
|
||||
Widget _buildPlaceholderChip(String placeholder) {
|
||||
final primaryColor = WebTheme.getPrimaryColor(context);
|
||||
|
||||
return Tooltip(
|
||||
message: _getPlaceholderDescription(placeholder),
|
||||
child: ActionChip(
|
||||
label: Text(
|
||||
'{$placeholder}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: primaryColor,
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
_insertPlaceholder(placeholder);
|
||||
},
|
||||
backgroundColor: primaryColor.withOpacity(0.1),
|
||||
side: BorderSide(
|
||||
color: primaryColor.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建系统提示词编辑器
|
||||
Widget _buildSystemPromptEditor() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'系统提示词 (System Prompt)',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: WebTheme.getSurfaceColor(context),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _systemPromptController,
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
decoration: InputDecoration(
|
||||
hintText: '输入系统提示词...\n\n系统提示词用于设置AI的角色和基本行为规则。',
|
||||
hintStyle: TextStyle(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.4,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_isEdited = true;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建用户提示词编辑器
|
||||
Widget _buildUserPromptEditor() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'用户提示词 (User Prompt)',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: WebTheme.getSurfaceColor(context),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _userPromptController,
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
decoration: InputDecoration(
|
||||
hintText: '输入用户提示词...\n\n用户提示词包含具体的任务指令和要求。可以使用占位符来动态插入内容。',
|
||||
hintStyle: TextStyle(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.4,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_isEdited = true;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建属性编辑器(参考 PromptPropertiesEditor)
|
||||
Widget _buildPropertiesEditor() {
|
||||
return Container(
|
||||
color: WebTheme.getSurfaceColor(context),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SingleChildScrollView(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildBasicInfo(),
|
||||
const SizedBox(height: 24),
|
||||
_buildSettings(),
|
||||
const SizedBox(height: 24),
|
||||
_buildMetadata(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBasicInfo() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'基础信息',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '模板名称 *',
|
||||
hintText: '请输入模板名称',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return '请输入模板名称';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _descriptionController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '模板描述',
|
||||
hintText: '请输入模板描述',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 3,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_isEdited = true;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<AIFeatureType>(
|
||||
value: _featureType,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '功能类型 *',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: _featureTypes.map((type) {
|
||||
return DropdownMenuItem(
|
||||
value: type,
|
||||
child: Text(_featureTypeLabels[type] ?? type.name),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_featureType = value;
|
||||
_isEdited = true;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _tagsController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '标签',
|
||||
hintText: '请输入标签,用逗号分隔',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_isEdited = true;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 插入占位符
|
||||
void _insertPlaceholder(String placeholder) {
|
||||
final currentText = _userPromptController.text;
|
||||
final selection = _userPromptController.selection;
|
||||
final newText = currentText.replaceRange(
|
||||
selection.start,
|
||||
selection.end,
|
||||
'{$placeholder}',
|
||||
);
|
||||
_userPromptController.text = newText;
|
||||
_userPromptController.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: selection.start + placeholder.length + 2),
|
||||
);
|
||||
setState(() {
|
||||
_isEdited = true;
|
||||
});
|
||||
}
|
||||
|
||||
/// 获取占位符描述
|
||||
String _getPlaceholderDescription(String placeholder) {
|
||||
switch (placeholder) {
|
||||
case 'content':
|
||||
return '要处理的主要内容';
|
||||
case 'context':
|
||||
return '上下文信息';
|
||||
case 'requirement':
|
||||
return '具体要求';
|
||||
case 'style':
|
||||
return '风格要求';
|
||||
case 'length':
|
||||
return '长度要求';
|
||||
default:
|
||||
return '占位符:$placeholder';
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建元数据显示
|
||||
Widget _buildMetadata() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'元数据',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildMetadataRow('创建时间', _formatDateTime(widget.template.createdAt)),
|
||||
_buildMetadataRow('更新时间', _formatDateTime(widget.template.updatedAt)),
|
||||
_buildMetadataRow('使用次数', widget.template.useCount?.toString() ?? '0'),
|
||||
_buildMetadataRow('评分', widget.template.averageRating?.toStringAsFixed(1) ?? '无'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建元数据行
|
||||
Widget _buildMetadataRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 格式化日期时间
|
||||
String _formatDateTime(DateTime dateTime) {
|
||||
return '${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-${dateTime.day.toString().padLeft(2, '0')} '
|
||||
'${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
Widget _buildSettings() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'设置选项',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CheckboxListTile(
|
||||
title: const Text('公开模板'),
|
||||
subtitle: const Text('是否将此模板设为公开可见'),
|
||||
value: _isPublic,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_isPublic = value ?? false;
|
||||
_isEdited = true;
|
||||
});
|
||||
},
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: const Text('官方认证'),
|
||||
subtitle: const Text('是否标记为官方认证模板'),
|
||||
value: _isVerified,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_isVerified = value ?? false;
|
||||
_isEdited = true;
|
||||
});
|
||||
},
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: const Text('默认模板'),
|
||||
subtitle: const Text('是否设为该功能类型的默认模板'),
|
||||
value: _isDefault,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_isDefault = value ?? false;
|
||||
_isEdited = true;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActions() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(color: WebTheme.getBorderColor(context)),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _isLoading ? null : () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
if (_isEdited || _isLoading)
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : _saveTemplate,
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('保存'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _saveTemplate() async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final tags = _tagsController.text
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.where((tag) => tag.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
final updatedTemplate = widget.template.copyWith(
|
||||
name: _nameController.text.trim(),
|
||||
description: _descriptionController.text.trim().isNotEmpty
|
||||
? _descriptionController.text.trim()
|
||||
: null,
|
||||
content: _combinePrompts(),
|
||||
featureType: _featureType,
|
||||
templateTags: tags,
|
||||
isPublic: _isPublic,
|
||||
isVerified: _isVerified,
|
||||
isDefault: _isDefault,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
await _adminRepository.updateTemplate(widget.template.id, updatedTemplate);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isEdited = false;
|
||||
});
|
||||
Navigator.of(context).pop();
|
||||
widget.onSuccess?.call();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('模板更新成功')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.e('EditTemplateDialog', '更新模板失败', e);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('更新失败: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 合并系统提示词和用户提示词
|
||||
String _combinePrompts() {
|
||||
final systemPrompt = _systemPromptController.text.trim();
|
||||
final userPrompt = _userPromptController.text.trim();
|
||||
|
||||
if (systemPrompt.isEmpty) {
|
||||
return userPrompt;
|
||||
} else if (userPrompt.isEmpty) {
|
||||
return systemPrompt;
|
||||
} else {
|
||||
return '$systemPrompt\n\n$userPrompt';
|
||||
}
|
||||
}
|
||||
}
|
||||
332
AINoval/lib/screens/admin/widgets/enhanced_template_card.dart
Normal file
332
AINoval/lib/screens/admin/widgets/enhanced_template_card.dart
Normal file
@@ -0,0 +1,332 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../models/prompt_models.dart';
|
||||
import '../../../utils/web_theme.dart';
|
||||
|
||||
/// 增强模板卡片组件
|
||||
class EnhancedTemplateCard extends StatelessWidget {
|
||||
final EnhancedUserPromptTemplate template;
|
||||
final bool isSelected;
|
||||
final bool batchMode;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onEdit;
|
||||
final VoidCallback? onDelete;
|
||||
final VoidCallback? onReview;
|
||||
final VoidCallback? onToggleVerified;
|
||||
final VoidCallback? onTogglePublish;
|
||||
final VoidCallback? onViewStats;
|
||||
final VoidCallback? onViewDetails;
|
||||
final VoidCallback? onDuplicate;
|
||||
final ValueChanged<bool>? onSelectionChanged;
|
||||
|
||||
const EnhancedTemplateCard({
|
||||
Key? key,
|
||||
required this.template,
|
||||
this.isSelected = false,
|
||||
this.batchMode = false,
|
||||
this.onTap,
|
||||
this.onEdit,
|
||||
this.onDelete,
|
||||
this.onReview,
|
||||
this.onToggleVerified,
|
||||
this.onTogglePublish,
|
||||
this.onViewStats,
|
||||
this.onViewDetails,
|
||||
this.onDuplicate,
|
||||
this.onSelectionChanged,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
color: WebTheme.getCardColor(context),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (batchMode)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: Checkbox(
|
||||
value: isSelected,
|
||||
onChanged: onSelectionChanged != null ? (value) => onSelectionChanged!(value ?? false) : null,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
template.name,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildStatusBadges(),
|
||||
],
|
||||
),
|
||||
if (template.description?.isNotEmpty == true)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
template.description!,
|
||||
style: TextStyle(
|
||||
color: WebTheme.getTextColor(context).withOpacity(0.7),
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!batchMode)
|
||||
PopupMenuButton<String>(
|
||||
onSelected: _handleMenuAction,
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(value: 'duplicate', child: Text('复制为新模板')),
|
||||
if (template.isPublic == true && template.isVerified != true)
|
||||
const PopupMenuItem(value: 'review', child: Text('审核')),
|
||||
PopupMenuItem(
|
||||
value: 'verify',
|
||||
child: Text(template.isVerified == true ? '取消认证' : '设为认证'),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'publish',
|
||||
child: Text(template.isPublic == true ? '取消发布' : '发布'),
|
||||
),
|
||||
const PopupMenuItem(value: 'stats', child: Text('统计信息')),
|
||||
const PopupMenuItem(value: 'delete', child: Text('删除')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildTemplateInfo(),
|
||||
const SizedBox(height: 12),
|
||||
_buildTemplateStats(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusBadges() {
|
||||
return Row(
|
||||
children: [
|
||||
if (template.isVerified == true)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
margin: const EdgeInsets.only(left: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: Colors.green),
|
||||
),
|
||||
child: const Text(
|
||||
'已认证',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.green,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (template.isPublic)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
margin: const EdgeInsets.only(left: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: Colors.blue),
|
||||
),
|
||||
child: const Text(
|
||||
'公开',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.blue,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (template.isPublic && !template.isVerified)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
margin: const EdgeInsets.only(left: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: Colors.orange),
|
||||
),
|
||||
child: const Text(
|
||||
'待审核',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.orange,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTemplateInfo() {
|
||||
return Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_buildInfoItem(Icons.category, '功能类型', template.featureType.displayName),
|
||||
_buildInfoItem(Icons.language, '语言', template.language ?? 'zh'),
|
||||
if (template.tags.isNotEmpty)
|
||||
_buildInfoItem(Icons.label, '标签', template.tags.take(3).join(', ')),
|
||||
_buildInfoItem(Icons.person, '作者', template.authorId ?? '未知'),
|
||||
if (template.version != null)
|
||||
_buildInfoItem(Icons.history, '版本', template.version.toString()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoItem(IconData icon, String label, String value) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'$label: ',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTemplateStats() {
|
||||
return Row(
|
||||
children: [
|
||||
_buildStatChip(
|
||||
icon: Icons.play_arrow,
|
||||
label: '使用',
|
||||
value: template.usageCount.toString(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildStatChip(
|
||||
icon: Icons.favorite,
|
||||
label: '收藏',
|
||||
value: (template.favoriteCount ?? 0).toString(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (template.rating > 0)
|
||||
_buildStatChip(
|
||||
icon: Icons.star,
|
||||
label: '评分',
|
||||
value: template.rating.toStringAsFixed(1),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'创建于 ${_formatDate(template.createdAt)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatChip({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required String value,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
' $label',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
void _handleMenuAction(String action) {
|
||||
switch (action) {
|
||||
case 'duplicate':
|
||||
onDuplicate?.call();
|
||||
break;
|
||||
case 'review':
|
||||
onReview?.call();
|
||||
break;
|
||||
case 'verify':
|
||||
onToggleVerified?.call();
|
||||
break;
|
||||
case 'publish':
|
||||
onTogglePublish?.call();
|
||||
break;
|
||||
case 'stats':
|
||||
onViewStats?.call();
|
||||
break;
|
||||
case 'delete':
|
||||
onDelete?.call();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
424
AINoval/lib/screens/admin/widgets/enhanced_template_editor.dart
Normal file
424
AINoval/lib/screens/admin/widgets/enhanced_template_editor.dart
Normal file
@@ -0,0 +1,424 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../models/prompt_models.dart';
|
||||
import '../../../services/api_service/repositories/impl/admin_repository_impl.dart';
|
||||
import '../../../utils/web_theme.dart';
|
||||
|
||||
/// 增强模板右侧编辑器(可创建/更新)
|
||||
class EnhancedTemplateEditor extends StatefulWidget {
|
||||
final EnhancedUserPromptTemplate? template;
|
||||
final VoidCallback? onCancel;
|
||||
final ValueChanged<EnhancedUserPromptTemplate>? onSaved;
|
||||
|
||||
const EnhancedTemplateEditor({
|
||||
super.key,
|
||||
this.template,
|
||||
this.onCancel,
|
||||
this.onSaved,
|
||||
});
|
||||
|
||||
@override
|
||||
State<EnhancedTemplateEditor> createState() => _EnhancedTemplateEditorState();
|
||||
}
|
||||
|
||||
class _EnhancedTemplateEditorState extends State<EnhancedTemplateEditor>
|
||||
with TickerProviderStateMixin {
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _descriptionController;
|
||||
late final TextEditingController _systemPromptController;
|
||||
late final TextEditingController _userPromptController;
|
||||
late final TextEditingController _tagsController;
|
||||
late final TextEditingController _authorIdController;
|
||||
late final TextEditingController _userIdController;
|
||||
late final TextEditingController _categoriesController;
|
||||
|
||||
late String _featureType;
|
||||
late String _language;
|
||||
late bool _isVerified;
|
||||
bool _isSaving = false;
|
||||
|
||||
late TabController _tabController;
|
||||
|
||||
// 功能类型由 AIFeatureTypeHelper.allFeatures 动态提供
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
final t = widget.template;
|
||||
_nameController = TextEditingController(text: t?.name ?? '');
|
||||
_descriptionController = TextEditingController(text: t?.description ?? '');
|
||||
_systemPromptController = TextEditingController(text: t?.systemPrompt ?? '');
|
||||
_userPromptController = TextEditingController(text: t?.userPrompt ?? '');
|
||||
_tagsController = TextEditingController(text: (t?.tags ?? const []).join(', '));
|
||||
_authorIdController = TextEditingController(text: t?.authorId ?? 'system');
|
||||
_userIdController = TextEditingController(text: t?.userId ?? 'system');
|
||||
_categoriesController = TextEditingController(text: (t?.categories ?? const []).join(', '));
|
||||
_featureType = t?.featureType.toApiString() ?? 'TEXT_EXPANSION';
|
||||
_language = t?.language ?? 'zh';
|
||||
_isVerified = t?.isVerified ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant EnhancedTemplateEditor oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.template?.id != widget.template?.id) {
|
||||
final t = widget.template;
|
||||
_nameController.text = t?.name ?? '';
|
||||
_descriptionController.text = t?.description ?? '';
|
||||
_systemPromptController.text = t?.systemPrompt ?? '';
|
||||
_userPromptController.text = t?.userPrompt ?? '';
|
||||
_tagsController.text = (t?.tags ?? const []).join(', ');
|
||||
setState(() {
|
||||
_featureType = t?.featureType.toApiString() ?? 'TEXT_EXPANSION';
|
||||
_language = t?.language ?? 'zh';
|
||||
_isVerified = t?.isVerified ?? false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
_nameController.dispose();
|
||||
_descriptionController.dispose();
|
||||
_systemPromptController.dispose();
|
||||
_userPromptController.dispose();
|
||||
_tagsController.dispose();
|
||||
_authorIdController.dispose();
|
||||
_userIdController.dispose();
|
||||
_categoriesController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isCreate = widget.template == null;
|
||||
return Column(
|
||||
children: [
|
||||
_buildTopBar(isCreate),
|
||||
_buildTabBar(),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildContentTab(),
|
||||
_buildPropertiesTab(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTopBar(bool isCreate) {
|
||||
return Container(
|
||||
height: 56,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getSurfaceColor(context),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (widget.onCancel != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
tooltip: '返回',
|
||||
onPressed: _isSaving ? null : widget.onCancel,
|
||||
),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _nameController,
|
||||
decoration: WebTheme.getBorderlessInputDecoration(
|
||||
hintText: isCreate ? '输入新模板名称…' : '编辑模板名称…',
|
||||
context: context,
|
||||
),
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _isSaving ? null : _save,
|
||||
icon: _isSaving
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Icon(Icons.save, size: 16),
|
||||
label: Text(_isSaving ? '保存中…' : '保存'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabBar() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getSurfaceColor(context),
|
||||
border: Border(
|
||||
bottom: BorderSide(color: WebTheme.getBorderColor(context), width: 1),
|
||||
),
|
||||
),
|
||||
child: TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: WebTheme.getPrimaryColor(context),
|
||||
unselectedLabelColor: WebTheme.getSecondaryTextColor(context),
|
||||
indicatorColor: WebTheme.getPrimaryColor(context),
|
||||
tabs: const [
|
||||
Tab(icon: Icon(Icons.notes_outlined, size: 18), text: '提示词内容'),
|
||||
Tab(icon: Icon(Icons.tune, size: 18), text: '基础信息'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContentTab() {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _systemPromptController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '系统提示词 *',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.multiline,
|
||||
minLines: 6,
|
||||
maxLines: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _userPromptController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '用户提示词 *',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.multiline,
|
||||
minLines: 6,
|
||||
maxLines: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPropertiesTab() {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _descriptionController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '模板描述',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _userIdController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '用户ID (userId)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _authorIdController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '作者ID (authorId)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
value: _featureType,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '功能类型 *',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: AIFeatureTypeHelper.allFeatures
|
||||
.map((t) => DropdownMenuItem<String>(
|
||||
value: t.toApiString(),
|
||||
child: Text(t.displayName),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (v) => setState(() => _featureType = v ?? _featureType),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
value: _language,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '语言',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'zh', child: Text('中文')),
|
||||
DropdownMenuItem(value: 'en', child: Text('English')),
|
||||
DropdownMenuItem(value: 'ja', child: Text('日本語')),
|
||||
DropdownMenuItem(value: 'ko', child: Text('한국어')),
|
||||
],
|
||||
onChanged: (v) => setState(() => _language = v ?? _language),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _tagsController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '标签(用逗号分隔)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _categoriesController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '分类(用逗号分隔)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
CheckboxListTile(
|
||||
title: const Text('设为官方认证模板'),
|
||||
value: _isVerified,
|
||||
onChanged: (v) => setState(() => _isVerified = v ?? false),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (_nameController.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('模板名称不能为空')));
|
||||
return;
|
||||
}
|
||||
if (_systemPromptController.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('系统提示词不能为空')));
|
||||
return;
|
||||
}
|
||||
if (_userPromptController.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('用户提示词不能为空')));
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSaving = true);
|
||||
try {
|
||||
final adminRepo = AdminRepositoryImpl();
|
||||
final List<String> tags = _tagsController.text
|
||||
.split(',')
|
||||
.map((e) => e.trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toList();
|
||||
final List<String> categories = _categoriesController.text
|
||||
.split(',')
|
||||
.map((e) => e.trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
EnhancedUserPromptTemplate saved;
|
||||
if (widget.template != null) {
|
||||
final updated = widget.template!.copyWith(
|
||||
name: _nameController.text.trim(),
|
||||
description: _descriptionController.text.trim().isEmpty
|
||||
? null
|
||||
: _descriptionController.text.trim(),
|
||||
systemPrompt: _systemPromptController.text.trim(),
|
||||
userPrompt: _userPromptController.text.trim(),
|
||||
tags: tags,
|
||||
categories: categories,
|
||||
language: _language,
|
||||
featureType: _getFeatureTypeFromString(_featureType),
|
||||
isVerified: _isVerified,
|
||||
userId: _userIdController.text.trim().isEmpty ? null : _userIdController.text.trim(),
|
||||
authorId: _authorIdController.text.trim().isEmpty ? null : _authorIdController.text.trim(),
|
||||
);
|
||||
saved = await adminRepo.updateEnhancedTemplate(widget.template!.id, updated);
|
||||
} else {
|
||||
final now = DateTime.now();
|
||||
final t = EnhancedUserPromptTemplate(
|
||||
id: '',
|
||||
userId: _userIdController.text.trim().isEmpty ? 'system' : _userIdController.text.trim(),
|
||||
name: _nameController.text.trim(),
|
||||
description: _descriptionController.text.trim().isEmpty
|
||||
? null
|
||||
: _descriptionController.text.trim(),
|
||||
featureType: _getFeatureTypeFromString(_featureType),
|
||||
systemPrompt: _systemPromptController.text.trim(),
|
||||
userPrompt: _userPromptController.text.trim(),
|
||||
tags: tags,
|
||||
categories: categories,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isPublic: true,
|
||||
isVerified: _isVerified,
|
||||
version: 1,
|
||||
language: _language,
|
||||
authorId: _authorIdController.text.trim().isEmpty ? null : _authorIdController.text.trim(),
|
||||
);
|
||||
saved = await adminRepo.createOfficialEnhancedTemplate(t);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('模板保存成功')));
|
||||
widget.onSaved?.call(saved);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('保存失败: $e')));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSaving = false);
|
||||
}
|
||||
}
|
||||
|
||||
AIFeatureType _getFeatureTypeFromString(String featureType) {
|
||||
switch (featureType) {
|
||||
case 'TEXT_EXPANSION':
|
||||
return AIFeatureType.textExpansion;
|
||||
case 'TEXT_REFACTOR':
|
||||
return AIFeatureType.textRefactor;
|
||||
case 'TEXT_SUMMARY':
|
||||
return AIFeatureType.textSummary;
|
||||
case 'AI_CHAT':
|
||||
return AIFeatureType.aiChat;
|
||||
case 'NOVEL_GENERATION':
|
||||
return AIFeatureType.novelGeneration;
|
||||
case 'PROFESSIONAL_FICTION_CONTINUATION':
|
||||
return AIFeatureType.professionalFictionContinuation;
|
||||
case 'SCENE_BEAT_GENERATION':
|
||||
return AIFeatureType.sceneBeatGeneration;
|
||||
case 'SCENE_TO_SUMMARY':
|
||||
return AIFeatureType.sceneToSummary;
|
||||
case 'SUMMARY_TO_SCENE':
|
||||
return AIFeatureType.summaryToScene;
|
||||
case 'NOVEL_COMPOSE':
|
||||
return AIFeatureType.novelCompose;
|
||||
case 'SETTING_TREE_GENERATION':
|
||||
return AIFeatureType.settingTreeGeneration;
|
||||
default:
|
||||
return AIFeatureType.textExpansion;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,829 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../models/public_model_config.dart';
|
||||
import '../../../models/prompt_models.dart';
|
||||
import '../../../config/provider_icons.dart';
|
||||
import '../../../utils/web_theme.dart';
|
||||
|
||||
/// 公共模型提供商分组卡片
|
||||
/// 显示提供商信息和其下的公共模型列表
|
||||
class PublicModelProviderGroupCard extends StatelessWidget {
|
||||
const PublicModelProviderGroupCard({
|
||||
super.key,
|
||||
required this.provider,
|
||||
required this.providerName,
|
||||
required this.description,
|
||||
required this.configs,
|
||||
required this.isExpanded,
|
||||
required this.onToggleExpanded,
|
||||
required this.onAddModel,
|
||||
required this.onValidate,
|
||||
required this.onEdit,
|
||||
required this.onDelete,
|
||||
required this.onToggleStatus,
|
||||
required this.onCopy,
|
||||
});
|
||||
|
||||
final String provider;
|
||||
final String providerName;
|
||||
final String description;
|
||||
final List<PublicModelConfigDetails> configs;
|
||||
final bool isExpanded;
|
||||
final VoidCallback onToggleExpanded;
|
||||
final VoidCallback onAddModel;
|
||||
final Function(String) onValidate;
|
||||
final Function(String) onEdit;
|
||||
final Function(String) onDelete;
|
||||
final Function(String, bool) onToggleStatus;
|
||||
final Function(String) onCopy;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = ProviderIcons.getProviderColor(provider);
|
||||
|
||||
// 统计状态
|
||||
final enabledCount = configs.where((c) => c.enabled == true).length;
|
||||
final validatedCount = configs.where((c) => c.isValidated == true).length;
|
||||
final totalCount = configs.length;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getCardColor(context),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: WebTheme.getShadowColor(context, opacity: 0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 提供商头部
|
||||
InkWell(
|
||||
onTap: onToggleExpanded,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
// 提供商图标
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: color.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: ProviderIcons.getProviderIconForContext(
|
||||
provider,
|
||||
iconSize: IconSize.medium,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// 提供商信息
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
providerName,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
description,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// 状态统计
|
||||
Row(
|
||||
children: [
|
||||
_buildStatusChip(
|
||||
context,
|
||||
'总计: $totalCount',
|
||||
Colors.blue,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildStatusChip(
|
||||
context,
|
||||
'启用: $enabledCount',
|
||||
Colors.green,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildStatusChip(
|
||||
context,
|
||||
'已验证: $validatedCount',
|
||||
Colors.orange,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 展开/折叠图标
|
||||
Icon(
|
||||
isExpanded ? Icons.expand_less : Icons.expand_more,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 模型列表
|
||||
if (isExpanded) ...[
|
||||
Divider(
|
||||
height: 1,
|
||||
color: WebTheme.getBorderColor(context),
|
||||
),
|
||||
if (configs.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.cloud_off,
|
||||
size: 48,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'该提供商暂无公共模型配置',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onAddModel,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('添加模型'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: color,
|
||||
side: BorderSide(color: color),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: configs.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final config = configs[index];
|
||||
return _buildModelConfigCard(context, config);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusChip(BuildContext context, String label, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: color.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildModelConfigCard(BuildContext context, PublicModelConfigDetails config) {
|
||||
final color = ProviderIcons.getProviderColor(provider);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getBackgroundColor(context),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: WebTheme.getShadowColor(context, opacity: 0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 模型头部
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.03),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题行
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
config.displayName ?? config.modelId,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 复制按钮
|
||||
IconButton(
|
||||
onPressed: () => onCopy(config.id!),
|
||||
icon: Icon(
|
||||
Icons.content_copy,
|
||||
color: color,
|
||||
size: 18,
|
||||
),
|
||||
tooltip: '复制配置',
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 32,
|
||||
minHeight: 32,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (config.displayName != null && config.displayName != config.modelId)
|
||||
Text(
|
||||
config.modelId,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 启用状态开关
|
||||
Switch(
|
||||
value: config.enabled ?? false,
|
||||
onChanged: (value) => onToggleStatus(config.id!, value),
|
||||
activeColor: color,
|
||||
inactiveThumbColor: WebTheme.getSecondaryTextColor(context),
|
||||
inactiveTrackColor: WebTheme.getSecondaryTextColor(context).withOpacity(0.3),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 描述信息
|
||||
if (config.description != null && config.description!.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
config.description!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
height: 1.3,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 状态标签行
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
_buildConfigStatusChip(
|
||||
context,
|
||||
config.isValidated == true ? '已验证' : '未验证',
|
||||
config.isValidated == true ? Colors.green : Colors.red,
|
||||
),
|
||||
if (config.apiKeyPoolStatus != null)
|
||||
_buildConfigStatusChip(
|
||||
context,
|
||||
'Keys: ${config.apiKeyPoolStatus}',
|
||||
Colors.blue,
|
||||
),
|
||||
if (config.priority != null && config.priority! > 0)
|
||||
_buildConfigStatusChip(
|
||||
context,
|
||||
'优先级: ${config.priority}',
|
||||
Colors.purple,
|
||||
),
|
||||
if (config.tags != null && config.tags!.isNotEmpty)
|
||||
...config.tags!.take(3).map((tag) => _buildConfigStatusChip(
|
||||
context,
|
||||
tag,
|
||||
Colors.orange,
|
||||
)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 详细信息
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 功能授权
|
||||
if (config.enabledForFeatures != null && config.enabledForFeatures!.isNotEmpty) ...[
|
||||
_buildDetailSection(
|
||||
context,
|
||||
'授权功能',
|
||||
Icons.verified_user,
|
||||
color,
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: config.enabledForFeatures!.map((feature) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: color.withOpacity(0.3)),
|
||||
),
|
||||
child: Text(
|
||||
_getFeatureDisplayName(feature),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
// 配置信息
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildInfoGrid(context, config, color),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 定价信息
|
||||
if (config.pricingInfo?.hasPricingData == true) ...[
|
||||
const SizedBox(height: 12),
|
||||
_buildPricingInfo(context, config.pricingInfo!, color),
|
||||
],
|
||||
|
||||
// 使用统计
|
||||
if (config.usageStatistics?.hasUsageData == true) ...[
|
||||
const SizedBox(height: 12),
|
||||
_buildUsageStatistics(context, config.usageStatistics!, color),
|
||||
],
|
||||
|
||||
// 时间信息
|
||||
if (config.createdAt != null || config.updatedAt != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
_buildTimeInfo(context, config),
|
||||
],
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 操作按钮
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => onValidate(config.id!),
|
||||
icon: const Icon(Icons.verified, size: 16),
|
||||
label: const Text('验证'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.green,
|
||||
side: const BorderSide(color: Colors.green),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => onEdit(config.id!),
|
||||
icon: const Icon(Icons.edit, size: 16),
|
||||
label: const Text('编辑'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: color,
|
||||
side: BorderSide(color: color),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => onDelete(config.id!),
|
||||
icon: const Icon(Icons.delete, size: 16),
|
||||
label: const Text('删除'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.red,
|
||||
side: const BorderSide(color: Colors.red),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailSection(BuildContext context, String title, IconData icon, Color color, Widget content) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 14, color: color),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
content,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoGrid(BuildContext context, PublicModelConfigDetails config, Color color) {
|
||||
final items = <Widget>[];
|
||||
|
||||
if (config.creditRateMultiplier != null) {
|
||||
items.add(_buildInfoItem(context, '积分倍数', '${config.creditRateMultiplier}x'));
|
||||
}
|
||||
|
||||
if (config.maxConcurrentRequests != null) {
|
||||
items.add(_buildInfoItem(context, '最大并发',
|
||||
config.maxConcurrentRequests! > 0 ? '${config.maxConcurrentRequests}' : '无限制'));
|
||||
}
|
||||
|
||||
if (config.dailyRequestLimit != null) {
|
||||
items.add(_buildInfoItem(context, '日限制',
|
||||
config.dailyRequestLimit! > 0 ? '${config.dailyRequestLimit}' : '无限制'));
|
||||
}
|
||||
|
||||
if (config.hourlyRequestLimit != null) {
|
||||
items.add(_buildInfoItem(context, '时限制',
|
||||
config.hourlyRequestLimit! > 0 ? '${config.hourlyRequestLimit}' : '无限制'));
|
||||
}
|
||||
|
||||
if (config.apiEndpoint != null && config.apiEndpoint!.isNotEmpty) {
|
||||
items.add(_buildInfoItem(context, 'Endpoint', config.apiEndpoint!, isUrl: true));
|
||||
}
|
||||
|
||||
if (items.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.settings, size: 14, color: color),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'配置信息',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 6,
|
||||
children: items,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoItem(BuildContext context, String label, String value, {bool isUrl = false}) {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(minWidth: 80),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
maxLines: isUrl ? 1 : null,
|
||||
overflow: isUrl ? TextOverflow.ellipsis : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPricingInfo(BuildContext context, PricingInfo pricing, Color color) {
|
||||
return _buildDetailSection(
|
||||
context,
|
||||
'定价信息',
|
||||
Icons.attach_money,
|
||||
color,
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.05),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: color.withOpacity(0.2)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (pricing.inputPricePerThousandTokens != null)
|
||||
Text(
|
||||
'输入: \$${pricing.inputPricePerThousandTokens!.toStringAsFixed(4)}/1K tokens',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
if (pricing.outputPricePerThousandTokens != null)
|
||||
Text(
|
||||
'输出: \$${pricing.outputPricePerThousandTokens!.toStringAsFixed(4)}/1K tokens',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
if (pricing.maxContextTokens != null)
|
||||
Text(
|
||||
'最大上下文: ${pricing.maxContextTokens!.toString().replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (match) => '${match[1]},')} tokens',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
if (pricing.supportsStreaming == true)
|
||||
Text(
|
||||
'支持流式输出',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.green,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUsageStatistics(BuildContext context, UsageStatistics usage, Color color) {
|
||||
return _buildDetailSection(
|
||||
context,
|
||||
'使用统计',
|
||||
Icons.bar_chart,
|
||||
color,
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.05),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: color.withOpacity(0.2)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (usage.totalRequests != null)
|
||||
Text(
|
||||
'总请求: ${usage.totalRequests}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
if (usage.totalCost != null)
|
||||
Text(
|
||||
'总成本: \$${usage.totalCost!.toStringAsFixed(4)}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
if (usage.last30DaysRequests != null)
|
||||
Text(
|
||||
'近30天请求: ${usage.last30DaysRequests}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
if (usage.averageCostPerRequest != null)
|
||||
Text(
|
||||
'平均每请求成本: \$${usage.averageCostPerRequest!.toStringAsFixed(6)}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTimeInfo(BuildContext context, PublicModelConfigDetails config) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getSecondaryTextColor(context).withOpacity(0.05),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: WebTheme.getBorderColor(context)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (config.createdAt != null) ...[
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'创建时间',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
formatDateTime(config.createdAt!),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
if (config.updatedAt != null) ...[
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'更新时间',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
formatDateTime(config.updatedAt!),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getFeatureDisplayName(String feature) {
|
||||
try {
|
||||
final type = AIFeatureTypeHelper.fromApiString(feature.toUpperCase());
|
||||
return type.displayName;
|
||||
} catch (_) {
|
||||
return feature;
|
||||
}
|
||||
}
|
||||
|
||||
String formatDateTime(DateTime dateTime) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(dateTime);
|
||||
|
||||
if (difference.inDays > 0) {
|
||||
return '${difference.inDays}天前';
|
||||
} else if (difference.inHours > 0) {
|
||||
return '${difference.inHours}小时前';
|
||||
} else if (difference.inMinutes > 0) {
|
||||
return '${difference.inMinutes}分钟前';
|
||||
} else {
|
||||
return '刚刚';
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildConfigStatusChip(BuildContext context, String label, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: color.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
482
AINoval/lib/screens/admin/widgets/public_template_card.dart
Normal file
482
AINoval/lib/screens/admin/widgets/public_template_card.dart
Normal file
@@ -0,0 +1,482 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../models/prompt_models.dart';
|
||||
import '../../../utils/web_theme.dart';
|
||||
|
||||
/// 公共模板卡片组件
|
||||
class PublicTemplateCard extends StatelessWidget {
|
||||
final PromptTemplate template;
|
||||
final bool isSelected;
|
||||
final bool batchMode;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onEdit;
|
||||
final VoidCallback? onDuplicate;
|
||||
final VoidCallback? onReview;
|
||||
final VoidCallback? onPublish;
|
||||
final VoidCallback? onSetVerified;
|
||||
final VoidCallback? onDelete;
|
||||
final ValueChanged<bool>? onSelectionChanged;
|
||||
|
||||
const PublicTemplateCard({
|
||||
Key? key,
|
||||
required this.template,
|
||||
this.isSelected = false,
|
||||
this.batchMode = false,
|
||||
this.onTap,
|
||||
this.onEdit,
|
||||
this.onDuplicate,
|
||||
this.onReview,
|
||||
this.onPublish,
|
||||
this.onSetVerified,
|
||||
this.onDelete,
|
||||
this.onSelectionChanged,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
color: isSelected
|
||||
? WebTheme.getPrimaryColor(context).withOpacity(0.1)
|
||||
: WebTheme.getCardColor(context),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHeader(context),
|
||||
const SizedBox(height: 12),
|
||||
_buildContent(context),
|
||||
const SizedBox(height: 12),
|
||||
_buildFooter(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
if (batchMode) ...[
|
||||
Checkbox(
|
||||
value: isSelected,
|
||||
onChanged: (value) => onSelectionChanged?.call(value ?? false),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
template.name,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildStatusChips(context),
|
||||
],
|
||||
),
|
||||
if (template.description?.isNotEmpty == true) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
template.description!,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: WebTheme.getTextColor(context).withOpacity(0.7),
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (!batchMode) ...[
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (value) => _handleMenuAction(value),
|
||||
itemBuilder: (context) => _buildMenuItems(),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusChips(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (template.isVerified == true)
|
||||
_buildStatusChip(context, '认证', Colors.orange),
|
||||
if (template.isPublic == true)
|
||||
_buildStatusChip(context, '已发布', Colors.green),
|
||||
if (template.isPublic != true && template.isVerified != true)
|
||||
_buildStatusChip(context, '待审核', Colors.grey),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusChip(BuildContext context, String label, Color color) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(left: 6),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: color.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
if (template.templateTags?.isNotEmpty == true) ...[
|
||||
Expanded(
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: template.templateTags!.take(5).map((tag) =>
|
||||
_buildTag(context, tag)).toList(),
|
||||
),
|
||||
),
|
||||
] else
|
||||
const Expanded(child: SizedBox()),
|
||||
|
||||
if (template.aiFeatureType != null) ...[
|
||||
const SizedBox(width: 12),
|
||||
_buildFeatureTypeChip(context),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFeatureTypeChip(BuildContext context) {
|
||||
final featureType = _featureTypeToString(template.aiFeatureType!);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: _getFeatureTypeColor(featureType).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: _getFeatureTypeColor(featureType).withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_getFeatureTypeLabel(featureType),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _getFeatureTypeColor(featureType),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTag(BuildContext context, String tag) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getTextColor(context).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
tag,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: WebTheme.getTextColor(context).withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFooter(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
// 创建信息
|
||||
if (template.authorName != null) ...[
|
||||
Icon(
|
||||
Icons.person,
|
||||
size: 14,
|
||||
color: WebTheme.getTextColor(context).withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
template.authorName!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getTextColor(context).withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
|
||||
// 创建时间
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 14,
|
||||
color: WebTheme.getTextColor(context).withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_formatDateTime(template.createdAt),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getTextColor(context).withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// 使用次数
|
||||
Icon(
|
||||
Icons.play_circle_outline,
|
||||
size: 14,
|
||||
color: WebTheme.getTextColor(context).withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'使用 ${template.useCount ?? 0} 次',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getTextColor(context).withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// 评分信息
|
||||
if (template.averageRating != null && template.averageRating! > 0) ...[
|
||||
Icon(
|
||||
Icons.star,
|
||||
size: 14,
|
||||
color: Colors.amber,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${template.averageRating!.toStringAsFixed(1)} (${template.ratingCount ?? 0})',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getTextColor(context).withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
] else
|
||||
Text(
|
||||
'暂无评分',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getTextColor(context).withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<PopupMenuEntry<String>> _buildMenuItems() {
|
||||
List<PopupMenuEntry<String>> items = [];
|
||||
|
||||
// 复制选项
|
||||
items.add(const PopupMenuItem(
|
||||
value: 'duplicate',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.copy, size: 18),
|
||||
SizedBox(width: 8),
|
||||
Text('复制为新模板'),
|
||||
],
|
||||
),
|
||||
));
|
||||
|
||||
// 根据状态显示不同操作
|
||||
if (template.isPublic != true) {
|
||||
items.add(const PopupMenuItem(
|
||||
value: 'review',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.rate_review, size: 18),
|
||||
SizedBox(width: 8),
|
||||
Text('审核'),
|
||||
],
|
||||
),
|
||||
));
|
||||
|
||||
items.add(const PopupMenuItem(
|
||||
value: 'publish',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.publish, size: 18),
|
||||
SizedBox(width: 8),
|
||||
Text('发布'),
|
||||
],
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
if (template.isVerified != true) {
|
||||
items.add(PopupMenuItem(
|
||||
value: 'verify',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.verified, size: 18, color: Colors.orange),
|
||||
SizedBox(width: 8),
|
||||
Text('设为认证', style: TextStyle(color: Colors.orange)),
|
||||
],
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
items.add(const PopupMenuDivider());
|
||||
|
||||
// 删除选项
|
||||
items.add(const PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.delete, size: 18, color: Colors.red),
|
||||
SizedBox(width: 8),
|
||||
Text('删除', style: TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
Color _getFeatureTypeColor(String featureType) {
|
||||
switch (featureType) {
|
||||
case 'AI_CHAT':
|
||||
return Colors.blue;
|
||||
case 'SCENE_TO_SUMMARY':
|
||||
case 'SUMMARY_TO_SCENE':
|
||||
return Colors.green;
|
||||
case 'PROFESSIONAL_FICTION_CONTINUATION':
|
||||
case 'NOVEL_GENERATION':
|
||||
case 'NOVEL_COMPOSE':
|
||||
return Colors.orange;
|
||||
case 'TEXT_SUMMARY':
|
||||
return Colors.purple;
|
||||
case 'TEXT_EXPANSION':
|
||||
case 'TEXT_REFACTOR':
|
||||
return Colors.teal;
|
||||
case 'SCENE_BEAT_GENERATION':
|
||||
return Colors.indigo;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
String _getFeatureTypeLabel(String featureType) {
|
||||
switch (featureType) {
|
||||
case 'AI_CHAT':
|
||||
return 'AI聊天';
|
||||
case 'SCENE_TO_SUMMARY':
|
||||
return '场景摘要';
|
||||
case 'SUMMARY_TO_SCENE':
|
||||
return '摘要场景';
|
||||
case 'TEXT_EXPANSION':
|
||||
return '文本扩写';
|
||||
case 'TEXT_REFACTOR':
|
||||
return '文本重构';
|
||||
case 'TEXT_SUMMARY':
|
||||
return '文本总结';
|
||||
case 'NOVEL_GENERATION':
|
||||
return '小说生成';
|
||||
case 'NOVEL_COMPOSE':
|
||||
return '设定编排';
|
||||
case 'PROFESSIONAL_FICTION_CONTINUATION':
|
||||
return '专业续写';
|
||||
case 'SCENE_BEAT_GENERATION':
|
||||
return '场景节拍';
|
||||
default:
|
||||
return featureType;
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDateTime(DateTime? dateTime) {
|
||||
if (dateTime == null) return '';
|
||||
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(dateTime);
|
||||
|
||||
if (difference.inDays > 0) {
|
||||
return '${difference.inDays}天前';
|
||||
} else if (difference.inHours > 0) {
|
||||
return '${difference.inHours}小时前';
|
||||
} else if (difference.inMinutes > 0) {
|
||||
return '${difference.inMinutes}分钟前';
|
||||
} else {
|
||||
return '刚刚';
|
||||
}
|
||||
}
|
||||
|
||||
void _handleMenuAction(String action) {
|
||||
switch (action) {
|
||||
case 'duplicate':
|
||||
onDuplicate?.call();
|
||||
break;
|
||||
case 'review':
|
||||
onReview?.call();
|
||||
break;
|
||||
case 'publish':
|
||||
onPublish?.call();
|
||||
break;
|
||||
case 'verify':
|
||||
onSetVerified?.call();
|
||||
break;
|
||||
case 'delete':
|
||||
onDelete?.call();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
String _featureTypeToString(AIFeatureType featureType) {
|
||||
switch (featureType) {
|
||||
case AIFeatureType.sceneToSummary:
|
||||
return 'SCENE_TO_SUMMARY';
|
||||
case AIFeatureType.summaryToScene:
|
||||
return 'SUMMARY_TO_SCENE';
|
||||
case AIFeatureType.textExpansion:
|
||||
return 'TEXT_EXPANSION';
|
||||
case AIFeatureType.textRefactor:
|
||||
return 'TEXT_REFACTOR';
|
||||
case AIFeatureType.textSummary:
|
||||
return 'TEXT_SUMMARY';
|
||||
case AIFeatureType.aiChat:
|
||||
return 'AI_CHAT';
|
||||
case AIFeatureType.novelGeneration:
|
||||
return 'NOVEL_GENERATION';
|
||||
case AIFeatureType.novelCompose:
|
||||
return 'NOVEL_COMPOSE';
|
||||
case AIFeatureType.professionalFictionContinuation:
|
||||
return 'PROFESSIONAL_FICTION_CONTINUATION';
|
||||
case AIFeatureType.sceneBeatGeneration:
|
||||
return 'SCENE_BEAT_GENERATION';
|
||||
case AIFeatureType.settingTreeGeneration:
|
||||
return 'SETTING_TREE_GENERATION';
|
||||
}
|
||||
}
|
||||
}
|
||||
482
AINoval/lib/screens/admin/widgets/role_management_table.dart
Normal file
482
AINoval/lib/screens/admin/widgets/role_management_table.dart
Normal file
@@ -0,0 +1,482 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../models/admin/admin_models.dart';
|
||||
import '../../../blocs/admin/admin_bloc.dart';
|
||||
import '../../../utils/web_theme.dart';
|
||||
|
||||
class RoleManagementTable extends StatelessWidget {
|
||||
final List<AdminRole> roles;
|
||||
|
||||
const RoleManagementTable({
|
||||
super.key,
|
||||
required this.roles,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
color: WebTheme.getCardColor(context),
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题栏
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getTextColor(context).withOpacity(0.05),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(12),
|
||||
topRight: Radius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'角色管理',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.refresh, color: WebTheme.getTextColor(context)),
|
||||
onPressed: () => context.read<AdminBloc>().add(LoadRoles()),
|
||||
tooltip: '刷新角色列表',
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'总计: ${roles.length} 个角色',
|
||||
style: TextStyle(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 数据表格
|
||||
if (roles.isNotEmpty)
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: DataTable(
|
||||
columnSpacing: 16,
|
||||
dataRowMinHeight: 48,
|
||||
dataRowMaxHeight: 80,
|
||||
headingRowHeight: 56,
|
||||
headingRowColor: MaterialStateColor.resolveWith(
|
||||
(states) => WebTheme.getCardColor(context),
|
||||
),
|
||||
columns: [
|
||||
DataColumn(
|
||||
label: Text(
|
||||
'角色名称',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
DataColumn(
|
||||
label: Text(
|
||||
'显示名称',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
DataColumn(
|
||||
label: Text(
|
||||
'描述',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
DataColumn(
|
||||
label: Text(
|
||||
'权限',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
DataColumn(
|
||||
label: Text(
|
||||
'状态',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
DataColumn(
|
||||
label: Text(
|
||||
'优先级',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
DataColumn(
|
||||
label: Text(
|
||||
'操作',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
rows: roles.map((role) => DataRow(
|
||||
cells: [
|
||||
DataCell(
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: _getRoleTypeColor(role).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_getRoleTypeIcon(role),
|
||||
size: 16,
|
||||
color: _getRoleTypeColor(role),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
role.roleName,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
Text(
|
||||
role.displayName,
|
||||
style: TextStyle(color: WebTheme.getTextColor(context)),
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
SizedBox(
|
||||
width: 200,
|
||||
child: Text(
|
||||
role.description ?? '无描述',
|
||||
style: TextStyle(color: WebTheme.getSecondaryTextColor(context)),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
SizedBox(
|
||||
width: 150,
|
||||
child: Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 2,
|
||||
children: role.permissions.take(3).map((permission) =>
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getTextColor(context).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
permission,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
DataCell(_buildStatusChip(context, role.enabled)),
|
||||
DataCell(
|
||||
Text(
|
||||
'优先级: ${role.priority}',
|
||||
style: TextStyle(color: WebTheme.getSecondaryTextColor(context)),
|
||||
),
|
||||
),
|
||||
DataCell(_buildActionButtons(context, role)),
|
||||
],
|
||||
)).toList(),
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.security_outlined,
|
||||
size: 64,
|
||||
color: WebTheme.getSecondaryTextColor(context).withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'暂无角色数据',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _showCreateRoleDialog(context),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('创建第一个角色'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: WebTheme.getTextColor(context),
|
||||
foregroundColor: WebTheme.getBackgroundColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusChip(BuildContext context, bool active) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? Colors.green.withOpacity(0.1) : Colors.red.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
active ? Icons.check_circle : Icons.cancel,
|
||||
size: 14,
|
||||
color: active ? Colors.green.shade700 : Colors.red.shade700,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
active ? '活跃' : '禁用',
|
||||
style: TextStyle(
|
||||
color: active ? Colors.green.shade700 : Colors.red.shade700,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getRoleTypeColor(AdminRole role) {
|
||||
final roleName = role.roleName;
|
||||
if (roleName.startsWith('SYSTEM_')) {
|
||||
return Colors.orange;
|
||||
} else if (roleName.startsWith('ADMIN')) {
|
||||
return Colors.red;
|
||||
} else if (roleName.startsWith('USER')) {
|
||||
return Colors.blue;
|
||||
} else {
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getRoleTypeIcon(AdminRole role) {
|
||||
final roleName = role.roleName;
|
||||
if (roleName.startsWith('SYSTEM_')) {
|
||||
return Icons.admin_panel_settings;
|
||||
} else if (roleName.startsWith('ADMIN')) {
|
||||
return Icons.manage_accounts;
|
||||
} else if (roleName.startsWith('USER')) {
|
||||
return Icons.person;
|
||||
} else {
|
||||
return Icons.group;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildActionButtons(BuildContext context, AdminRole role) {
|
||||
final isSystemRole = role.roleName.startsWith('SYSTEM_');
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 编辑角色
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.edit,
|
||||
size: 18,
|
||||
color: isSystemRole
|
||||
? WebTheme.getSecondaryTextColor(context).withOpacity(0.5)
|
||||
: WebTheme.getTextColor(context),
|
||||
),
|
||||
onPressed: isSystemRole ? null : () => _showEditRoleDialog(context, role),
|
||||
tooltip: isSystemRole ? '系统角色不可编辑' : '编辑角色',
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
// 查看权限
|
||||
IconButton(
|
||||
icon: Icon(Icons.visibility, size: 18, color: WebTheme.getTextColor(context)),
|
||||
onPressed: () => _showPermissionsDialog(context, role),
|
||||
tooltip: '查看权限',
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
// 删除
|
||||
if (!isSystemRole)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, size: 18),
|
||||
onPressed: () => _showDeleteConfirmDialog(context, role),
|
||||
tooltip: '删除角色',
|
||||
visualDensity: VisualDensity.compact,
|
||||
color: Colors.red.shade700,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _showCreateRoleDialog(BuildContext context) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('创建角色功能开发中...')),
|
||||
);
|
||||
}
|
||||
|
||||
void _showEditRoleDialog(BuildContext context, AdminRole role) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('编辑角色功能开发中...')),
|
||||
);
|
||||
}
|
||||
|
||||
void _showPermissionsDialog(BuildContext context, AdminRole role) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: WebTheme.getCardColor(context),
|
||||
title: Text(
|
||||
'${role.displayName} - 权限详情',
|
||||
style: TextStyle(color: WebTheme.getTextColor(context)),
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 400,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'权限列表:',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (role.permissions.isNotEmpty)
|
||||
...role.permissions.map((permission) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
size: 16,
|
||||
color: Colors.green,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
permission,
|
||||
style: TextStyle(color: WebTheme.getTextColor(context)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
))
|
||||
else
|
||||
Text(
|
||||
'无权限配置',
|
||||
style: TextStyle(color: WebTheme.getSecondaryTextColor(context)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(
|
||||
'关闭',
|
||||
style: TextStyle(color: WebTheme.getTextColor(context)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDeleteConfirmDialog(BuildContext context, AdminRole role) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: WebTheme.getCardColor(context),
|
||||
title: Text(
|
||||
'确认删除',
|
||||
style: TextStyle(color: WebTheme.getTextColor(context)),
|
||||
),
|
||||
content: Text(
|
||||
'确定要删除角色 "${role.displayName}" 吗?此操作不可撤销。',
|
||||
style: TextStyle(color: WebTheme.getTextColor(context)),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(
|
||||
'取消',
|
||||
style: TextStyle(color: WebTheme.getSecondaryTextColor(context)),
|
||||
),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('删除'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && context.mounted) {
|
||||
// TODO: 实现删除角色API调用
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('删除角色功能开发中...'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
70
AINoval/lib/screens/admin/widgets/stats_card.dart
Normal file
70
AINoval/lib/screens/admin/widgets/stats_card.dart
Normal file
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class StatsCard extends StatelessWidget {
|
||||
final String title;
|
||||
final String value;
|
||||
final IconData icon;
|
||||
final Color? color;
|
||||
|
||||
const StatsCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.icon,
|
||||
this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cardColor = color ?? Theme.of(context).colorScheme.primary;
|
||||
|
||||
return Card(
|
||||
elevation: 4,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 32,
|
||||
color: cardColor,
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: cardColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 24,
|
||||
color: cardColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: cardColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
386
AINoval/lib/screens/admin/widgets/subscription_plan_table.dart
Normal file
386
AINoval/lib/screens/admin/widgets/subscription_plan_table.dart
Normal file
@@ -0,0 +1,386 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../models/admin/subscription_models.dart';
|
||||
import '../../../blocs/subscription/subscription_bloc.dart';
|
||||
|
||||
class SubscriptionPlanTable extends StatelessWidget {
|
||||
final List<SubscriptionPlan> plans;
|
||||
|
||||
const SubscriptionPlanTable({
|
||||
super.key,
|
||||
required this.plans,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题栏
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(8),
|
||||
topRight: Radius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'订阅计划管理',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('创建订阅计划功能开发中...')),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('创建订阅计划'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () => context.read<SubscriptionBloc>().add(LoadSubscriptionPlans()),
|
||||
tooltip: '刷新订阅计划列表',
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'总计: ${plans.length} 个计划',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 数据表格或空状态
|
||||
if (plans.isNotEmpty)
|
||||
_buildPlansTable(context)
|
||||
else
|
||||
_buildEmptyState(context),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlansTable(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: DataTable(
|
||||
columnSpacing: 16,
|
||||
dataRowMinHeight: 48,
|
||||
dataRowMaxHeight: 80,
|
||||
headingRowHeight: 56,
|
||||
columns: const [
|
||||
DataColumn(
|
||||
label: Text(
|
||||
'计划名称',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
DataColumn(
|
||||
label: Text(
|
||||
'价格',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
DataColumn(
|
||||
label: Text(
|
||||
'计费周期',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
DataColumn(
|
||||
label: Text(
|
||||
'积分',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
numeric: true,
|
||||
),
|
||||
DataColumn(
|
||||
label: Text(
|
||||
'状态',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
DataColumn(
|
||||
label: Text(
|
||||
'推荐',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
DataColumn(
|
||||
label: Text(
|
||||
'优先级',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
numeric: true,
|
||||
),
|
||||
DataColumn(
|
||||
label: Text(
|
||||
'操作',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
rows: plans.map((plan) => DataRow(
|
||||
cells: [
|
||||
DataCell(
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
plan.planName,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
if (plan.description != null && plan.description!.isNotEmpty)
|
||||
Text(
|
||||
plan.description!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.7),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
plan.formattedPrice,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: _getBillingCycleColor(plan.billingCycle).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
plan.billingCycleText,
|
||||
style: TextStyle(
|
||||
color: _getBillingCycleColor(plan.billingCycle),
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
Text(
|
||||
plan.creditsGranted?.toString() ?? '-',
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
DataCell(_buildStatusChip(context, plan.active)),
|
||||
DataCell(
|
||||
plan.recommended
|
||||
? const Icon(Icons.star, color: Colors.amber, size: 18)
|
||||
: const Icon(Icons.star_border, color: Colors.grey, size: 18),
|
||||
),
|
||||
DataCell(
|
||||
Text(
|
||||
plan.priority.toString(),
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
DataCell(_buildActionButtons(context, plan)),
|
||||
],
|
||||
)).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.subscriptions_outlined,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'暂无订阅计划',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('创建订阅计划功能开发中...')),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('创建第一个订阅计划'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusChip(BuildContext context, bool active) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? Colors.green.withOpacity(0.1) : Colors.red.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
active ? Icons.check_circle : Icons.cancel,
|
||||
size: 14,
|
||||
color: active ? Colors.green.shade700 : Colors.red.shade700,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
active ? '活跃' : '禁用',
|
||||
style: TextStyle(
|
||||
color: active ? Colors.green.shade700 : Colors.red.shade700,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getBillingCycleColor(BillingCycle cycle) {
|
||||
switch (cycle) {
|
||||
case BillingCycle.monthly:
|
||||
return Colors.blue;
|
||||
case BillingCycle.quarterly:
|
||||
return Colors.orange;
|
||||
case BillingCycle.yearly:
|
||||
return Colors.green;
|
||||
case BillingCycle.lifetime:
|
||||
return Colors.purple;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildActionButtons(BuildContext context, SubscriptionPlan plan) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 编辑计划
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, size: 18),
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('编辑订阅计划功能开发中...')),
|
||||
);
|
||||
},
|
||||
tooltip: '编辑计划',
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
// 启用/禁用
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
plan.active ? Icons.pause : Icons.play_arrow,
|
||||
size: 18,
|
||||
),
|
||||
onPressed: () => _togglePlanStatus(context, plan),
|
||||
tooltip: plan.active ? '禁用计划' : '启用计划',
|
||||
visualDensity: VisualDensity.compact,
|
||||
color: plan.active ? Colors.orange.shade700 : Colors.green.shade700,
|
||||
),
|
||||
// 删除
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, size: 18),
|
||||
onPressed: () => _deletePlan(context, plan),
|
||||
tooltip: '删除计划',
|
||||
visualDensity: VisualDensity.compact,
|
||||
color: Colors.red.shade700,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _togglePlanStatus(BuildContext context, SubscriptionPlan plan) {
|
||||
if (plan.id != null) {
|
||||
context.read<SubscriptionBloc>().add(ToggleSubscriptionPlanStatus(
|
||||
planId: plan.id!,
|
||||
active: !plan.active,
|
||||
));
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('${plan.active ? "禁用" : "启用"}计划操作已提交'),
|
||||
backgroundColor: Colors.blue,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _deletePlan(BuildContext context, SubscriptionPlan plan) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('确认删除'),
|
||||
content: Text('确定要删除订阅计划 "${plan.planName}" 吗?此操作不可撤销。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('删除'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && context.mounted && plan.id != null) {
|
||||
context.read<SubscriptionBloc>().add(DeleteSubscriptionPlan(plan.id!));
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('删除订阅计划操作已提交'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
384
AINoval/lib/screens/admin/widgets/system_preset_card.dart
Normal file
384
AINoval/lib/screens/admin/widgets/system_preset_card.dart
Normal file
@@ -0,0 +1,384 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../models/preset_models.dart';
|
||||
import '../../../utils/web_theme.dart';
|
||||
|
||||
/// 系统预设卡片组件
|
||||
/// 显示系统预设的基本信息和操作按钮
|
||||
class SystemPresetCard extends StatelessWidget {
|
||||
final AIPromptPreset preset;
|
||||
final bool isSelected;
|
||||
final bool batchMode;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onEdit;
|
||||
final VoidCallback? onDelete;
|
||||
final VoidCallback? onToggleVisibility;
|
||||
final VoidCallback? onViewStats;
|
||||
final VoidCallback? onViewDetails;
|
||||
final ValueChanged<bool>? onSelectionChanged;
|
||||
|
||||
const SystemPresetCard({
|
||||
Key? key,
|
||||
required this.preset,
|
||||
this.isSelected = false,
|
||||
this.batchMode = false,
|
||||
this.onTap,
|
||||
this.onEdit,
|
||||
this.onDelete,
|
||||
this.onToggleVisibility,
|
||||
this.onViewStats,
|
||||
this.onViewDetails,
|
||||
this.onSelectionChanged,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary.withOpacity(0.1)
|
||||
: WebTheme.getCardColor(context),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHeader(context),
|
||||
const SizedBox(height: 12),
|
||||
_buildContent(context),
|
||||
const SizedBox(height: 12),
|
||||
_buildFooter(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
if (batchMode) ...[
|
||||
Checkbox(
|
||||
value: isSelected,
|
||||
onChanged: (value) => onSelectionChanged?.call(value ?? false),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
preset.presetName ?? '未命名预设',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
if (preset.presetDescription?.isNotEmpty == true) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
preset.presetDescription!,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: WebTheme.getTextColor(context).withOpacity(0.7),
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (!batchMode) ...[
|
||||
_buildQuickAccessIndicator(context),
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (value) => _handleMenuAction(value),
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.edit, size: 18),
|
||||
SizedBox(width: 8),
|
||||
Text('编辑'),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'visibility',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
preset.showInQuickAccess ? Icons.visibility_off : Icons.visibility,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(preset.showInQuickAccess ? '隐藏快捷访问' : '显示在快捷访问'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'details',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.article, size: 18),
|
||||
SizedBox(width: 8),
|
||||
Text('查看内容'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'stats',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.analytics, size: 18),
|
||||
SizedBox(width: 8),
|
||||
Text('查看统计'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
const PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.delete, size: 18, color: Colors.red),
|
||||
SizedBox(width: 8),
|
||||
Text('删除', style: TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuickAccessIndicator(BuildContext context) {
|
||||
if (!preset.showInQuickAccess) return const SizedBox();
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.flash_on,
|
||||
size: 14,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'快捷',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
_buildFeatureTypeChip(context),
|
||||
const SizedBox(width: 12),
|
||||
if (preset.presetTags?.isNotEmpty == true) ...[
|
||||
Expanded(
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: preset.presetTags!.take(3).map((tag) => _buildTag(context, tag)).toList(),
|
||||
),
|
||||
),
|
||||
] else
|
||||
const Expanded(child: SizedBox()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFeatureTypeChip(BuildContext context) {
|
||||
final featureType = preset.aiFeatureType;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: _getFeatureTypeColor(featureType).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: _getFeatureTypeColor(featureType).withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_getFeatureTypeLabel(featureType),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _getFeatureTypeColor(featureType),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTag(BuildContext context, String tag) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getTextColor(context).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
tag,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: WebTheme.getTextColor(context).withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFooter(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 14,
|
||||
color: WebTheme.getTextColor(context).withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_formatDateTime(preset.createdAt),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getTextColor(context).withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 16),
|
||||
Icon(
|
||||
Icons.play_circle_outline,
|
||||
size: 14,
|
||||
color: WebTheme.getTextColor(context).withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'使用 ${preset.useCount} 次',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getTextColor(context).withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
if (preset.lastUsedAt != null) ...[
|
||||
Text(
|
||||
'最后使用: ${_formatDateTime(preset.lastUsedAt)}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getTextColor(context).withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
] else
|
||||
Text(
|
||||
'从未使用',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getTextColor(context).withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Color _getFeatureTypeColor(String featureType) {
|
||||
switch (featureType) {
|
||||
case 'CHAT':
|
||||
return Colors.blue;
|
||||
case 'SCENE_GENERATION':
|
||||
return Colors.green;
|
||||
case 'CONTINUATION':
|
||||
return Colors.orange;
|
||||
case 'SUMMARY':
|
||||
return Colors.purple;
|
||||
case 'OUTLINE':
|
||||
return Colors.teal;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
String _getFeatureTypeLabel(String featureType) {
|
||||
switch (featureType) {
|
||||
case 'CHAT':
|
||||
return 'AI聊天';
|
||||
case 'SCENE_GENERATION':
|
||||
return '场景生成';
|
||||
case 'CONTINUATION':
|
||||
return '续写';
|
||||
case 'SUMMARY':
|
||||
return '总结';
|
||||
case 'OUTLINE':
|
||||
return '大纲';
|
||||
default:
|
||||
return featureType;
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDateTime(DateTime? dateTime) {
|
||||
if (dateTime == null) return '';
|
||||
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(dateTime);
|
||||
|
||||
if (difference.inDays > 0) {
|
||||
return '${difference.inDays}天前';
|
||||
} else if (difference.inHours > 0) {
|
||||
return '${difference.inHours}小时前';
|
||||
} else if (difference.inMinutes > 0) {
|
||||
return '${difference.inMinutes}分钟前';
|
||||
} else {
|
||||
return '刚刚';
|
||||
}
|
||||
}
|
||||
|
||||
void _handleMenuAction(String action) {
|
||||
switch (action) {
|
||||
case 'edit':
|
||||
onEdit?.call();
|
||||
break;
|
||||
case 'visibility':
|
||||
onToggleVisibility?.call();
|
||||
break;
|
||||
case 'details':
|
||||
onViewDetails?.call();
|
||||
break;
|
||||
case 'stats':
|
||||
onViewStats?.call();
|
||||
break;
|
||||
case 'delete':
|
||||
onDelete?.call();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
702
AINoval/lib/screens/admin/widgets/template_details_dialog.dart
Normal file
702
AINoval/lib/screens/admin/widgets/template_details_dialog.dart
Normal file
@@ -0,0 +1,702 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../../models/prompt_models.dart';
|
||||
import '../../../utils/web_theme.dart';
|
||||
import '../../../widgets/common/dialog_container.dart';
|
||||
import '../../../widgets/common/dialog_header.dart';
|
||||
|
||||
/// 模板详情查看对话框
|
||||
class TemplateDetailsDialog extends StatefulWidget {
|
||||
final EnhancedUserPromptTemplate template;
|
||||
final Map<String, Object>? statistics;
|
||||
|
||||
const TemplateDetailsDialog({
|
||||
Key? key,
|
||||
required this.template,
|
||||
this.statistics,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<TemplateDetailsDialog> createState() => _TemplateDetailsDialogState();
|
||||
}
|
||||
|
||||
class _TemplateDetailsDialogState extends State<TemplateDetailsDialog> with TickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 3, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DialogContainer(
|
||||
maxWidth: 900,
|
||||
height: 700,
|
||||
child: Column(
|
||||
children: [
|
||||
DialogHeader(
|
||||
title: '模板详情 - ${widget.template.name}',
|
||||
onClose: () => Navigator.of(context).pop(),
|
||||
),
|
||||
_buildTabs(),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildBasicInfoTab(),
|
||||
_buildContentTab(),
|
||||
_buildStatisticsTab(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabs() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: WebTheme.getTextColor(context),
|
||||
unselectedLabelColor: WebTheme.getTextColor(context).withOpacity(0.6),
|
||||
indicatorColor: WebTheme.getPrimaryColor(context),
|
||||
tabs: const [
|
||||
Tab(
|
||||
icon: Icon(Icons.info),
|
||||
text: '基础信息',
|
||||
),
|
||||
Tab(
|
||||
icon: Icon(Icons.code),
|
||||
text: '提示词内容',
|
||||
),
|
||||
Tab(
|
||||
icon: Icon(Icons.analytics),
|
||||
text: '统计信息',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBasicInfoTab() {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildInfoSection(),
|
||||
const SizedBox(height: 24),
|
||||
_buildStatusSection(),
|
||||
const SizedBox(height: 24),
|
||||
_buildTagsSection(),
|
||||
const SizedBox(height: 24),
|
||||
_buildMetadataSection(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoSection() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.info, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'基本信息',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildInfoRow('模板名称', widget.template.name),
|
||||
_buildInfoRow('模板ID', widget.template.id),
|
||||
_buildInfoRow('功能类型', _getFeatureTypeLabel(widget.template.featureType.toApiString())),
|
||||
_buildInfoRow('语言', _getLanguageLabel(widget.template.language)),
|
||||
_buildInfoRow('版本', (widget.template.version ?? 1).toString()),
|
||||
if (widget.template.description?.isNotEmpty == true)
|
||||
_buildInfoRow('描述', widget.template.description!, maxLines: 3),
|
||||
_buildInfoRow('作者ID', widget.template.authorId ?? '未知'),
|
||||
_buildInfoRow('创建时间', _formatDateTime(widget.template.createdAt)),
|
||||
_buildInfoRow('更新时间', _formatDateTime(widget.template.updatedAt)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusSection() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.flag, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'状态信息',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
_buildStatusChip(
|
||||
label: '公开状态',
|
||||
value: widget.template.isPublic == true ? '已发布' : '私有',
|
||||
color: widget.template.isPublic == true ? Colors.green : Colors.grey,
|
||||
),
|
||||
_buildStatusChip(
|
||||
label: '认证状态',
|
||||
value: widget.template.isVerified == true ? '已认证' : '未认证',
|
||||
color: widget.template.isVerified == true ? Colors.blue : Colors.grey,
|
||||
),
|
||||
_buildStatusChip(
|
||||
label: '评分',
|
||||
value: widget.template.rating > 0 ? widget.template.rating.toStringAsFixed(1) : '无评分',
|
||||
color: _getRatingColor(widget.template.rating),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTagsSection() {
|
||||
if (widget.template.tags.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.label, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'标签',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: widget.template.tags.map((tag) => _buildTag(tag)).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMetadataSection() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.data_object, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'元数据',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildInfoRow('使用次数', widget.template.usageCount.toString()),
|
||||
_buildInfoRow('收藏次数', (widget.template.favoriteCount ?? 0).toString()),
|
||||
if (widget.template.reviewedAt != null)
|
||||
_buildInfoRow('审核时间', _formatDateTime(widget.template.reviewedAt)),
|
||||
if (widget.template.reviewedBy?.isNotEmpty == true)
|
||||
_buildInfoRow('审核人', widget.template.reviewedBy!),
|
||||
if (widget.template.reviewComment?.isNotEmpty == true)
|
||||
_buildInfoRow('审核备注', widget.template.reviewComment!, maxLines: 2),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContentTab() {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 系统提示词部分
|
||||
_buildPromptSection(
|
||||
title: '系统提示词 (System Prompt)',
|
||||
content: widget.template.systemPrompt.isNotEmpty
|
||||
? widget.template.systemPrompt
|
||||
: '未设置系统提示词',
|
||||
icon: Icons.settings,
|
||||
isEmpty: widget.template.systemPrompt.isEmpty,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 用户提示词部分
|
||||
_buildPromptSection(
|
||||
title: '用户提示词 (User Prompt)',
|
||||
content: widget.template.userPrompt.isNotEmpty
|
||||
? widget.template.userPrompt
|
||||
: '未设置用户提示词',
|
||||
icon: Icons.person,
|
||||
isEmpty: widget.template.userPrompt.isEmpty,
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 占位符提示
|
||||
_buildPlaceholderInfo(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPromptSection({
|
||||
required String title,
|
||||
required String content,
|
||||
required IconData icon,
|
||||
bool isEmpty = false,
|
||||
}) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (!isEmpty)
|
||||
IconButton(
|
||||
onPressed: () => _copyToClipboard(content),
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
tooltip: '复制到剪贴板',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
constraints: const BoxConstraints(minHeight: 120),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isEmpty
|
||||
? Colors.grey.withOpacity(0.05)
|
||||
: Colors.grey.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isEmpty
|
||||
? Colors.grey.withOpacity(0.1)
|
||||
: Colors.grey.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: SelectableText(
|
||||
content,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 14,
|
||||
height: 1.4,
|
||||
color: isEmpty
|
||||
? WebTheme.getSecondaryTextColor(context)
|
||||
: WebTheme.getTextColor(context),
|
||||
fontStyle: isEmpty ? FontStyle.italic : FontStyle.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建占位符信息
|
||||
Widget _buildPlaceholderInfo() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.code, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'占位符说明',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'提示词中可以使用占位符来动态插入内容,常用占位符包括:',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildPlaceholderExample('{content}', '要处理的主要内容'),
|
||||
_buildPlaceholderExample('{context}', '上下文信息'),
|
||||
_buildPlaceholderExample('{requirement}', '具体要求'),
|
||||
_buildPlaceholderExample('{style}', '风格要求'),
|
||||
_buildPlaceholderExample('{length}', '长度要求'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaceholderExample(String placeholder, String description) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getPrimaryColor(context).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: WebTheme.getPrimaryColor(context).withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
placeholder,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
description,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatisticsTab() {
|
||||
final stats = widget.statistics ?? {};
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildStatsCard('使用统计', [
|
||||
_buildStatRow('总使用次数', stats['usageCount']?.toString() ?? '0'),
|
||||
_buildStatRow('本月使用', stats['monthlyUsage']?.toString() ?? '0'),
|
||||
_buildStatRow('本周使用', stats['weeklyUsage']?.toString() ?? '0'),
|
||||
_buildStatRow('今日使用', stats['dailyUsage']?.toString() ?? '0'),
|
||||
], Icons.play_arrow),
|
||||
const SizedBox(height: 24),
|
||||
_buildStatsCard('用户反馈', [
|
||||
_buildStatRow('收藏次数', stats['favoriteCount']?.toString() ?? '0'),
|
||||
_buildStatRow('平均评分', stats['averageRating']?.toString() ?? '0.0'),
|
||||
_buildStatRow('评分人数', stats['ratingCount']?.toString() ?? '0'),
|
||||
_buildStatRow('反馈次数', stats['feedbackCount']?.toString() ?? '0'),
|
||||
], Icons.favorite),
|
||||
const SizedBox(height: 24),
|
||||
_buildStatsCard('性能数据', [
|
||||
_buildStatRow('平均响应时间', '${stats['averageResponseTime'] ?? 0}ms'),
|
||||
_buildStatRow('成功率', '${stats['successRate'] ?? 100}%'),
|
||||
_buildStatRow('错误次数', stats['errorCount']?.toString() ?? '0'),
|
||||
_buildStatRow('最后使用时间', _formatDateTime(stats['lastUsedAt'] as DateTime?)),
|
||||
], Icons.speed),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatsCard(String title, List<Widget> children, IconData icon) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(String label, String value, {int maxLines = 1}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: Text(
|
||||
'$label:',
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
color: WebTheme.getTextColor(context).withOpacity(0.8),
|
||||
),
|
||||
maxLines: maxLines,
|
||||
overflow: maxLines > 1 ? TextOverflow.ellipsis : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusChip({
|
||||
required String label,
|
||||
required String value,
|
||||
required Color color,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: color.withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: color,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTag(String tag) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getPrimaryColor(context).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: WebTheme.getPrimaryColor(context).withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
tag,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getFeatureTypeLabel(String? featureType) {
|
||||
switch (featureType) {
|
||||
case 'AI_CHAT':
|
||||
return 'AI聊天';
|
||||
case 'TEXT_EXPANSION':
|
||||
return '文本扩写';
|
||||
case 'TEXT_REFACTOR':
|
||||
return '文本润色';
|
||||
case 'TEXT_SUMMARY':
|
||||
return '文本总结';
|
||||
case 'SCENE_TO_SUMMARY':
|
||||
return '场景转摘要';
|
||||
case 'SUMMARY_TO_SCENE':
|
||||
return '摘要转场景';
|
||||
case 'NOVEL_GENERATION':
|
||||
return '小说生成';
|
||||
case 'PROFESSIONAL_FICTION_CONTINUATION':
|
||||
return '专业续写';
|
||||
case 'SCENE_BEAT_GENERATION':
|
||||
return '场景节拍生成';
|
||||
default:
|
||||
return featureType ?? '未知类型';
|
||||
}
|
||||
}
|
||||
|
||||
String _getLanguageLabel(String? language) {
|
||||
switch (language) {
|
||||
case 'zh':
|
||||
return '中文';
|
||||
case 'en':
|
||||
return 'English';
|
||||
case 'ja':
|
||||
return '日本語';
|
||||
case 'ko':
|
||||
return '한국어';
|
||||
default:
|
||||
return language ?? '中文';
|
||||
}
|
||||
}
|
||||
|
||||
Color _getRatingColor(double? rating) {
|
||||
if (rating == null) return WebTheme.getSecondaryTextColor(context);
|
||||
if (rating >= 4.5) return WebTheme.success;
|
||||
if (rating >= 3.5) return WebTheme.warning;
|
||||
if (rating >= 2.0) return WebTheme.error;
|
||||
return WebTheme.getSecondaryTextColor(context);
|
||||
}
|
||||
|
||||
String _formatDateTime(DateTime? dateTime) {
|
||||
if (dateTime == null) return '未设置';
|
||||
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(dateTime);
|
||||
|
||||
if (difference.inDays > 0) {
|
||||
return '${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-${dateTime.day.toString().padLeft(2, '0')}';
|
||||
} else if (difference.inHours > 0) {
|
||||
return '${difference.inHours}小时前';
|
||||
} else if (difference.inMinutes > 0) {
|
||||
return '${difference.inMinutes}分钟前';
|
||||
} else {
|
||||
return '刚刚';
|
||||
}
|
||||
}
|
||||
|
||||
void _copyToClipboard(String text) {
|
||||
Clipboard.setData(ClipboardData(text: text));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('已复制到剪贴板'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
533
AINoval/lib/screens/admin/widgets/template_review_dialog.dart
Normal file
533
AINoval/lib/screens/admin/widgets/template_review_dialog.dart
Normal file
@@ -0,0 +1,533 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../models/prompt_models.dart';
|
||||
import '../../../utils/logger.dart';
|
||||
|
||||
/// 模板审核对话框
|
||||
class TemplateReviewDialog extends StatefulWidget {
|
||||
final EnhancedUserPromptTemplate template;
|
||||
final Function(bool approved, String? comment) onReview;
|
||||
|
||||
const TemplateReviewDialog({
|
||||
Key? key,
|
||||
required this.template,
|
||||
required this.onReview,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<TemplateReviewDialog> createState() => _TemplateReviewDialogState();
|
||||
}
|
||||
|
||||
class _TemplateReviewDialogState extends State<TemplateReviewDialog> {
|
||||
final _reviewCommentController = TextEditingController();
|
||||
|
||||
String _reviewAction = 'approve'; // 'approve', 'reject'
|
||||
bool _setAsVerified = false;
|
||||
bool _isLoading = false;
|
||||
|
||||
static const Map<String, String> _actionLabels = {
|
||||
'approve': '通过审核',
|
||||
'reject': '拒绝',
|
||||
};
|
||||
|
||||
static const Map<String, Color> _actionColors = {
|
||||
'approve': Colors.green,
|
||||
'reject': Colors.red,
|
||||
};
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_reviewCommentController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
child: Container(
|
||||
width: 700,
|
||||
constraints: const BoxConstraints(maxHeight: 800),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHeader(),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildTemplateInfo(),
|
||||
const SizedBox(height: 24),
|
||||
_buildTemplateContent(),
|
||||
const SizedBox(height: 24),
|
||||
_buildReviewSection(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
_buildActionButtons(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Row(
|
||||
children: [
|
||||
const Icon(Icons.rate_review, size: 24),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'模板审核',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Spacer(),
|
||||
_buildStatusChip(),
|
||||
const SizedBox(width: 16),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusChip() {
|
||||
String status;
|
||||
Color color;
|
||||
|
||||
if (widget.template.isVerified == true) {
|
||||
status = '已认证';
|
||||
color = Colors.green;
|
||||
} else if (widget.template.isPublic == true) {
|
||||
status = '已发布';
|
||||
color = Colors.blue;
|
||||
} else {
|
||||
status = '待审核';
|
||||
color = Colors.orange;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: color.withOpacity(0.3)),
|
||||
),
|
||||
child: Text(
|
||||
status,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTemplateInfo() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'模板信息',
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 基本信息
|
||||
_buildInfoRow('模板名称', widget.template.name),
|
||||
if (widget.template.description?.isNotEmpty == true)
|
||||
_buildInfoRow('描述', widget.template.description!),
|
||||
_buildInfoRow('功能类型', _getFeatureTypeLabel(widget.template.featureType.toApiString())),
|
||||
if (widget.template.authorId?.isNotEmpty == true)
|
||||
_buildInfoRow('作者', widget.template.authorId!),
|
||||
_buildInfoRow('版本', (widget.template.version ?? 1).toString()),
|
||||
_buildInfoRow('语言', widget.template.language ?? 'zh'),
|
||||
_buildInfoRow('创建时间', _formatDateTime(widget.template.createdAt)),
|
||||
_buildInfoRow('使用次数', '${widget.template.usageCount} 次'),
|
||||
_buildInfoRow('收藏次数', '${widget.template.favoriteCount ?? 0} 次'),
|
||||
if (widget.template.rating > 0)
|
||||
_buildInfoRow('评分', widget.template.rating.toStringAsFixed(1)),
|
||||
|
||||
// 标签
|
||||
if (widget.template.tags.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'标签:',
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: widget.template.tags.map((tag) =>
|
||||
_buildTag(tag)).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: Text(
|
||||
'$label:',
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(value),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTag(String tag) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
tag,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTemplateContent() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'模板内容',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (widget.template.systemPrompt.isNotEmpty) ...[
|
||||
Text(
|
||||
'系统提示词:',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.background,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
widget.template.systemPrompt,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
if (widget.template.userPrompt.isNotEmpty) ...[
|
||||
Text(
|
||||
'用户提示词:',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.background,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
widget.template.userPrompt,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReviewSection() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'审核操作',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 审核动作选择
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'审核结果:',
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Column(
|
||||
children: _actionLabels.entries.map((entry) {
|
||||
return RadioListTile<String>(
|
||||
title: Text(
|
||||
entry.value,
|
||||
style: TextStyle(
|
||||
color: _actionColors[entry.key],
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
value: entry.key,
|
||||
groupValue: _reviewAction,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_reviewAction = value!;
|
||||
});
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
|
||||
if (_reviewAction == 'approve') ...[
|
||||
const SizedBox(height: 12),
|
||||
CheckboxListTile(
|
||||
title: const Text('同时设为认证模板'),
|
||||
subtitle: const Text('为该模板添加官方认证标识'),
|
||||
value: _setAsVerified,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_setAsVerified = value ?? false;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 审核评论
|
||||
TextFormField(
|
||||
controller: _reviewCommentController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '审核备注',
|
||||
hintText: _getCommentHint(),
|
||||
border: const OutlineInputBorder(),
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
maxLines: 4,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _getCommentHint() {
|
||||
switch (_reviewAction) {
|
||||
case 'approve':
|
||||
return '可以添加通过审核的说明(可选)';
|
||||
case 'reject':
|
||||
return '请说明拒绝的原因';
|
||||
case 'request_changes':
|
||||
return '请详细说明需要修改的内容';
|
||||
default:
|
||||
return '请输入审核备注';
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildActionButtons() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _isLoading ? null : () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : _submitReview,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _actionColors[_reviewAction],
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(_actionLabels[_reviewAction]!),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _submitReview() async {
|
||||
if (_reviewAction == 'reject') {
|
||||
if (_reviewCommentController.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('拒绝审核时请填写审核备注')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final reviewComment = _reviewCommentController.text.trim();
|
||||
final approved = _reviewAction == 'approve';
|
||||
|
||||
await widget.onReview(approved, reviewComment.isEmpty ? null : reviewComment);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.e('TemplateReviewDialog', '提交模板审核失败', e);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('提交失败: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _getFeatureTypeLabel(String? featureType) {
|
||||
switch (featureType) {
|
||||
case 'AI_CHAT':
|
||||
return 'AI聊天';
|
||||
case 'TEXT_EXPANSION':
|
||||
return '文本扩写';
|
||||
case 'TEXT_REFACTOR':
|
||||
return '文本润色';
|
||||
case 'TEXT_SUMMARY':
|
||||
return '文本总结';
|
||||
case 'SCENE_TO_SUMMARY':
|
||||
return '场景转摘要';
|
||||
case 'SUMMARY_TO_SCENE':
|
||||
return '摘要转场景';
|
||||
case 'NOVEL_GENERATION':
|
||||
return '小说生成';
|
||||
case 'PROFESSIONAL_FICTION_CONTINUATION':
|
||||
return '专业续写';
|
||||
case 'SCENE_BEAT_GENERATION':
|
||||
return '场景节拍生成';
|
||||
default:
|
||||
return featureType ?? '未知';
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDateTime(DateTime? dateTime) {
|
||||
if (dateTime == null) return '';
|
||||
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(dateTime);
|
||||
|
||||
if (difference.inDays > 0) {
|
||||
return '${difference.inDays}天前';
|
||||
} else if (difference.inHours > 0) {
|
||||
return '${difference.inHours}小时前';
|
||||
} else if (difference.inMinutes > 0) {
|
||||
return '${difference.inMinutes}分钟前';
|
||||
} else {
|
||||
return '刚刚';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../services/api_service/repositories/impl/admin_repository_impl.dart';
|
||||
import '../../../utils/logger.dart';
|
||||
import '../../../widgets/common/loading_indicator.dart';
|
||||
|
||||
/// 模板统计对话框
|
||||
class TemplateStatisticsDialog extends StatefulWidget {
|
||||
const TemplateStatisticsDialog({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<TemplateStatisticsDialog> createState() => _TemplateStatisticsDialogState();
|
||||
}
|
||||
|
||||
class _TemplateStatisticsDialogState extends State<TemplateStatisticsDialog> {
|
||||
final AdminRepositoryImpl _adminRepository = AdminRepositoryImpl();
|
||||
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
Map<String, dynamic>? _statistics;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadStatistics();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
child: Container(
|
||||
width: 600,
|
||||
height: 500,
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHeader(),
|
||||
const SizedBox(height: 24),
|
||||
Expanded(
|
||||
child: _buildContent(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildActionButtons(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Row(
|
||||
children: [
|
||||
const Icon(Icons.analytics, size: 24),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'模板统计',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent() {
|
||||
if (_isLoading) {
|
||||
return const Center(child: LoadingIndicator());
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'加载失败',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_error!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).textTheme.bodyMedium?.color?.withOpacity(0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _loadStatistics,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_statistics == null) {
|
||||
return const Center(
|
||||
child: Text('暂无统计数据'),
|
||||
);
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildOverviewSection(),
|
||||
const SizedBox(height: 24),
|
||||
_buildCategorySection(),
|
||||
const SizedBox(height: 24),
|
||||
_buildStatusSection(),
|
||||
const SizedBox(height: 24),
|
||||
_buildTopTemplatesSection(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOverviewSection() {
|
||||
final overview = _statistics!['overview'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'总览',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
GridView.count(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 16,
|
||||
childAspectRatio: 2.5,
|
||||
children: [
|
||||
_buildStatCard('总模板数', '${overview['totalTemplates'] ?? 0}', Icons.article, Colors.blue),
|
||||
_buildStatCard('官方模板', '${overview['officialTemplates'] ?? 0}', Icons.verified, Colors.green),
|
||||
_buildStatCard('用户模板', '${overview['userTemplates'] ?? 0}', Icons.person, Colors.orange),
|
||||
_buildStatCard('已发布', '${overview['publishedTemplates'] ?? 0}', Icons.public, Colors.purple),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatCard(String title, String value, IconData icon, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: color.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 24, color: color),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: color.withOpacity(0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategorySection() {
|
||||
final categories = _statistics!['byCategory'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'按功能类型分布',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
...categories.entries.map((entry) {
|
||||
final label = _getFeatureTypeLabel(entry.key);
|
||||
final count = entry.value as int;
|
||||
return _buildProgressItem(label, count, _getMaxCount(categories));
|
||||
}).toList(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusSection() {
|
||||
final status = _statistics!['byStatus'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'按状态分布',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
...status.entries.map((entry) {
|
||||
final label = _getStatusLabel(entry.key);
|
||||
final count = entry.value as int;
|
||||
final color = _getStatusColor(entry.key);
|
||||
return _buildProgressItem(label, count, _getMaxCount(status), color);
|
||||
}).toList(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgressItem(String label, int count, int maxCount, [Color? color]) {
|
||||
final progress = maxCount > 0 ? count / maxCount : 0.0;
|
||||
final itemColor = color ?? Theme.of(context).colorScheme.primary;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label),
|
||||
Text(
|
||||
'$count',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: itemColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
LinearProgressIndicator(
|
||||
value: progress,
|
||||
backgroundColor: itemColor.withOpacity(0.2),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(itemColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTopTemplatesSection() {
|
||||
final topTemplates = _statistics!['topTemplates'] as List<dynamic>? ?? [];
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'热门模板 Top 5',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
if (topTemplates.isEmpty)
|
||||
const Text('暂无数据')
|
||||
else
|
||||
...topTemplates.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final template = entry.value as Map<String, dynamic>;
|
||||
return _buildTopTemplateItem(
|
||||
index + 1,
|
||||
template['templateName'] as String,
|
||||
template['useCount'] as int,
|
||||
template['averageRating'] as double?,
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTopTemplateItem(int rank, String name, int useCount, double? rating) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: _getRankColor(rank),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'$rank',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
name,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'使用 $useCount 次',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
if (rating != null && rating > 0) ...[
|
||||
const SizedBox(width: 8),
|
||||
Icon(Icons.star, size: 16, color: Colors.amber),
|
||||
Text(
|
||||
rating.toStringAsFixed(1),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButtons() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: _loadStatistics,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('刷新'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('关闭'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadStatistics() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final statistics = await _adminRepository.getTemplateStatistics();
|
||||
setState(() {
|
||||
_statistics = statistics;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
AppLogger.e('TemplateStatisticsDialog', '加载模板统计失败', e);
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
int _getMaxCount(Map<String, dynamic> data) {
|
||||
if (data.isEmpty) return 1;
|
||||
return data.values.cast<int>().reduce((a, b) => a > b ? a : b);
|
||||
}
|
||||
|
||||
String _getFeatureTypeLabel(String featureType) {
|
||||
switch (featureType) {
|
||||
case 'CHAT':
|
||||
return 'AI聊天';
|
||||
case 'SCENE_GENERATION':
|
||||
return '场景生成';
|
||||
case 'CONTINUATION':
|
||||
return '续写';
|
||||
case 'SUMMARY':
|
||||
return '总结';
|
||||
case 'OUTLINE':
|
||||
return '大纲';
|
||||
default:
|
||||
return featureType;
|
||||
}
|
||||
}
|
||||
|
||||
String _getStatusLabel(String status) {
|
||||
switch (status) {
|
||||
case 'PUBLISHED':
|
||||
return '已发布';
|
||||
case 'PENDING':
|
||||
return '待审核';
|
||||
case 'REJECTED':
|
||||
return '已拒绝';
|
||||
case 'VERIFIED':
|
||||
return '已认证';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
Color _getStatusColor(String status) {
|
||||
switch (status) {
|
||||
case 'PUBLISHED':
|
||||
return Colors.green;
|
||||
case 'PENDING':
|
||||
return Colors.orange;
|
||||
case 'REJECTED':
|
||||
return Colors.red;
|
||||
case 'VERIFIED':
|
||||
return Colors.blue;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
Color _getRankColor(int rank) {
|
||||
switch (rank) {
|
||||
case 1:
|
||||
return Colors.amber;
|
||||
case 2:
|
||||
return Colors.grey[400]!;
|
||||
case 3:
|
||||
return Colors.orange[300]!;
|
||||
default:
|
||||
return Colors.blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
236
AINoval/lib/screens/admin/widgets/user_edit_dialog.dart
Normal file
236
AINoval/lib/screens/admin/widgets/user_edit_dialog.dart
Normal file
@@ -0,0 +1,236 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../models/admin/admin_models.dart';
|
||||
|
||||
class UserEditDialog extends StatefulWidget {
|
||||
final AdminUser user;
|
||||
|
||||
const UserEditDialog({
|
||||
super.key,
|
||||
required this.user,
|
||||
});
|
||||
|
||||
@override
|
||||
State<UserEditDialog> createState() => _UserEditDialogState();
|
||||
}
|
||||
|
||||
class _UserEditDialogState extends State<UserEditDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final TextEditingController _emailController;
|
||||
late final TextEditingController _displayNameController;
|
||||
late String _selectedAccountStatus;
|
||||
|
||||
final List<String> _accountStatuses = [
|
||||
'ACTIVE',
|
||||
'SUSPENDED',
|
||||
'DISABLED',
|
||||
'PENDING_VERIFICATION',
|
||||
];
|
||||
|
||||
final Map<String, String> _statusLabels = {
|
||||
'ACTIVE': '活跃',
|
||||
'SUSPENDED': '暂停',
|
||||
'DISABLED': '禁用',
|
||||
'PENDING_VERIFICATION': '待验证',
|
||||
};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_emailController = TextEditingController(text: widget.user.email);
|
||||
_displayNameController = TextEditingController(text: widget.user.displayName ?? '');
|
||||
_selectedAccountStatus = widget.user.accountStatus;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_displayNameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
'编辑用户信息',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 400,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 用户基本信息展示
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.person,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.user.username,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'ID: ${widget.user.id}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'积分: ${widget.user.credits}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 邮箱输入
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '邮箱',
|
||||
prefixIcon: Icon(Icons.email),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入邮箱';
|
||||
}
|
||||
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
|
||||
return '请输入有效的邮箱地址';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 显示名称输入
|
||||
TextFormField(
|
||||
controller: _displayNameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '显示名称',
|
||||
prefixIcon: Icon(Icons.badge),
|
||||
border: OutlineInputBorder(),
|
||||
hintText: '可选,留空则使用用户名',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 账户状态选择
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedAccountStatus,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '账户状态',
|
||||
prefixIcon: Icon(Icons.account_circle),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: _accountStatuses.map((status) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: status,
|
||||
child: Row(
|
||||
children: [
|
||||
_getStatusIcon(status),
|
||||
const SizedBox(width: 8),
|
||||
Text(_statusLabels[status] ?? status),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_selectedAccountStatus = value;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: _handleSubmit,
|
||||
child: const Text('保存'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getStatusIcon(String status) {
|
||||
switch (status) {
|
||||
case 'ACTIVE':
|
||||
return Icon(Icons.check_circle, color: Colors.green, size: 16);
|
||||
case 'SUSPENDED':
|
||||
return Icon(Icons.pause_circle, color: Colors.orange, size: 16);
|
||||
case 'DISABLED':
|
||||
return Icon(Icons.cancel, color: Colors.red, size: 16);
|
||||
case 'PENDING_VERIFICATION':
|
||||
return Icon(Icons.pending, color: Colors.blue, size: 16);
|
||||
default:
|
||||
return Icon(Icons.help, color: Colors.grey, size: 16);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSubmit() {
|
||||
if (_formKey.currentState?.validate() == true) {
|
||||
final email = _emailController.text.trim();
|
||||
final displayName = _displayNameController.text.trim();
|
||||
|
||||
// 检查是否有更改
|
||||
bool hasChanges = false;
|
||||
Map<String, dynamic> changes = {};
|
||||
|
||||
if (email != widget.user.email) {
|
||||
hasChanges = true;
|
||||
changes['email'] = email;
|
||||
}
|
||||
|
||||
if (displayName != (widget.user.displayName ?? '')) {
|
||||
hasChanges = true;
|
||||
changes['displayName'] = displayName.isEmpty ? null : displayName;
|
||||
}
|
||||
|
||||
if (_selectedAccountStatus != widget.user.accountStatus) {
|
||||
hasChanges = true;
|
||||
changes['accountStatus'] = _selectedAccountStatus;
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
Navigator.of(context).pop(changes);
|
||||
} else {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
484
AINoval/lib/screens/admin/widgets/user_management_table.dart
Normal file
484
AINoval/lib/screens/admin/widgets/user_management_table.dart
Normal file
@@ -0,0 +1,484 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../models/admin/admin_models.dart';
|
||||
import '../../../blocs/admin/admin_bloc.dart';
|
||||
import 'credit_operation_dialog.dart';
|
||||
import 'user_edit_dialog.dart';
|
||||
|
||||
class UserManagementTable extends StatelessWidget {
|
||||
final List<AdminUser> users;
|
||||
|
||||
const UserManagementTable({
|
||||
super.key,
|
||||
required this.users,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题栏
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(8),
|
||||
topRight: Radius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'用户管理',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () => context.read<AdminBloc>().add(LoadUsers()),
|
||||
tooltip: '刷新用户列表',
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'总计: ${users.length} 用户',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 数据表格
|
||||
if (users.isNotEmpty)
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: DataTable(
|
||||
columnSpacing: 16,
|
||||
dataRowMinHeight: 48,
|
||||
dataRowMaxHeight: 80,
|
||||
headingRowHeight: 56,
|
||||
columns: const [
|
||||
DataColumn(
|
||||
label: Text(
|
||||
'用户名',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
DataColumn(
|
||||
label: Text(
|
||||
'邮箱',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
DataColumn(
|
||||
label: Text(
|
||||
'状态',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
DataColumn(
|
||||
label: Text(
|
||||
'积分',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
numeric: true,
|
||||
),
|
||||
DataColumn(
|
||||
label: Text(
|
||||
'角色',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
DataColumn(
|
||||
label: Text(
|
||||
'创建时间',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
DataColumn(
|
||||
label: Text(
|
||||
'操作',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
rows: users.map((user) => DataRow(
|
||||
cells: [
|
||||
DataCell(
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
user.username,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
if (user.displayName != null && user.displayName!.isNotEmpty)
|
||||
Text(
|
||||
user.displayName!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
SelectableText(
|
||||
user.email,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
DataCell(_buildStatusChip(context, user.accountStatus)),
|
||||
DataCell(
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
_formatCredits(user.credits),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 2,
|
||||
children: user.roles.take(2).map((role) => Chip(
|
||||
label: Text(
|
||||
role,
|
||||
style: const TextStyle(fontSize: 10),
|
||||
),
|
||||
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
|
||||
labelStyle: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
),
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
)).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
Text(
|
||||
user.createdAt.toString().substring(0, 10),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
DataCell(_buildActionButtons(context, user)),
|
||||
],
|
||||
)).toList(),
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.people_outline,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'暂无用户数据',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusChip(BuildContext context, String status) {
|
||||
Color backgroundColor;
|
||||
Color textColor;
|
||||
IconData icon;
|
||||
String label;
|
||||
|
||||
switch (status) {
|
||||
case 'ACTIVE':
|
||||
backgroundColor = Colors.green.withOpacity(0.1);
|
||||
textColor = Colors.green.shade700;
|
||||
icon = Icons.check_circle;
|
||||
label = '活跃';
|
||||
break;
|
||||
case 'SUSPENDED':
|
||||
backgroundColor = Colors.orange.withOpacity(0.1);
|
||||
textColor = Colors.orange.shade700;
|
||||
icon = Icons.pause_circle;
|
||||
label = '暂停';
|
||||
break;
|
||||
case 'DISABLED':
|
||||
backgroundColor = Colors.red.withOpacity(0.1);
|
||||
textColor = Colors.red.shade700;
|
||||
icon = Icons.cancel;
|
||||
label = '禁用';
|
||||
break;
|
||||
case 'PENDING_VERIFICATION':
|
||||
backgroundColor = Colors.blue.withOpacity(0.1);
|
||||
textColor = Colors.blue.shade700;
|
||||
icon = Icons.pending;
|
||||
label = '待验证';
|
||||
break;
|
||||
default:
|
||||
backgroundColor = Colors.grey.withOpacity(0.1);
|
||||
textColor = Colors.grey.shade700;
|
||||
icon = Icons.help;
|
||||
label = status;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: textColor),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatCredits(int credits) {
|
||||
if (credits >= 1000000) {
|
||||
return '${(credits / 1000000).toStringAsFixed(1)}M';
|
||||
} else if (credits >= 1000) {
|
||||
return '${(credits / 1000).toStringAsFixed(1)}K';
|
||||
} else {
|
||||
return credits.toString();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildActionButtons(BuildContext context, AdminUser user) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 编辑用户信息
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, size: 18),
|
||||
onPressed: () => _showEditUserDialog(context, user),
|
||||
tooltip: '编辑用户信息',
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
// 添加积分
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_circle, size: 18),
|
||||
onPressed: () => _showCreditDialog(context, user, true),
|
||||
tooltip: '添加积分',
|
||||
visualDensity: VisualDensity.compact,
|
||||
color: Colors.green.shade700,
|
||||
),
|
||||
// 扣减积分
|
||||
IconButton(
|
||||
icon: const Icon(Icons.remove_circle, size: 18),
|
||||
onPressed: () => _showCreditDialog(context, user, false),
|
||||
tooltip: '扣减积分',
|
||||
visualDensity: VisualDensity.compact,
|
||||
color: Colors.red.shade700,
|
||||
),
|
||||
// 更多操作
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert, size: 18),
|
||||
onSelected: (value) => _handleMenuAction(context, user, value),
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'toggle_status',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.swap_horiz, size: 18),
|
||||
title: Text('切换状态'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'assign_role',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.security, size: 18),
|
||||
title: Text('分配角色'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'view_details',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.info, size: 18),
|
||||
title: Text('查看详情'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _showEditUserDialog(BuildContext context, AdminUser user) async {
|
||||
final result = await showDialog<Map<String, dynamic>>(
|
||||
context: context,
|
||||
builder: (context) => UserEditDialog(user: user),
|
||||
);
|
||||
|
||||
if (result != null && context.mounted) {
|
||||
context.read<AdminBloc>().add(UpdateUserInfo(
|
||||
userId: user.id,
|
||||
email: result['email'],
|
||||
displayName: result['displayName'],
|
||||
accountStatus: result['accountStatus'],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
void _showCreditDialog(BuildContext context, AdminUser user, bool isAdd) async {
|
||||
final result = await showDialog<Map<String, dynamic>>(
|
||||
context: context,
|
||||
builder: (context) => CreditOperationDialog(user: user, isAdd: isAdd),
|
||||
);
|
||||
|
||||
if (result != null && context.mounted) {
|
||||
final amount = result['amount'] as int;
|
||||
final reason = result['reason'] as String;
|
||||
|
||||
if (isAdd) {
|
||||
context.read<AdminBloc>().add(AddCreditsToUser(
|
||||
userId: user.id,
|
||||
amount: amount,
|
||||
reason: reason,
|
||||
));
|
||||
} else {
|
||||
context.read<AdminBloc>().add(DeductCreditsFromUser(
|
||||
userId: user.id,
|
||||
amount: amount,
|
||||
reason: reason,
|
||||
));
|
||||
}
|
||||
|
||||
// 显示成功消息
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('${isAdd ? "添加" : "扣减"}积分操作已提交'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleMenuAction(BuildContext context, AdminUser user, String action) {
|
||||
switch (action) {
|
||||
case 'toggle_status':
|
||||
final newStatus = user.accountStatus == 'ACTIVE' ? 'SUSPENDED' : 'ACTIVE';
|
||||
context.read<AdminBloc>().add(UpdateUserStatus(
|
||||
userId: user.id,
|
||||
status: newStatus,
|
||||
));
|
||||
break;
|
||||
case 'assign_role':
|
||||
_showAssignRoleDialog(context, user);
|
||||
break;
|
||||
case 'view_details':
|
||||
_showUserDetailsDialog(context, user);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _showAssignRoleDialog(BuildContext context, AdminUser user) {
|
||||
// TODO: 实现角色分配对话框
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('角色分配功能开发中...')),
|
||||
);
|
||||
}
|
||||
|
||||
void _showUserDetailsDialog(BuildContext context, AdminUser user) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('用户详情 - ${user.username}'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildDetailRow('用户ID', user.id),
|
||||
_buildDetailRow('用户名', user.username),
|
||||
_buildDetailRow('邮箱', user.email),
|
||||
_buildDetailRow('显示名称', user.displayName ?? '-'),
|
||||
_buildDetailRow('账户状态', user.accountStatus),
|
||||
_buildDetailRow('积分余额', user.credits.toString()),
|
||||
_buildDetailRow('角色', user.roles.join(', ')),
|
||||
_buildDetailRow('创建时间', user.createdAt.toString()),
|
||||
_buildDetailRow('更新时间', user.updatedAt?.toString() ?? '-'),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('关闭'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: Text(
|
||||
'$label:',
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SelectableText(value),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
216
AINoval/lib/screens/admin/widgets/validation_results_dialog.dart
Normal file
216
AINoval/lib/screens/admin/widgets/validation_results_dialog.dart
Normal file
@@ -0,0 +1,216 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../models/public_model_config.dart';
|
||||
import '../../../utils/web_theme.dart';
|
||||
|
||||
class ValidationResultsDialog extends StatelessWidget {
|
||||
const ValidationResultsDialog({
|
||||
super.key,
|
||||
required this.config,
|
||||
});
|
||||
|
||||
final PublicModelConfigWithKeys config;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final keys = config.apiKeyStatuses ?? const <ApiKeyWithStatus>[];
|
||||
final successCount = keys.where((k) => k.isValid == true).length;
|
||||
final total = keys.length;
|
||||
final bool allPass = total > 0 && successCount == total;
|
||||
final bool somePass = successCount > 0 && successCount < total;
|
||||
|
||||
return Dialog(
|
||||
backgroundColor: WebTheme.getCardColor(context),
|
||||
child: Container(
|
||||
width: 720,
|
||||
height: 520,
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'API Key 验证结果',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_statusChip(
|
||||
context,
|
||||
successCount == total && total > 0 ? '全部通过' : '$successCount/$total 通过',
|
||||
successCount == total && total > 0 ? Colors.green : Colors.orange,
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: Icon(Icons.close, color: WebTheme.getTextColor(context)),
|
||||
tooltip: '关闭',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_buildSummaryBanner(context, allPass: allPass, somePass: somePass, total: total, successCount: successCount),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'${config.provider}:${config.modelId}${config.displayName != null && config.displayName!.isNotEmpty ? ' (${config.displayName})' : ''}',
|
||||
style: TextStyle(color: WebTheme.getSecondaryTextColor(context)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getBackgroundColor(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: WebTheme.getSecondaryBorderColor(context)),
|
||||
),
|
||||
child: keys.isEmpty
|
||||
? Center(
|
||||
child: Text('没有可显示的API Key', style: TextStyle(color: WebTheme.getSecondaryTextColor(context))),
|
||||
)
|
||||
: ListView.separated(
|
||||
itemCount: keys.length,
|
||||
separatorBuilder: (_, __) => Divider(height: 1, color: WebTheme.getSecondaryBorderColor(context)),
|
||||
itemBuilder: (context, index) {
|
||||
final item = keys[index];
|
||||
return _buildRow(context, item);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummaryBanner(BuildContext context, {required bool allPass, required bool somePass, required int total, required int successCount}) {
|
||||
late Color bg;
|
||||
late Color fg;
|
||||
late IconData icon;
|
||||
late String text;
|
||||
if (allPass) {
|
||||
bg = Colors.green.withOpacity(0.12);
|
||||
fg = Colors.green;
|
||||
icon = Icons.check_circle_rounded;
|
||||
text = '全部通过:$successCount/$total';
|
||||
} else if (somePass) {
|
||||
bg = Colors.orange.withOpacity(0.12);
|
||||
fg = Colors.orange;
|
||||
icon = Icons.error_outline_rounded;
|
||||
text = '部分通过:$successCount/$total';
|
||||
} else {
|
||||
bg = Colors.red.withOpacity(0.12);
|
||||
fg = Colors.red;
|
||||
icon = Icons.cancel_rounded;
|
||||
text = total == 0 ? '未配置任何API Key' : '全部失败:$successCount/$total';
|
||||
}
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: fg.withOpacity(0.35)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: fg, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(color: fg, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRow(BuildContext context, ApiKeyWithStatus item) {
|
||||
final maskedKey = _maskKey(item.apiKey ?? '');
|
||||
final isOk = item.isValid == true;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
margin: const EdgeInsets.only(top: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isOk ? Colors.green : Colors.red,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
maskedKey,
|
||||
style: TextStyle(
|
||||
color: WebTheme.getTextColor(context),
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_statusChip(context, isOk ? '有效' : '无效', isOk ? Colors.green : Colors.red),
|
||||
],
|
||||
),
|
||||
if ((item.validationError ?? '').isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
item.validationError!,
|
||||
style: TextStyle(color: Colors.redAccent),
|
||||
),
|
||||
],
|
||||
if ((item.note ?? '').isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'备注: ${item.note}',
|
||||
style: TextStyle(color: WebTheme.getSecondaryTextColor(context)),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _maskKey(String key) {
|
||||
if (key.isEmpty) return '(空)';
|
||||
if (key.length <= 8) return '****$key';
|
||||
final start = key.substring(0, 4);
|
||||
final end = key.substring(key.length - 4);
|
||||
return '$start••••••••$end';
|
||||
}
|
||||
|
||||
Widget _statusChip(BuildContext context, String text, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: color.withOpacity(0.4)),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(color: color, fontSize: 12),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
217
AINoval/lib/screens/ai_config/ai_config_management_screen.dart
Normal file
217
AINoval/lib/screens/ai_config/ai_config_management_screen.dart
Normal file
@@ -0,0 +1,217 @@
|
||||
import 'package:ainoval/blocs/ai_config/ai_config_bloc.dart';
|
||||
import 'package:ainoval/config/app_config.dart'; // <<< Import AppConfig
|
||||
import 'package:ainoval/models/user_ai_model_config_model.dart';
|
||||
import 'package:ainoval/services/api_service/base/api_client.dart';
|
||||
import 'package:ainoval/services/api_service/repositories/impl/user_ai_model_config_repository_impl.dart';
|
||||
import 'package:ainoval/services/api_service/repositories/user_ai_model_config_repository.dart';
|
||||
import 'package:ainoval/widgets/common/top_toast.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:ainoval/l10n/app_localizations.dart';
|
||||
import 'package:ainoval/widgets/common/theme_toggle_button.dart';
|
||||
|
||||
import 'widgets/add_edit_ai_config_dialog.dart';
|
||||
import 'widgets/ai_config_list_item.dart';
|
||||
|
||||
class AiConfigManagementScreen extends StatelessWidget {
|
||||
const AiConfigManagementScreen({super.key});
|
||||
|
||||
// TODO: Replace with proper dependency injection for repository
|
||||
static final _tempApiClient =
|
||||
ApiClient(); // Temporary - use injected instance
|
||||
static final UserAIModelConfigRepository _repository =
|
||||
UserAIModelConfigRepositoryImpl(apiClient: _tempApiClient); // Temporary
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
// <<< Get userId from AppConfig >>>
|
||||
// Ensure userId is available before navigating here, or handle null case
|
||||
final String? currentUserId = AppConfig.userId; // Allow null initially
|
||||
|
||||
// Show an error/loading state if userId is null and required
|
||||
if (currentUserId == null) {
|
||||
// <<< Check for null
|
||||
return Scaffold(
|
||||
// appBar: AppBar(title: Text(l10n.errorTitle)), // TODO: Add l10n.errorTitle='错误'
|
||||
appBar: AppBar(title: const Text('错误')), // Placeholder
|
||||
// body: Center(child: Text(l10n.errorUserNotLoggedIn)) // TODO: Add l10n.errorUserNotLoggedIn = '无法加载配置:用户未登录。'
|
||||
body: const Center(child: Text('无法加载配置:用户未登录。')) // Placeholder
|
||||
); // <<< 修正: 移除了多余的括号并添加了分号
|
||||
}
|
||||
|
||||
return BlocProvider(
|
||||
// Use ! because we checked for null above
|
||||
create: (context) => AiConfigBloc(repository: _repository)
|
||||
..add(LoadAiConfigs(userId: currentUserId)),
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
// TODO: Add l10n.aiModelConfigTitle string
|
||||
// title: Text(l10n.aiModelConfigTitle), // Placeholder 'AI 模型配置'
|
||||
title: const Text('AI 模型配置'), // Placeholder
|
||||
actions: [
|
||||
const ThemeToggleButton(),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
),
|
||||
body: BlocConsumer<AiConfigBloc, AiConfigState>(
|
||||
listener: (context, state) {
|
||||
if (state.actionStatus == AiConfigActionStatus.error &&
|
||||
state.actionErrorMessage != null) {
|
||||
TopToast.error(context, '操作失败: ${state.actionErrorMessage!}');
|
||||
}
|
||||
// Optional: Show success message
|
||||
else if (state.actionStatus == AiConfigActionStatus.success) {
|
||||
// Consider showing temporary success confirmations if needed
|
||||
// ScaffoldMessenger.of(context).showSnackBar(
|
||||
// SnackBar(content: Text(l10n.operationSuccessful), backgroundColor: Colors.green), // TODO: Add l10n.operationSuccessful = '操作成功'
|
||||
// );
|
||||
// Reset action status after showing message? Maybe handle in BLoC directly.
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state.status == AiConfigStatus.loading &&
|
||||
state.configs.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (state.status == AiConfigStatus.error && state.configs.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Text(l10n.errorLoadingConfig, style: TextStyle(color: Colors.red)), // TODO: Add l10n.errorLoadingConfig = '加载配置时出错'
|
||||
const Text('加载配置时出错',
|
||||
style: TextStyle(color: Colors.red)), // Placeholder
|
||||
if (state.errorMessage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Text(state.errorMessage!),
|
||||
),
|
||||
ElevatedButton(
|
||||
// Use ! because userId is checked non-null here
|
||||
onPressed: () => context
|
||||
.read<AiConfigBloc>()
|
||||
.add(LoadAiConfigs(userId: currentUserId)),
|
||||
// child: Text(l10n.retry), // TODO: Add l10n.retry = '重试'
|
||||
child: const Text('重试'), // Placeholder
|
||||
)
|
||||
],
|
||||
));
|
||||
}
|
||||
|
||||
final configs = state.configs;
|
||||
final bool isActionLoading =
|
||||
state.actionStatus == AiConfigActionStatus.loading;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
if (configs.isEmpty && state.status != AiConfigStatus.loading)
|
||||
// Center(child: Text(l10n.noConfigsFound)), // TODO: Add l10n.noConfigsFound = '未找到任何配置'
|
||||
const Center(child: Text('未找到任何配置')), // Placeholder
|
||||
ListView.builder(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 80), // Add padding to avoid FAB overlap
|
||||
itemCount: configs.length,
|
||||
itemBuilder: (context, index) {
|
||||
final config = configs[index];
|
||||
// Pass specific loading state for the item if we track it by ID, otherwise use global action loading state
|
||||
// bool itemIsLoading = isActionLoading && state.loadingConfigId == config.id; // Need state.loadingConfigId
|
||||
|
||||
return AiConfigListItem(
|
||||
config: config,
|
||||
// If not tracking individual item loading, disable buttons globally during action
|
||||
isLoading: isActionLoading,
|
||||
// Use ! for userId
|
||||
onEdit: () => _showAddEditDialog(context, currentUserId,
|
||||
config: config), // Pass userId
|
||||
onDelete: () => _showDeleteConfirmation(
|
||||
context, currentUserId, config), // Pass userId
|
||||
onValidate: () => context.read<AiConfigBloc>().add(
|
||||
ValidateAiConfig(
|
||||
userId: currentUserId,
|
||||
configId: config.id)), // Use userId
|
||||
onSetDefault: () => context.read<AiConfigBloc>().add(
|
||||
SetDefaultAiConfig(
|
||||
userId: currentUserId,
|
||||
configId: config.id)), // Use userId
|
||||
);
|
||||
},
|
||||
),
|
||||
// Optional: Global loading indicator overlay
|
||||
// if (isActionLoading)
|
||||
// Positioned.fill(
|
||||
// child: Container(
|
||||
// color: Colors.black.withOpacity(0.1),
|
||||
// child: const Center(child: CircularProgressIndicator()),
|
||||
// ),
|
||||
// ),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
// Use ! for userId
|
||||
onPressed: () =>
|
||||
_showAddEditDialog(context, currentUserId), // Pass userId
|
||||
// tooltip: l10n.addConfigTooltip, // TODO: Add l10n.addConfigTooltip = '添加配置'
|
||||
tooltip: '添加配置', // Placeholder
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// <<< Add userId parameter >>>
|
||||
void _showAddEditDialog(BuildContext context, String userId,
|
||||
{UserAIModelConfigModel? config}) {
|
||||
final aiConfigBloc =
|
||||
context.read<AiConfigBloc>(); // Get BLoC from current context
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible:
|
||||
false, // Prevent closing while dialog action is in progress
|
||||
builder: (_) => BlocProvider.value(
|
||||
// Provide the *existing* BLoC instance to the dialog
|
||||
value: aiConfigBloc,
|
||||
child: AddEditAiConfigDialog(
|
||||
userId: userId, // Pass userId from parameter
|
||||
configToEdit: config,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// <<< Add userId parameter >>>
|
||||
void _showDeleteConfirmation(
|
||||
BuildContext context, String userId, UserAIModelConfigModel config) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
// title: Text(l10n.deleteConfigTitle), // TODO: Add l10n.deleteConfigTitle = '删除配置'
|
||||
title: const Text('删除配置'), // Placeholder
|
||||
// content: Text(l10n.deleteConfigConfirmation(config.alias)), // TODO: Add l10n.deleteConfigConfirmation
|
||||
content: Text('确定要删除配置 ${config.alias} 吗?此操作无法撤销。'), // Placeholder
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
// child: Text(l10n.cancel), // TODO: Add l10n.cancel = '取消'
|
||||
child: const Text('取消'), // Placeholder
|
||||
),
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
onPressed: () {
|
||||
// <<< Use userId from parameter >>>
|
||||
context
|
||||
.read<AiConfigBloc>()
|
||||
.add(DeleteAiConfig(userId: userId, configId: config.id));
|
||||
Navigator.pop(ctx); // Close confirmation dialog
|
||||
},
|
||||
// child: Text(l10n.delete), // TODO: Add l10n.delete = '删除'
|
||||
child: const Text('删除'), // Placeholder
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import 'package:ainoval/blocs/ai_config/ai_config_bloc.dart';
|
||||
import 'package:ainoval/models/user_ai_model_config_model.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:ainoval/l10n/app_localizations.dart';
|
||||
|
||||
class AddEditAiConfigDialog extends StatefulWidget {
|
||||
// Needed for add/update events
|
||||
|
||||
const AddEditAiConfigDialog({
|
||||
super.key,
|
||||
required this.userId,
|
||||
this.configToEdit,
|
||||
});
|
||||
final UserAIModelConfigModel? configToEdit;
|
||||
final String userId;
|
||||
|
||||
@override
|
||||
State<AddEditAiConfigDialog> createState() => _AddEditAiConfigDialogState();
|
||||
}
|
||||
|
||||
class _AddEditAiConfigDialogState extends State<AddEditAiConfigDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late TextEditingController _aliasController;
|
||||
late TextEditingController _apiKeyController;
|
||||
late TextEditingController _apiEndpointController;
|
||||
|
||||
String? _selectedProvider;
|
||||
String? _selectedModel;
|
||||
bool _isLoadingProviders = false;
|
||||
bool _isLoadingModels = false;
|
||||
bool _isSaving = false; // Track internal saving state
|
||||
|
||||
List<String> _providers = [];
|
||||
List<String> _models = [];
|
||||
|
||||
bool get _isEditMode => widget.configToEdit != null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Initialize controllers
|
||||
_aliasController =
|
||||
TextEditingController(text: widget.configToEdit?.alias ?? '');
|
||||
_apiKeyController =
|
||||
TextEditingController(); // API key is never pre-filled for editing
|
||||
_apiEndpointController =
|
||||
TextEditingController(text: widget.configToEdit?.apiEndpoint ?? '');
|
||||
_selectedProvider = widget.configToEdit?.provider;
|
||||
_selectedModel = widget.configToEdit?.modelName;
|
||||
|
||||
// Request providers immediately if needed
|
||||
if (!_isEditMode) {
|
||||
_loadProviders();
|
||||
} else if (_selectedProvider != null) {
|
||||
// If editing, load providers to populate dropdown, and models for the selected provider
|
||||
_loadProviders();
|
||||
_loadModels(_selectedProvider!);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_aliasController.dispose();
|
||||
_apiKeyController.dispose();
|
||||
_apiEndpointController.dispose();
|
||||
// Clear models when dialog is closed
|
||||
context.read<AiConfigBloc>().add(ClearProviderModels());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _loadProviders() {
|
||||
setState(() {
|
||||
_isLoadingProviders = true;
|
||||
});
|
||||
// Use the Bloc provided via context
|
||||
context.read<AiConfigBloc>().add(LoadAvailableProviders());
|
||||
}
|
||||
|
||||
void _loadModels(String provider) {
|
||||
setState(() {
|
||||
_isLoadingModels = true;
|
||||
_selectedModel = null; // Reset model selection when provider changes
|
||||
_models = []; // Clear previous models
|
||||
});
|
||||
context.read<AiConfigBloc>().add(LoadModelsForProvider(provider: provider));
|
||||
}
|
||||
|
||||
void _submitForm() {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
setState(() {
|
||||
_isSaving = true;
|
||||
});
|
||||
final bloc = context.read<AiConfigBloc>();
|
||||
|
||||
if (_isEditMode) {
|
||||
bloc.add(UpdateAiConfig(
|
||||
userId: widget.userId,
|
||||
configId: widget.configToEdit!.id,
|
||||
alias: _aliasController.text.trim().isEmpty
|
||||
? null
|
||||
: _aliasController.text
|
||||
.trim(), // Only send if not empty, or let backend decide
|
||||
apiKey: _apiKeyController.text.trim().isEmpty
|
||||
? null
|
||||
: _apiKeyController.text.trim(), // Only send if changed
|
||||
apiEndpoint: _apiEndpointController.text
|
||||
.trim(), // Send empty string to clear endpoint
|
||||
));
|
||||
} else {
|
||||
bloc.add(AddAiConfig(
|
||||
userId: widget.userId,
|
||||
provider: _selectedProvider!,
|
||||
modelName: _selectedModel!,
|
||||
apiKey: _apiKeyController.text.trim(),
|
||||
alias: _aliasController.text.trim().isEmpty
|
||||
? _selectedModel
|
||||
: _aliasController.text
|
||||
.trim(), // Default alias to model name if empty
|
||||
apiEndpoint: _apiEndpointController.text.trim(),
|
||||
));
|
||||
}
|
||||
// Listen for completion state change to close dialog
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
return BlocListener<AiConfigBloc, AiConfigState>(
|
||||
listener: (context, state) {
|
||||
// Update local lists and loading states based on Bloc state
|
||||
setState(() {
|
||||
_providers = state.availableProviders;
|
||||
_isLoadingProviders =
|
||||
false; // Assuming load finishes once providers appear
|
||||
|
||||
if (state.selectedProviderForModels == _selectedProvider) {
|
||||
_models = state.modelsForProvider;
|
||||
_isLoadingModels = false;
|
||||
} else if (_selectedProvider != null &&
|
||||
state.selectedProviderForModels != _selectedProvider) {
|
||||
// Handle case where Bloc state is for a different provider than selected
|
||||
_isLoadingModels = false; // Stop loading indicator
|
||||
}
|
||||
|
||||
// Handle save completion or error
|
||||
if (_isSaving) {
|
||||
if (state.actionStatus == AiConfigActionStatus.success ||
|
||||
state.actionStatus == AiConfigActionStatus.error) {
|
||||
_isSaving = false;
|
||||
if (state.actionStatus == AiConfigActionStatus.success &&
|
||||
mounted) {
|
||||
Navigator.of(context).pop(); // Close dialog on success
|
||||
}
|
||||
// Error message is handled by the main screen's listener
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
child: AlertDialog(
|
||||
// title: Text(_isEditMode ? l10n.editConfigTitle : l10n.addConfigTitle), // TODO: Add l10n
|
||||
title: Text(_isEditMode ? '编辑配置' : '添加配置'), // Placeholder
|
||||
content: SingleChildScrollView(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
// --- Provider Dropdown ---
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedProvider,
|
||||
// hint: Text(l10n.selectProviderHint), // TODO: Add l10n
|
||||
hint: const Text('选择提供商'), // Placeholder
|
||||
isExpanded: true,
|
||||
onChanged: _isEditMode
|
||||
? null // Cannot change provider when editing
|
||||
: (String? newValue) {
|
||||
if (newValue != null &&
|
||||
newValue != _selectedProvider) {
|
||||
setState(() {
|
||||
_selectedProvider = newValue;
|
||||
_selectedModel = null; // Reset model
|
||||
_models = []; // Clear models
|
||||
});
|
||||
_loadModels(newValue);
|
||||
}
|
||||
},
|
||||
items:
|
||||
_providers.map<DropdownMenuItem<String>>((String value) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: value,
|
||||
child: Text(value),
|
||||
);
|
||||
}).toList(),
|
||||
// validator: (value) => value == null ? l10n.providerRequired : null, // TODO: Add l10n
|
||||
validator: (value) =>
|
||||
value == null ? '请选择提供商' : null, // Placeholder
|
||||
decoration: InputDecoration(
|
||||
// labelText: l10n.providerLabel, // TODO: Add l10n
|
||||
labelText: '提供商', // Placeholder
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: _isLoadingProviders
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: null,
|
||||
),
|
||||
disabledHint: _isEditMode
|
||||
? Text(_selectedProvider ?? '')
|
||||
: null, // Show selected value when disabled
|
||||
style: _isEditMode
|
||||
? TextStyle(color: Theme.of(context).disabledColor)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// --- Model Dropdown ---
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedModel,
|
||||
// hint: Text(l10n.selectModelHint), // TODO: Add l10n
|
||||
hint: const Text('选择模型'), // Placeholder
|
||||
isExpanded: true,
|
||||
onChanged: _isEditMode ||
|
||||
_selectedProvider == null ||
|
||||
_isLoadingModels
|
||||
? null // Cannot change model when editing or provider not selected or loading
|
||||
: (String? newValue) {
|
||||
setState(() {
|
||||
_selectedModel = newValue;
|
||||
});
|
||||
},
|
||||
items: _models.map<DropdownMenuItem<String>>((String value) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: value,
|
||||
child: Text(value, overflow: TextOverflow.ellipsis),
|
||||
);
|
||||
}).toList(),
|
||||
// validator: (value) => value == null ? l10n.modelRequired : null, // TODO: Add l10n
|
||||
validator: (value) =>
|
||||
value == null ? '请选择模型' : null, // Placeholder
|
||||
decoration: InputDecoration(
|
||||
// labelText: l10n.modelLabel, // TODO: Add l10n
|
||||
labelText: '模型', // Placeholder
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: _isLoadingModels
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: null,
|
||||
),
|
||||
disabledHint: _isEditMode
|
||||
? Text(_selectedModel ?? '')
|
||||
: null, // Show selected value when disabled
|
||||
style: _isEditMode
|
||||
? TextStyle(color: Theme.of(context).disabledColor)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// --- Alias ---
|
||||
TextFormField(
|
||||
controller: _aliasController,
|
||||
decoration: InputDecoration(
|
||||
// labelText: l10n.aliasLabel, // TODO: Add l10n
|
||||
labelText: '别名 (可选)', // Placeholder
|
||||
// hintText: l10n.aliasHint( _selectedModel ?? 'model'), // TODO: Add l10n
|
||||
hintText: '例如:我的${_selectedModel ?? '模型'}', // Placeholder
|
||||
border: const OutlineInputBorder()),
|
||||
// No validator, alias is optional or defaults
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// --- API Key ---
|
||||
TextFormField(
|
||||
controller: _apiKeyController,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
// labelText: l10n.apiKeyLabel, // TODO: Add l10n
|
||||
labelText: 'API Key', // Placeholder
|
||||
// hintText: _isEditMode ? l10n.apiKeyEditHint : null, // TODO: Add l10n
|
||||
hintText: _isEditMode ? '留空则不更新' : null, // Placeholder
|
||||
border: const OutlineInputBorder()),
|
||||
validator: (value) {
|
||||
if (!_isEditMode &&
|
||||
(value == null || value.trim().isEmpty)) {
|
||||
// return l10n.apiKeyRequired; // TODO: Add l10n
|
||||
return 'API Key 不能为空'; // Placeholder
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// --- API Endpoint ---
|
||||
TextFormField(
|
||||
controller: _apiEndpointController,
|
||||
decoration: const InputDecoration(
|
||||
// labelText: l10n.apiEndpointLabel, // TODO: Add l10n
|
||||
labelText: 'API Endpoint (可选)', // Placeholder
|
||||
// hintText: l10n.apiEndpointHint, // TODO: Add l10n
|
||||
hintText: '例如: https://api.openai.com/v1', // Placeholder
|
||||
border: OutlineInputBorder()),
|
||||
// No validator, endpoint is optional
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: _isSaving ? null : () => Navigator.of(context).pop(),
|
||||
// child: Text(l10n.cancel), // TODO: Add l10n
|
||||
child: const Text('取消'), // Placeholder
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: _isSaving ? null : _submitForm,
|
||||
child: _isSaving
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white))
|
||||
// : Text(_isEditMode ? l10n.saveChanges : l10n.add), // TODO: Add l10n
|
||||
: Text(_isEditMode ? '保存更改' : '添加'), // Placeholder
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Add localization strings: editConfigTitle, addConfigTitle, selectProviderHint, providerRequired, providerLabel,
|
||||
// selectModelHint, modelRequired, modelLabel, aliasLabel, aliasHint, apiKeyLabel, apiKeyEditHint, apiKeyRequired,
|
||||
// apiEndpointLabel, apiEndpointHint, cancel, saveChanges, add
|
||||
268
AINoval/lib/screens/ai_config/widgets/ai_config_list_item.dart
Normal file
268
AINoval/lib/screens/ai_config/widgets/ai_config_list_item.dart
Normal file
@@ -0,0 +1,268 @@
|
||||
import 'package:ainoval/models/user_ai_model_config_model.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/l10n/app_localizations.dart';
|
||||
import 'package:intl/intl.dart'; // For date formatting
|
||||
|
||||
class AiConfigListItem extends StatelessWidget {
|
||||
// Indicate if an action is pending for this item (optional, for finer control)
|
||||
|
||||
const AiConfigListItem({
|
||||
super.key,
|
||||
required this.config,
|
||||
required this.onEdit,
|
||||
required this.onDelete,
|
||||
required this.onValidate,
|
||||
required this.onSetDefault,
|
||||
this.isLoading = false, // Default to false
|
||||
});
|
||||
final UserAIModelConfigModel config;
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback onValidate;
|
||||
final VoidCallback onSetDefault;
|
||||
final bool isLoading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final disabledColor = theme.disabledColor;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
theme.colorScheme.surface.withAlpha(255),
|
||||
isDark
|
||||
? theme.colorScheme.surfaceContainerHighest.withAlpha(255)
|
||||
: theme.colorScheme.surfaceContainerLowest.withAlpha(255),
|
||||
],
|
||||
),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? Colors.white.withAlpha(13) // 0.05 opacity
|
||||
: Colors.black.withAlpha(13), // 0.05 opacity
|
||||
width: 0.5,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: isDark
|
||||
? Colors.black.withAlpha(51) // 0.2 opacity
|
||||
: Colors.black.withAlpha(13), // 0.05 opacity
|
||||
blurRadius: 8,
|
||||
spreadRadius: 0,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
config.alias,
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold, fontSize: 18),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (config.isDefault)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? Colors.green.withAlpha(51) // 0.2 opacity
|
||||
: Colors.green.shade100,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withAlpha(13), // 0.05 opacity
|
||||
blurRadius: 2,
|
||||
spreadRadius: 0,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Text('默认',
|
||||
style: TextStyle(
|
||||
color: isDark ? Colors.green.shade300 : Colors.green.shade900,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') onEdit();
|
||||
if (value == 'delete') onDelete();
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(value: 'edit', child: Text('编辑')),
|
||||
const PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Text('删除', style: TextStyle(color: Colors.red))),
|
||||
],
|
||||
icon: isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.more_vert),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? theme.colorScheme.surfaceContainerHighest.withAlpha(77) // 0.3 opacity
|
||||
: theme.colorScheme.surfaceContainerLowest.withAlpha(128), // 0.5 opacity
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'${config.provider} / ${config.modelName}',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: isDark
|
||||
? theme.colorScheme.onSurface.withAlpha(230) // 0.9 opacity
|
||||
: theme.colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (config.apiEndpoint != null && config.apiEndpoint!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.link,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurface.withAlpha(128)),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
config.apiEndpoint!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withAlpha(179), // 0.7 opacity
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: config.isValidated
|
||||
? (isDark ? Colors.green.withAlpha(26) : Colors.green.withAlpha(13)) // 0.1/0.05 opacity
|
||||
: (isDark ? Colors.grey.withAlpha(26) : Colors.grey.withAlpha(13)), // 0.1/0.05 opacity
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: config.isValidated
|
||||
? Colors.green.withAlpha(77) // 0.3 opacity
|
||||
: Colors.grey.withAlpha(77), // 0.3 opacity
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
config.isValidated ? Icons.check_circle : Icons.error_outline,
|
||||
color: config.isValidated
|
||||
? Colors.green
|
||||
: Colors.grey,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
config.isValidated
|
||||
? '已验证'
|
||||
: '未验证',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: config.isValidated
|
||||
? Colors.green
|
||||
: Colors.grey,
|
||||
fontStyle: config.isValidated
|
||||
? FontStyle.normal
|
||||
: FontStyle.italic,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.update,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurface.withAlpha(128)),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
DateFormat.yMd().add_jm().format(config.updatedAt.toLocal()),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withAlpha(128)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12.0),
|
||||
child: Divider(height: 1, thickness: 0.5),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (!config.isValidated)
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.sync, size: 16),
|
||||
label: const Text('验证'),
|
||||
onPressed: isLoading ? null : onValidate,
|
||||
style: ElevatedButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.onSecondaryContainer,
|
||||
backgroundColor: theme.colorScheme.secondaryContainer,
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
),
|
||||
if (config.isValidated && !config.isDefault)
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.star_border, size: 16),
|
||||
label: const Text('设为默认'),
|
||||
onPressed: isLoading ? null : onSetDefault,
|
||||
style: ElevatedButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.onPrimaryContainer,
|
||||
backgroundColor: theme.colorScheme.primaryContainer,
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
124
AINoval/lib/screens/ai_config/widgets/ai_model_selector.dart
Normal file
124
AINoval/lib/screens/ai_config/widgets/ai_model_selector.dart
Normal file
@@ -0,0 +1,124 @@
|
||||
import 'package:ainoval/blocs/ai_config/ai_config_bloc.dart';
|
||||
import 'package:ainoval/models/user_ai_model_config_model.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:ainoval/l10n/app_localizations.dart';
|
||||
|
||||
// Callback type when a config is selected
|
||||
typedef AiConfigSelectedCallback = void Function(
|
||||
UserAIModelConfigModel? selectedConfig);
|
||||
|
||||
class AiModelSelector extends StatelessWidget {
|
||||
// Allow pre-selecting a config
|
||||
|
||||
const AiModelSelector({
|
||||
super.key,
|
||||
required this.onConfigSelected,
|
||||
this.initialSelection,
|
||||
});
|
||||
final AiConfigSelectedCallback onConfigSelected;
|
||||
final UserAIModelConfigModel? initialSelection;
|
||||
|
||||
// Helper to find the config by ID in the list
|
||||
UserAIModelConfigModel? _findConfigById(
|
||||
List<UserAIModelConfigModel> configs, String? id) {
|
||||
if (id == null) return null;
|
||||
return configs.firstWhereOrNull((c) => c.id == id);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
// Assume AiConfigBloc is provided higher up the tree
|
||||
return BlocBuilder<AiConfigBloc, AiConfigState>(
|
||||
builder: (context, state) {
|
||||
final validatedConfigs = state.validatedConfigs;
|
||||
// Determine the current selection based on initialSelection or state's default
|
||||
UserAIModelConfigModel? currentSelection =
|
||||
_findConfigById(validatedConfigs, initialSelection?.id) ??
|
||||
state.defaultConfig;
|
||||
|
||||
// Ensure the current selection is actually in the validated list
|
||||
if (currentSelection != null &&
|
||||
!validatedConfigs.any((c) => c.id == currentSelection!.id)) {
|
||||
currentSelection = validatedConfigs.firstWhereOrNull((_) => true);
|
||||
}
|
||||
|
||||
if (state.status == AiConfigStatus.loading &&
|
||||
validatedConfigs.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
);
|
||||
}
|
||||
|
||||
if (validatedConfigs.isEmpty) {
|
||||
return const Tooltip(
|
||||
message: '前往设置添加或验证模型',
|
||||
child: Chip(
|
||||
avatar: Icon(Icons.error_outline, color: Colors.orange),
|
||||
label: Text('无可用模型'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return DropdownButton<UserAIModelConfigModel>(
|
||||
value: currentSelection,
|
||||
hint: const Text('选择AI模型'),
|
||||
underline: Container(),
|
||||
onChanged: (UserAIModelConfigModel? newValue) {
|
||||
onConfigSelected(newValue);
|
||||
},
|
||||
selectedItemBuilder: (BuildContext context) {
|
||||
return validatedConfigs.map<Widget>((UserAIModelConfigModel item) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: Chip(
|
||||
avatar: const Icon(Icons.smart_toy_outlined, size: 16),
|
||||
label: Text(item.alias,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
overflow: TextOverflow.ellipsis),
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
},
|
||||
items: validatedConfigs.map<DropdownMenuItem<UserAIModelConfigModel>>(
|
||||
(UserAIModelConfigModel config) {
|
||||
return DropdownMenuItem<UserAIModelConfigModel>(
|
||||
value: config,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(config.alias),
|
||||
if (config.isDefault)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 8.0),
|
||||
child: Icon(Icons.star, size: 14, color: Colors.amber),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'(${config.provider})',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(color: Colors.grey),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Add localization strings to .arb files:
|
||||
// - manageConfigsTooltip: '前往设置添加或验证模型'
|
||||
// - noValidatedConfigsFound: '无可用模型'
|
||||
// - selectAiModelHint: '选择AI模型'
|
||||
1899
AINoval/lib/screens/auth/enhanced_login_screen.dart
Normal file
1899
AINoval/lib/screens/auth/enhanced_login_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
1105
AINoval/lib/screens/auth/login_screen.dart
Normal file
1105
AINoval/lib/screens/auth/login_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
710
AINoval/lib/screens/chat/chat_screen.dart
Normal file
710
AINoval/lib/screens/chat/chat_screen.dart
Normal file
@@ -0,0 +1,710 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../blocs/chat/chat_bloc.dart';
|
||||
import '../../blocs/chat/chat_event.dart';
|
||||
import '../../blocs/chat/chat_state.dart';
|
||||
import '../../models/chat_models.dart';
|
||||
import '../../models/user_ai_model_config_model.dart';
|
||||
import '../../utils/logger.dart';
|
||||
import '../../widgets/common/top_toast.dart';
|
||||
import 'widgets/chat_input.dart';
|
||||
import 'widgets/chat_message_bubble.dart';
|
||||
import 'widgets/context_panel.dart';
|
||||
import 'widgets/typing_indicator.dart';
|
||||
|
||||
class ChatScreen extends StatefulWidget {
|
||||
const ChatScreen({
|
||||
Key? key,
|
||||
required this.novelId,
|
||||
this.chapterId,
|
||||
}) : super(key: key);
|
||||
final String novelId;
|
||||
final String? chapterId;
|
||||
|
||||
@override
|
||||
State<ChatScreen> createState() => _ChatScreenState();
|
||||
}
|
||||
|
||||
class _ChatScreenState extends State<ChatScreen> {
|
||||
final TextEditingController _messageController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
bool _isContextPanelExpanded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 加载聊天会话列表
|
||||
context.read<ChatBloc>().add(LoadChatSessions(novelId: widget.novelId));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_messageController.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// 滚动到底部
|
||||
void _scrollToBottom() {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
void _sendMessage() {
|
||||
final message = _messageController.text.trim();
|
||||
if (message.isNotEmpty) {
|
||||
context.read<ChatBloc>().add(SendMessage(content: message));
|
||||
_messageController.clear();
|
||||
|
||||
// 延迟滚动到底部,等待消息添加到列表
|
||||
Future.delayed(const Duration(milliseconds: 100), _scrollToBottom);
|
||||
}
|
||||
}
|
||||
|
||||
// 切换上下文面板
|
||||
void _toggleContextPanel() {
|
||||
setState(() {
|
||||
_isContextPanelExpanded = !_isContextPanelExpanded;
|
||||
});
|
||||
}
|
||||
|
||||
// 创建新会话
|
||||
void _createNewSession() {
|
||||
final TextEditingController titleController = TextEditingController();
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('创建新会话'),
|
||||
content: TextField(
|
||||
controller: titleController,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '输入会话标题',
|
||||
),
|
||||
onSubmitted: (value) {
|
||||
if (value.isNotEmpty) {
|
||||
context.read<ChatBloc>().add(CreateChatSession(
|
||||
title: value,
|
||||
novelId: widget.novelId,
|
||||
chapterId: widget.chapterId,
|
||||
));
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
final title = titleController.text.trim();
|
||||
|
||||
if (title.isNotEmpty) {
|
||||
context.read<ChatBloc>().add(CreateChatSession(
|
||||
title: title,
|
||||
novelId: widget.novelId,
|
||||
chapterId: widget.chapterId,
|
||||
));
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: const Text('创建'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 选择会话
|
||||
void _selectSession(String sessionId) {
|
||||
context.read<ChatBloc>().add(SelectChatSession(sessionId: sessionId, novelId: widget.novelId));
|
||||
}
|
||||
|
||||
// 执行操作
|
||||
void _executeAction(MessageAction action) {
|
||||
context.read<ChatBloc>().add(ExecuteAction(action: action));
|
||||
|
||||
// 显示操作执行提示
|
||||
TopToast.info(context, '执行操作: ${action.label}');
|
||||
}
|
||||
|
||||
/// 🚀 检查消息列表中是否有正在流式传输的消息
|
||||
bool _hasStreamingMessage(List<dynamic> messages) {
|
||||
return messages.any((message) => message.status == 'streaming' || message.status?.toString() == 'MessageStatus.streaming');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Scaffold(
|
||||
// 使用 surfaceContainerLow 作为基础背景色
|
||||
backgroundColor: colorScheme.surfaceContainerLow,
|
||||
appBar: AppBar(
|
||||
// AppBar 背景色
|
||||
backgroundColor: colorScheme.surfaceContainer,
|
||||
// 移除默认阴影,让边框控制分割
|
||||
elevation: 0,
|
||||
// 底部边框
|
||||
shape: Border(
|
||||
bottom: BorderSide(
|
||||
color: colorScheme.outlineVariant.withOpacity(0.5),
|
||||
width: 1.0)),
|
||||
title: BlocBuilder<ChatBloc, ChatState>(
|
||||
builder: (context, state) {
|
||||
String titleText = 'AI 聊天助手'; // 默认标题
|
||||
if (state is ChatSessionActive) {
|
||||
titleText = state.session.title; // 活动会话标题
|
||||
} else if (state is ChatSessionsLoaded) {
|
||||
// 可以考虑在列表视图显示不同的标题
|
||||
titleText = '聊天会话';
|
||||
}
|
||||
return Text(
|
||||
titleText,
|
||||
style: TextStyle(
|
||||
// 统一标题样式
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 18,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
centerTitle: false, // 标题居左
|
||||
// AppBar 操作按钮颜色
|
||||
iconTheme: IconThemeData(color: colorScheme.onSurfaceVariant),
|
||||
actionsIconTheme: IconThemeData(color: colorScheme.onSurfaceVariant),
|
||||
actions: [
|
||||
// 新建会话按钮
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_comment_outlined), // 换图标
|
||||
tooltip: '新建会话',
|
||||
onPressed: _createNewSession,
|
||||
),
|
||||
// 上下文面板切换按钮
|
||||
IconButton(
|
||||
// 根据状态改变图标,增加视觉反馈
|
||||
icon: Icon(_isContextPanelExpanded
|
||||
? Icons.info_rounded
|
||||
: Icons.info_outline_rounded),
|
||||
tooltip: _isContextPanelExpanded ? '关闭上下文' : '打开上下文',
|
||||
// 可以根据状态改变颜色
|
||||
color: _isContextPanelExpanded
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
onPressed: _toggleContextPanel,
|
||||
),
|
||||
// 会话列表按钮 (如果希望保留在 AppBar 中)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.menu_open_rounded), // 换图标
|
||||
tooltip: '会话列表',
|
||||
onPressed: _showSessionsDialog,
|
||||
),
|
||||
/* PopupMenuButton<String>( // 或者继续用 PopupMenu
|
||||
icon: const Icon(Icons.more_vert_rounded),
|
||||
onSelected: (value) {
|
||||
if (value == 'sessions') {
|
||||
_showSessionsDialog();
|
||||
}
|
||||
// TODO: 添加其他菜单项,如删除会话、重命名等
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'sessions',
|
||||
child: ListTile(leading: Icon(Icons.list_alt_rounded), title: Text('会话列表')),
|
||||
),
|
||||
// Add other options here...
|
||||
],
|
||||
), */
|
||||
const SizedBox(width: 8), // 右边距
|
||||
],
|
||||
),
|
||||
// 使用 SafeArea 避免内容与系统 UI 重叠
|
||||
body: SafeArea(
|
||||
child: BlocConsumer<ChatBloc, ChatState>(
|
||||
listener: (context, state) {
|
||||
// --- SnackBar 错误提示 (样式不变) ---
|
||||
if (state is ChatSessionsLoaded && state.error != null) {
|
||||
TopToast.error(context, state.error!);
|
||||
}
|
||||
if (state is ChatSessionActive && state.error != null) {
|
||||
TopToast.error(context, state.error!);
|
||||
}
|
||||
// --- 滚动逻辑 ---
|
||||
if (state is ChatSessionActive && !state.isLoadingHistory) {
|
||||
// 当新消息添加或流式更新时,滚动到底部
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollToBottom();
|
||||
});
|
||||
}
|
||||
},
|
||||
// --- buildWhen 优化检查 ---
|
||||
buildWhen: (previous, current) {
|
||||
// 允许从加载状态转换
|
||||
if ((previous is ChatSessionsLoading ||
|
||||
previous is ChatSessionLoading) &&
|
||||
(current is ChatSessionsLoaded ||
|
||||
current is ChatSessionActive)) {
|
||||
return true;
|
||||
}
|
||||
// 允许错误和初始状态
|
||||
if (current is ChatError || current is ChatInitial) return true;
|
||||
// 在 ChatSessionActive 内更新的条件
|
||||
if (previous is ChatSessionActive && current is ChatSessionActive) {
|
||||
return previous.session.id != current.session.id || // 会话切换
|
||||
previous.messages != current.messages || // 消息变化 (浅比较)
|
||||
previous.isGenerating != current.isGenerating ||
|
||||
previous.isLoadingHistory != current.isLoadingHistory ||
|
||||
previous.error != current.error ||
|
||||
previous.selectedModel?.id !=
|
||||
current.selectedModel?.id; // 模型变化
|
||||
}
|
||||
// 在 ChatSessionsLoaded 内更新的条件
|
||||
if (previous is ChatSessionsLoaded &&
|
||||
current is ChatSessionsLoaded) {
|
||||
return previous.sessions != current.sessions || // 列表变化
|
||||
previous.error != current.error;
|
||||
}
|
||||
// 从活动会话返回列表
|
||||
if (previous is ChatSessionActive &&
|
||||
current is ChatSessionsLoaded) {
|
||||
return true;
|
||||
}
|
||||
// 从列表进入活动会话
|
||||
if (previous is ChatSessionsLoaded &&
|
||||
current is ChatSessionActive) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 其他情况,如果类型不同则重建
|
||||
return previous.runtimeType != current.runtimeType;
|
||||
},
|
||||
builder: (context, state) {
|
||||
AppLogger.d('ChatScreen builder',
|
||||
'Building UI for state: ${state.runtimeType}');
|
||||
// --- 加载状态 ---
|
||||
if (state is ChatSessionsLoading || state is ChatSessionLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
// --- 列表或活动会话 ---
|
||||
// 修改:不再直接显示列表,主界面始终是聊天视图
|
||||
// 会话列表通过 AppBar 按钮或侧边栏显示
|
||||
else if (state is ChatSessionActive ||
|
||||
state is ChatSessionsLoaded ||
|
||||
state is ChatInitial) {
|
||||
// 如果当前是列表状态且有会话,可以自动选择第一个或上次的会话
|
||||
// 这里简化处理:如果 state 不是 ChatSessionActive,则显示提示或空状态
|
||||
if (state is ChatSessionActive) {
|
||||
return _buildChatView(state);
|
||||
} else {
|
||||
// 显示初始/空状态视图,提示用户选择或创建会话
|
||||
return _buildInitialEmptyState();
|
||||
}
|
||||
}
|
||||
// (旧的 _buildSessionsList 调用被移除或移到对话框/侧边栏)
|
||||
// else if (state is ChatSessionsLoaded) { ... }
|
||||
|
||||
// --- 错误状态 ---
|
||||
else if (state is ChatError) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
// 改进错误显示
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline_rounded,
|
||||
color: colorScheme.error, size: 48),
|
||||
const SizedBox(height: 16),
|
||||
Text('出现错误',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.copyWith(color: colorScheme.error)),
|
||||
const SizedBox(height: 8),
|
||||
Text(state.message,
|
||||
textAlign: TextAlign.center,
|
||||
style:
|
||||
TextStyle(color: colorScheme.onErrorContainer)),
|
||||
const SizedBox(height: 16),
|
||||
// 可以添加重试按钮
|
||||
/* ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
// 根据错误类型决定重试哪个操作
|
||||
if (state.message.contains("加载会话列表失败")) {
|
||||
context.read<ChatBloc>().add(LoadChatSessions(novelId: widget.novelId));
|
||||
} else if (state.message.contains("加载消息失败")){
|
||||
// 需要知道当前会话 ID 来重试加载消息
|
||||
}
|
||||
},
|
||||
icon: Icon(Icons.refresh_rounded),
|
||||
label: Text("重试"),
|
||||
style: ElevatedButton.styleFrom(foregroundColor: colorScheme.onError, backgroundColor: colorScheme.error),
|
||||
)*/
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
// --- 其他未处理状态 ---
|
||||
else {
|
||||
// 可以返回一个更通用的空状态或加载指示器
|
||||
return _buildInitialEmptyState();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 构建初始空状态视图
|
||||
Widget _buildInitialEmptyState() {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.forum_outlined,
|
||||
size: 64, color: colorScheme.secondary), // 使用不同图标
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'选择或创建会话',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'开始与 AI 助手聊天吧!',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
// 并排显示按钮
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
// 打开列表按钮
|
||||
onPressed: _showSessionsDialog,
|
||||
icon: const Icon(Icons.list_alt_rounded),
|
||||
label: const Text('选择已有对话'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: colorScheme.primary,
|
||||
side: BorderSide(
|
||||
color: colorScheme.outline.withOpacity(0.8)),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 10),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton.icon(
|
||||
// 创建新会话按钮
|
||||
onPressed: _createNewSession,
|
||||
icon: const Icon(Icons.add_comment_outlined),
|
||||
label: const Text('创建新对话'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
backgroundColor: colorScheme.primary,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 10),
|
||||
),
|
||||
),
|
||||
])
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 构建会话列表 - 从主 builder 移出,现在只用于对话框或侧边栏
|
||||
// (这里保留,适配对话框使用)
|
||||
Widget _buildSessionsListForDialog(ChatSessionsLoaded state) {
|
||||
final sessions = state.sessions;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return SizedBox(
|
||||
width: double.maxFinite,
|
||||
// 根据内容调整高度,限制最大高度
|
||||
// height: sessions.isEmpty ? 150 : (sessions.length * 60.0 + (state.error != null ? 40 : 0)).clamp(150.0, 400.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min, // 高度自适应内容
|
||||
children: [
|
||||
// 显示错误
|
||||
if (state.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0, left: 16, right: 16),
|
||||
child: Text(state.error!,
|
||||
style: TextStyle(color: colorScheme.error)),
|
||||
),
|
||||
// 列表或空状态
|
||||
Flexible(
|
||||
// 使用 Flexible 允许列表在 Column 内滚动
|
||||
child: sessions.isEmpty
|
||||
? const Center(
|
||||
child: Padding(
|
||||
// 改进空列表提示
|
||||
padding: EdgeInsets.symmetric(vertical: 32.0),
|
||||
child: Text('没有找到任何对话记录'),
|
||||
))
|
||||
: ListView.builder(
|
||||
shrinkWrap: true, // 在 Column 中需要
|
||||
itemCount: sessions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final session = sessions[index];
|
||||
// 获取当前活动会话 ID
|
||||
String? activeSessionId;
|
||||
final currentState = context.read<ChatBloc>().state;
|
||||
if (currentState is ChatSessionActive) {
|
||||
activeSessionId = currentState.session.id;
|
||||
}
|
||||
final bool isSelected = session.id == activeSessionId;
|
||||
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
// 图标指示
|
||||
isSelected
|
||||
? Icons.chat_bubble_rounded
|
||||
: Icons.chat_bubble_outline_rounded,
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
title: Text(
|
||||
session.title,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal),
|
||||
),
|
||||
subtitle: Text(
|
||||
'更新于: ${DateFormat('yyyy-MM-dd HH:mm').format(session.lastUpdatedAt)}',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant
|
||||
.withOpacity(0.8)),
|
||||
),
|
||||
selected: isSelected,
|
||||
selectedTileColor:
|
||||
colorScheme.primaryContainer.withOpacity(0.1),
|
||||
onTap: () {
|
||||
_selectSession(session.id);
|
||||
Navigator.pop(context); // Close dialog
|
||||
},
|
||||
// 可以添加删除按钮
|
||||
/* trailing: IconButton(
|
||||
icon: Icon(Icons.delete_outline, size: 20, color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
onPressed: () {
|
||||
// TODO: 确认删除逻辑
|
||||
// context.read<ChatBloc>().add(DeleteChatSession(sessionId: session.id));
|
||||
},
|
||||
tooltip: '删除会话',
|
||||
), */
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 构建聊天视图 (样式调整)
|
||||
Widget _buildChatView(ChatSessionActive state) {
|
||||
final UserAIModelConfigModel? currentChatModel = state.selectedModel;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
// 聊天主界面
|
||||
Expanded(
|
||||
// 根据上下文面板状态调整 flex 比例
|
||||
flex: _isContextPanelExpanded ? 3 : 5, // 主聊天区域占比更大
|
||||
// 使用 Container 设置背景色
|
||||
child: Container(
|
||||
color: colorScheme.surface, // 主聊天区域背景色
|
||||
child: Column(
|
||||
children: [
|
||||
// 历史加载指示器(保持不变)
|
||||
if (state.isLoadingHistory)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))),
|
||||
),
|
||||
// 可以考虑在此处显示持久的错误信息(如果不用 SnackBar)
|
||||
/* if (state.error != null && !state.isLoadingHistory)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: colorScheme.errorContainer,
|
||||
child: Row(children: [
|
||||
Icon(Icons.error_outline, color: colorScheme.onErrorContainer, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Expanded(child: Text(state.error!, style: TextStyle(color: colorScheme.onErrorContainer))),
|
||||
]),
|
||||
), */
|
||||
// 消息列表
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
// 增加上下内边距,左右在 Bubble 中处理
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 8.0, horizontal: 16.0),
|
||||
itemCount: state.messages.length +
|
||||
(state.isGenerating && !state.isLoadingHistory && !_hasStreamingMessage(state.messages) ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
// 🚀 只有在没有流式消息且正在生成时才显示TypingIndicator
|
||||
if (state.isGenerating &&
|
||||
!state.isLoadingHistory &&
|
||||
!_hasStreamingMessage(state.messages) &&
|
||||
index == state.messages.length) {
|
||||
return const TypingIndicator();
|
||||
}
|
||||
|
||||
final message = state.messages[index];
|
||||
// 🚀 所有消息都使用ChatMessageBubble,包括streaming状态的消息
|
||||
return ChatMessageBubble(
|
||||
message: message,
|
||||
onActionSelected: _executeAction, // 动作回调
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// 输入区域 (ChatInput 已在上面修改)
|
||||
ChatInput(
|
||||
controller: _messageController,
|
||||
onSend: _sendMessage,
|
||||
isGenerating: state.isGenerating,
|
||||
onCancel: () {
|
||||
context.read<ChatBloc>().add(const CancelOngoingRequest());
|
||||
},
|
||||
initialModel: currentChatModel,
|
||||
onModelSelected: (selectedModel) {
|
||||
if (selectedModel != null &&
|
||||
selectedModel.id != currentChatModel?.id) {
|
||||
context.read<ChatBloc>().add(UpdateChatModel(
|
||||
sessionId: state.session.id,
|
||||
modelConfigId: selectedModel.id,
|
||||
));
|
||||
AppLogger.i('ChatScreen',
|
||||
'Model selected event dispatched: ${selectedModel.id} for session ${state.session.id}');
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 上下文面板 (ContextPanel 已在上面修改)
|
||||
if (_isContextPanelExpanded)
|
||||
Expanded(
|
||||
flex: 2, // 上下文面板 flex 比例
|
||||
child: ContextPanel(
|
||||
context: state.context,
|
||||
onClose: _toggleContextPanel,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 显示会话列表对话框 (样式调整)
|
||||
void _showSessionsDialog() {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
// 对话框样式
|
||||
shape:
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)),
|
||||
backgroundColor: colorScheme.surfaceContainerHigh, // 背景色
|
||||
titlePadding:
|
||||
const EdgeInsets.only(top: 20, left: 24, right: 24, bottom: 10),
|
||||
contentPadding: const EdgeInsets.only(bottom: 8), // 调整内容边距
|
||||
actionsPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
|
||||
title: Text('选择对话',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
|
||||
content: BlocBuilder<ChatBloc, ChatState>(
|
||||
// 监听会话列表相关状态
|
||||
buildWhen: (prev, curr) =>
|
||||
curr is ChatSessionsLoaded ||
|
||||
curr is ChatSessionsLoading ||
|
||||
curr is ChatSessionActive,
|
||||
builder: (context, state) {
|
||||
// 尝试从 Bloc 获取当前的会话列表状态
|
||||
ChatSessionsLoaded? listState;
|
||||
if (state is ChatSessionsLoaded) {
|
||||
listState = state;
|
||||
} else if (state is ChatSessionActive) {
|
||||
// 如果当前是活动会话,也需要显示列表,需要能从ChatBloc获取到完整列表
|
||||
// 这要求 ChatBloc 在 ChatSessionActive 状态下仍然持有 sessions 列表
|
||||
// 或者在这里触发一次 LoadChatSessions (但不推荐,可能导致状态混乱)
|
||||
// 更好的方式是修改 Bloc,使其在 Active 状态下也能提供列表
|
||||
// 暂时假设可以获取到 (如果不行,对话框内容需要调整)
|
||||
// listState = context.read<ChatBloc>().getAllSessionsState(); // 假设有这个方法
|
||||
}
|
||||
|
||||
if (listState != null) {
|
||||
// 使用更新后的列表构建方法
|
||||
return _buildSessionsListForDialog(listState);
|
||||
} else if (state is ChatSessionsLoading) {
|
||||
// 处理加载状态
|
||||
return const SizedBox(
|
||||
height: 150, // 固定高度
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
} else {
|
||||
// 处理其他未能获取列表的状态
|
||||
return const SizedBox(
|
||||
height: 100, child: Center(child: Text('无法加载会话列表')));
|
||||
}
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: colorScheme.onSurfaceVariant),
|
||||
child: const Text('关闭'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context); // 先关闭对话框
|
||||
_createNewSession(); // 再打开创建对话框
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: colorScheme.primary,
|
||||
textStyle: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
child: const Text('新建对话'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
795
AINoval/lib/screens/chat/widgets/ai_chat_sidebar.dart
Normal file
795
AINoval/lib/screens/chat/widgets/ai_chat_sidebar.dart
Normal file
@@ -0,0 +1,795 @@
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:ainoval/widgets/common/top_toast.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:intl/intl.dart'; // 引入 intl 包用于日期格式化
|
||||
|
||||
import '../../../blocs/chat/chat_bloc.dart';
|
||||
import '../../../blocs/chat/chat_event.dart';
|
||||
import '../../../blocs/chat/chat_state.dart';
|
||||
import '../../../blocs/editor/editor_bloc.dart';
|
||||
import '../../../models/user_ai_model_config_model.dart'; // Import the model config
|
||||
import '../../../models/novel_structure.dart';
|
||||
import '../../../models/context_selection_models.dart';
|
||||
import '../../../models/novel_setting_item.dart';
|
||||
import '../../../models/novel_snippet.dart';
|
||||
import '../../../models/setting_group.dart';
|
||||
import 'chat_input.dart'; // 引入 ChatInput
|
||||
import 'chat_message_bubble.dart'; // 引入 ChatMessageBubble
|
||||
// 🚀 移除 TypingIndicator 导入,不再使用单独的等待指示器
|
||||
|
||||
/// AI聊天侧边栏组件,用于在编辑器右侧显示聊天功能
|
||||
class AIChatSidebar extends StatefulWidget {
|
||||
const AIChatSidebar({
|
||||
Key? key,
|
||||
required this.novelId,
|
||||
this.chapterId,
|
||||
this.onClose,
|
||||
this.isCardMode = false,
|
||||
this.editorController, // 🚀 新增:接收EditorScreenController参数
|
||||
}) : super(key: key);
|
||||
|
||||
final String novelId;
|
||||
final String? chapterId;
|
||||
final VoidCallback? onClose;
|
||||
final bool isCardMode; // 是否以卡片模式显示
|
||||
final dynamic editorController; // 🚀 新增:EditorScreenController实例
|
||||
|
||||
@override
|
||||
State<AIChatSidebar> createState() => _AIChatSidebarState();
|
||||
}
|
||||
|
||||
class _AIChatSidebarState extends State<AIChatSidebar> {
|
||||
final TextEditingController _messageController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
// 记录已经完成上下文数据初始化的会话,避免重复检查
|
||||
final Set<String> _contextInitializedSessions = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// --- Add initState Log ---
|
||||
AppLogger.i('AIChatSidebar',
|
||||
'initState called. Widget hash: ${identityHashCode(widget)}, State hash: ${identityHashCode(this)}');
|
||||
// Get the Bloc instance WITHOUT triggering a rebuild if already present
|
||||
final chatBloc = BlocProvider.of<ChatBloc>(context, listen: false);
|
||||
AppLogger.i('AIChatSidebar',
|
||||
'initState: Associated ChatBloc hash: ${identityHashCode(chatBloc)}');
|
||||
// --- End Add Log ---
|
||||
// 每次初始化侧边栏都强制重新加载指定小说的会话列表,防止沿用上一部小说的数据
|
||||
chatBloc.add(LoadChatSessions(novelId: widget.novelId));
|
||||
|
||||
// 同时重新加载上下文数据(设定、片段等)
|
||||
chatBloc.add(LoadContextData(novelId: widget.novelId));
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant AIChatSidebar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// 如果小说发生切换,重新拉取该小说的会话及上下文
|
||||
if (widget.novelId != oldWidget.novelId) {
|
||||
AppLogger.i('AIChatSidebar',
|
||||
'didUpdateWidget: novelId changed from \\${oldWidget.novelId} to \\${widget.novelId}, reloading sessions & context');
|
||||
|
||||
final chatBloc = BlocProvider.of<ChatBloc>(context, listen: false);
|
||||
|
||||
// 重新加载聊天会话列表
|
||||
chatBloc.add(LoadChatSessions(novelId: widget.novelId));
|
||||
|
||||
// 重新加载上下文数据(设定、片段等)
|
||||
chatBloc.add(LoadContextData(novelId: widget.novelId));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// --- Add dispose Log ---
|
||||
AppLogger.w('AIChatSidebar',
|
||||
'dispose() called. Widget hash: ${identityHashCode(widget)}, State hash: ${identityHashCode(this)}');
|
||||
// --- End Add Log ---
|
||||
_messageController.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// 滚动到底部
|
||||
void _scrollToBottom() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
void _sendMessage() {
|
||||
final message = _messageController.text.trim();
|
||||
AppLogger.i('AIChatSidebar', '🚀 _sendMessage被调用,消息内容: "$message"');
|
||||
|
||||
if (message.isNotEmpty) {
|
||||
final chatBloc = context.read<ChatBloc>();
|
||||
final currentState = chatBloc.state;
|
||||
|
||||
AppLogger.i('AIChatSidebar', '🚀 当前ChatBloc状态: ${currentState.runtimeType}');
|
||||
if (currentState is ChatSessionActive) {
|
||||
AppLogger.i('AIChatSidebar', '🚀 当前会话ID: ${currentState.session.id}, isGenerating: ${currentState.isGenerating}');
|
||||
}
|
||||
|
||||
AppLogger.i('AIChatSidebar', '🚀 发送SendMessage事件到ChatBloc,BLoC实例: ${identityHashCode(chatBloc)}, isClosed: ${chatBloc.isClosed}');
|
||||
chatBloc.add(SendMessage(content: message));
|
||||
_messageController.clear();
|
||||
AppLogger.i('AIChatSidebar', '🚀 SendMessage事件已发送,输入框已清空');
|
||||
} else {
|
||||
AppLogger.w('AIChatSidebar', '🚀 消息为空,不发送');
|
||||
}
|
||||
}
|
||||
|
||||
// 选择会话
|
||||
void _selectSession(String sessionId) {
|
||||
context.read<ChatBloc>().add(SelectChatSession(sessionId: sessionId, novelId: widget.novelId));
|
||||
}
|
||||
|
||||
// 创建新会话
|
||||
void _createNewThread() {
|
||||
context.read<ChatBloc>().add(CreateChatSession(
|
||||
title: '新对话 ${DateFormat('MM-dd HH:mm').format(DateTime.now())}',
|
||||
novelId: widget.novelId,
|
||||
chapterId: widget.chapterId,
|
||||
));
|
||||
}
|
||||
|
||||
// 🚀 已移除 _hasStreamingMessage 方法,不再需要检查流式消息
|
||||
|
||||
/// 🚀 构建并更新上下文数据
|
||||
void _buildAndUpdateContextData(Novel novel, ChatSessionActive state) {
|
||||
final novelSettings = state.cachedSettings.cast<NovelSettingItem>();
|
||||
final novelSettingGroups = state.cachedSettingGroups.cast<SettingGroup>();
|
||||
final novelSnippets = state.cachedSnippets.cast<NovelSnippet>();
|
||||
|
||||
AppLogger.i('AIChatSidebar', '🔧 构建上下文数据 - 设定: ${novelSettings.length}, 设定组: ${novelSettingGroups.length}, 片段: ${novelSnippets.length}');
|
||||
|
||||
final newContextData = ContextSelectionDataBuilder.fromNovelWithContext(
|
||||
novel,
|
||||
settings: novelSettings,
|
||||
settingGroups: novelSettingGroups,
|
||||
snippets: novelSnippets,
|
||||
);
|
||||
|
||||
AppLogger.i('AIChatSidebar', '🔧 构建的上下文数据包含 ${newContextData.availableItems.length} 个可用项目');
|
||||
|
||||
// 获取当前会话配置并更新
|
||||
final chatBloc = context.read<ChatBloc>();
|
||||
final currentConfig = chatBloc.getSessionConfig(state.session.id, widget.novelId);
|
||||
|
||||
if (currentConfig != null) {
|
||||
final updatedConfig = currentConfig.copyWith(
|
||||
contextSelections: newContextData,
|
||||
);
|
||||
|
||||
AppLogger.i('AIChatSidebar', '🔧 更新ChatBloc配置,上下文项目: ${newContextData.availableItems.length} → ChatBloc');
|
||||
|
||||
// 使用 Future.microtask 避免在 build 过程中直接调用 add
|
||||
Future.microtask(() {
|
||||
if (mounted) {
|
||||
chatBloc.add(UpdateChatConfiguration(
|
||||
sessionId: state.session.id,
|
||||
config: updatedConfig,
|
||||
));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
AppLogger.w('AIChatSidebar', '🚨 无法更新上下文数据:currentConfig为null,sessionId=${state.session.id}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Log the associated Bloc hash on build too, might be helpful
|
||||
final chatBloc = BlocProvider.of<ChatBloc>(context, listen: false);
|
||||
AppLogger.d('AIChatSidebar',
|
||||
'build called. Associated ChatBloc hash: ${identityHashCode(chatBloc)}');
|
||||
AppLogger.i('Screens/chat/widgets/ai_chat_sidebar',
|
||||
'Building AIChatSidebar widget');
|
||||
return Material(
|
||||
elevation: 4.0,
|
||||
child: Container(
|
||||
// 移除固定宽度,让父组件SizedBox控制宽度
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
child: Column(
|
||||
children: [
|
||||
// 顶部标题栏 - 在卡片模式下隐藏,因为多面板视图有自己的拖拽把手
|
||||
if (!widget.isCardMode)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outlineVariant
|
||||
.withOpacity(0.5),
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: BlocBuilder<ChatBloc, ChatState>(
|
||||
builder: (context, state) {
|
||||
String title = 'AI 聊天助手';
|
||||
if (state is ChatSessionActive) {
|
||||
title = state.session.title;
|
||||
} else if (state is ChatSessionsLoaded) {
|
||||
title = '聊天列表';
|
||||
}
|
||||
return Text(
|
||||
title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
BlocBuilder<ChatBloc, ChatState>(
|
||||
builder: (context, state) {
|
||||
if (state is ChatSessionActive) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.list),
|
||||
tooltip: '返回列表',
|
||||
onPressed: () {
|
||||
context
|
||||
.read<ChatBloc>()
|
||||
.add(LoadChatSessions(novelId: widget.novelId));
|
||||
},
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: widget.onClose,
|
||||
tooltip: '关闭侧边栏',
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 聊天内容区域
|
||||
Expanded(
|
||||
child: BlocConsumer<ChatBloc, ChatState>(
|
||||
listener: (context, state) {
|
||||
// 🚀 当会话激活且有缓存数据时,构建完整的上下文数据(仅限首次)
|
||||
if (state is ChatSessionActive &&
|
||||
!_contextInitializedSessions.contains(state.session.id)) {
|
||||
final editorState = context.read<EditorBloc>().state;
|
||||
if (editorState is EditorLoaded) {
|
||||
final novel = editorState.novel;
|
||||
|
||||
// 检查是否需要构建上下文数据
|
||||
final chatBloc = context.read<ChatBloc>();
|
||||
final currentConfig = chatBloc.getSessionConfig(state.session.id, widget.novelId);
|
||||
|
||||
final hasContextData = state.cachedSettings.isNotEmpty ||
|
||||
state.cachedSettingGroups.isNotEmpty ||
|
||||
state.cachedSnippets.isNotEmpty;
|
||||
final needsContextData =
|
||||
(currentConfig?.contextSelections?.availableItems ?? const []).isEmpty;
|
||||
|
||||
final shouldBuildContext = hasContextData && needsContextData;
|
||||
|
||||
if (shouldBuildContext) {
|
||||
AppLogger.i('AIChatSidebar',
|
||||
'🚀 构建完整的上下文数据,缓存数据: ${state.cachedSettings.length}设定, ${state.cachedSettingGroups.length}组, ${state.cachedSnippets.length}片段');
|
||||
_buildAndUpdateContextData(novel, state);
|
||||
}
|
||||
|
||||
// 无论是否真正构建,只要检查过一次就标记,避免后续重复评估
|
||||
_contextInitializedSessions.add(state.session.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 显示会话加载错误
|
||||
if (state is ChatSessionsLoaded && state.error != null) {
|
||||
TopToast.error(context, state.error!);
|
||||
}
|
||||
// 显示活动会话错误(例如加载历史失败或发送失败后)
|
||||
if (state is ChatSessionActive && state.error != null) {
|
||||
TopToast.error(context, state.error!);
|
||||
}
|
||||
// 滚动到底部逻辑保持不变
|
||||
if (state is ChatSessionActive && !state.isLoadingHistory) {
|
||||
// 仅在历史加载完成后滚动
|
||||
_scrollToBottom();
|
||||
}
|
||||
},
|
||||
// buildWhen 优化:避免不必要的重建,例如仅在关键状态或错误变化时重建
|
||||
buildWhen: (previous, current) {
|
||||
// Always rebuild if state type changed completely
|
||||
if (previous.runtimeType != current.runtimeType) return true;
|
||||
|
||||
// --- ChatSessionActive -> ChatSessionActive ---
|
||||
if (previous is ChatSessionActive && current is ChatSessionActive) {
|
||||
// 1. New / removed message
|
||||
final bool lengthChanged =
|
||||
previous.messages.length != current.messages.length;
|
||||
|
||||
// 2. Generation / loading flag flips
|
||||
final bool flagChanged =
|
||||
previous.isGenerating != current.isGenerating ||
|
||||
previous.isLoadingHistory != current.isLoadingHistory;
|
||||
|
||||
final bool idChanged = previous.session.id != current.session.id;
|
||||
// 3. Severe error / model switch / cached data updates
|
||||
final bool metaChanged = idChanged ||
|
||||
previous.error != current.error ||
|
||||
previous.selectedModel?.id != current.selectedModel?.id ||
|
||||
previous.cachedSettings != current.cachedSettings ||
|
||||
previous.cachedSettingGroups != current.cachedSettingGroups ||
|
||||
previous.cachedSnippets != current.cachedSnippets;
|
||||
|
||||
// NOTE: Streaming content updates keep the list length the same, so
|
||||
// lengthChanged will be false in that situation, effectively
|
||||
// preventing a rebuild on every token.
|
||||
return lengthChanged || flagChanged || metaChanged;
|
||||
}
|
||||
|
||||
// --- ChatSessionsLoaded -> ChatSessionsLoaded ---
|
||||
if (previous is ChatSessionsLoaded && current is ChatSessionsLoaded) {
|
||||
return previous.sessions != current.sessions || previous.error != current.error;
|
||||
}
|
||||
|
||||
// Fallback: rebuild for other transitions we did not explicitly handle
|
||||
return true;
|
||||
},
|
||||
builder: (context, state) {
|
||||
AppLogger.i('Screens/chat/widgets/ai_chat_sidebar',
|
||||
'Building chat UI for state: ${state.runtimeType}');
|
||||
// --- 加载状态处理 ---
|
||||
if (state is ChatSessionsLoading ||
|
||||
state is ChatSessionLoading) {
|
||||
AppLogger.d('AIChatSidebar builder',
|
||||
'State is Loading, showing indicator.');
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
// --- 错误状态处理 ---
|
||||
else if (state is ChatError) {
|
||||
AppLogger.d('AIChatSidebar builder',
|
||||
'State is ChatError, showing error message.');
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text('错误: ${state.message}',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
),
|
||||
);
|
||||
}
|
||||
// --- 会话列表状态 ---
|
||||
else if (state is ChatSessionsLoaded) {
|
||||
AppLogger.d('AIChatSidebar builder',
|
||||
'State is ChatSessionsLoaded with ${state.sessions.length} sessions.');
|
||||
return _buildThreadsList(
|
||||
context, state); // _buildThreadsList 会处理空列表
|
||||
}
|
||||
// --- 活动会话状态 ---
|
||||
else if (state is ChatSessionActive) {
|
||||
AppLogger.d('AIChatSidebar builder',
|
||||
'State is ChatSessionActive. isLoadingHistory: ${state.isLoadingHistory}, isGenerating: ${state.isGenerating}');
|
||||
return _buildChatView(context, state);
|
||||
}
|
||||
// --- 初始或其他状态 ---
|
||||
else {
|
||||
AppLogger.d('AIChatSidebar builder',
|
||||
'State is Initial or unexpected, showing empty state.');
|
||||
// 初始状态可以显示空状态或者加载列表
|
||||
// context.read<ChatBloc>().add(LoadChatSessions(novelId: widget.novelId)); // 如果希望初始时自动加载
|
||||
return _buildEmptyState(); // 或者 return const Center(child: CircularProgressIndicator()); 看设计需求
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 构建空状态
|
||||
Widget _buildEmptyState() {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.chat_bubble_outline,
|
||||
size: 56, color: Theme.of(context).colorScheme.secondary),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'开始一个新的对话',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'与AI助手交流,获取写作灵感、建议或进行头脑风暴',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _createNewThread,
|
||||
icon: const Icon(Icons.add_comment_outlined),
|
||||
label: const Text('新建对话'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
textStyle: Theme.of(context)
|
||||
.textTheme
|
||||
.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
foregroundColor: Theme.of(context).colorScheme.onPrimary,
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 构建会话列表
|
||||
Widget _buildThreadsList(BuildContext context, ChatSessionsLoaded state) {
|
||||
// 现在接收整个 state 以便访问 error
|
||||
final sessions = state.sessions;
|
||||
|
||||
if (sessions.isEmpty) {
|
||||
// 即使列表为空,也不显示加载,显示空状态
|
||||
return _buildEmptyState();
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
// 新建对话按钮
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _createNewThread,
|
||||
icon: const Icon(Icons.add_comment_outlined),
|
||||
label: const Text('新建对话'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(44),
|
||||
foregroundColor: Theme.of(context).colorScheme.primary,
|
||||
side: BorderSide(
|
||||
color:
|
||||
Theme.of(context).colorScheme.outline.withOpacity(0.8)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8)),
|
||||
textStyle: Theme.of(context)
|
||||
.textTheme
|
||||
.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1, thickness: 1, indent: 16, endIndent: 16),
|
||||
// 列表视图
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
itemCount: sessions.length,
|
||||
separatorBuilder: (context, index) => Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: 16,
|
||||
endIndent: 16,
|
||||
color:
|
||||
Theme.of(context).colorScheme.outlineVariant.withOpacity(0.3),
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final session = sessions[index];
|
||||
// 获取当前活动会话 ID (需要 ChatBloc 的状态信息,这里假设可以从 context 获取)
|
||||
String? activeSessionId;
|
||||
final currentState = context.read<ChatBloc>().state;
|
||||
if (currentState is ChatSessionActive) {
|
||||
activeSessionId = currentState.session.id;
|
||||
}
|
||||
final bool isSelected = session.id == activeSessionId;
|
||||
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
isSelected ? Icons.chat_bubble : Icons.chat_bubble_outline,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
title: Text(
|
||||
session.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.w500,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'最后更新: ${DateFormat('MM-dd HH:mm').format(session.lastUpdatedAt)}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant
|
||||
.withOpacity(0.8),
|
||||
),
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: Icon(Icons.delete_outline,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant, size: 20),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text('确认删除'),
|
||||
content:
|
||||
Text('确定要删除会话 "${session.title}" 吗?此操作无法撤销。'),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
child: const Text('取消'),
|
||||
onPressed: () {
|
||||
Navigator.of(dialogContext).pop();
|
||||
},
|
||||
),
|
||||
TextButton(
|
||||
child: Text('删除',
|
||||
style: TextStyle(
|
||||
color:
|
||||
Theme.of(context).colorScheme.error)),
|
||||
onPressed: () {
|
||||
context.read<ChatBloc>().add(
|
||||
DeleteChatSession(sessionId: session.id));
|
||||
Navigator.of(dialogContext).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
tooltip: '删除会话',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
selected: isSelected,
|
||||
selectedTileColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.primaryContainer
|
||||
.withOpacity(0.1),
|
||||
onTap: () => _selectSession(session.id),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 构建聊天视图
|
||||
Widget _buildChatView(BuildContext context, ChatSessionActive state) {
|
||||
// --- 获取当前会话选择的模型 ---
|
||||
// 现在可以直接从 state 获取 selectedModel
|
||||
final UserAIModelConfigModel? currentChatModel = state.selectedModel;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// 在卡片模式下显示简洁的返回按钮
|
||||
if (widget.isCardMode)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer.withValues(alpha: 0.5),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back, size: 18),
|
||||
tooltip: '返回列表',
|
||||
onPressed: () {
|
||||
context.read<ChatBloc>().add(LoadChatSessions(novelId: widget.novelId));
|
||||
},
|
||||
padding: const EdgeInsets.all(4),
|
||||
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
state.session.title,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 显示历史加载指示器
|
||||
if (state.isLoadingHistory)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))),
|
||||
),
|
||||
// 显示加载历史或发送消息时的错误信息(如果需要更持久的提示)
|
||||
// if (state.error != null)
|
||||
// Padding(
|
||||
// padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
|
||||
// child: Text(state.error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
// ),
|
||||
Expanded(
|
||||
child: ChatMessagesList(scrollController: _scrollController),
|
||||
),
|
||||
// ChatInput 背景应与聊天视图背景一致或略有区分
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: BlocBuilder<EditorBloc, EditorState>(
|
||||
builder: (context, editorState) {
|
||||
Novel? novel;
|
||||
if (editorState is EditorLoaded) {
|
||||
novel = editorState.novel;
|
||||
}
|
||||
|
||||
// 🚀 使用BlocBuilder获取当前会话的配置
|
||||
return BlocBuilder<ChatBloc, ChatState>(
|
||||
buildWhen: (previous, current) {
|
||||
// 只有当与当前会话相关的配置发生实际变化时才重建,避免流式 token 触发
|
||||
if (previous is ChatSessionActive && current is ChatSessionActive) {
|
||||
// 不同会话 → 必须重建
|
||||
if (previous.session.id != current.session.id) return true;
|
||||
|
||||
// ChatBloc 在更新配置(模型或上下文)时会带上 configUpdateTimestamp
|
||||
if (previous.configUpdateTimestamp != current.configUpdateTimestamp) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false; // 同会话且配置没变 → 不重建
|
||||
}
|
||||
|
||||
// 其它类型转变,例如从活动回到列表或错误,再由父 BlocConsumer 处理
|
||||
return false;
|
||||
},
|
||||
builder: (context, chatState) {
|
||||
final chatBloc = context.read<ChatBloc>();
|
||||
final currentConfig = chatBloc.getSessionConfig(state.session.id, widget.novelId);
|
||||
|
||||
// 配置获取完成
|
||||
|
||||
return ChatInput(
|
||||
key: ValueKey('chat_input_${state.session.id}_${currentConfig?.contextSelections?.selectedCount ?? 0}'), // 🚀 添加key确保Widget正确更新
|
||||
controller: _messageController,
|
||||
onSend: _sendMessage,
|
||||
isGenerating: state.isGenerating,
|
||||
onCancel: () {
|
||||
context.read<ChatBloc>().add(const CancelOngoingRequest());
|
||||
},
|
||||
initialModel: currentChatModel,
|
||||
novel: novel, // 传入从EditorBloc获取的novel数据
|
||||
contextData: widget.editorController?.cascadeMenuData, // 🚀 使用EditorScreenController维护的级联菜单数据(死的结构)
|
||||
onContextChanged: (newContextData) {
|
||||
// 🚀 如果需要通知EditorScreenController级联菜单数据变化,可以在这里处理
|
||||
// 但通常不需要,因为EditorScreenController维护的是结构数据,不是选择状态
|
||||
print('🔧 [AIChatSidebar] 级联菜单数据变化通知: ${newContextData.selectedCount}个选择');
|
||||
},
|
||||
settings: state.cachedSettings.cast<NovelSettingItem>(),
|
||||
settingGroups: state.cachedSettingGroups.cast<SettingGroup>(),
|
||||
snippets: state.cachedSnippets.cast<NovelSnippet>(),
|
||||
// 🚀 添加聊天配置支持,确保设置对话框能够同步
|
||||
chatConfig: currentConfig,
|
||||
onConfigChanged: (updatedConfig) {
|
||||
print('🔧 [AIChatSidebar] 聊天配置已更新,发送到ChatBloc');
|
||||
print('🔧 [AIChatSidebar] 更新后配置上下文: ${updatedConfig.contextSelections?.selectedCount ?? 0}');
|
||||
|
||||
// 发送配置更新事件到ChatBloc
|
||||
context.read<ChatBloc>().add(UpdateChatConfiguration(
|
||||
sessionId: state.session.id,
|
||||
config: updatedConfig,
|
||||
));
|
||||
},
|
||||
// 🚀 初始定位到当前章节/场景
|
||||
initialChapterId: widget.chapterId,
|
||||
initialSceneId: null,
|
||||
onModelSelected: (selectedModel) {
|
||||
if (selectedModel != null &&
|
||||
selectedModel.id != currentChatModel?.id) {
|
||||
// 使用正确的事件类
|
||||
context.read<ChatBloc>().add(UpdateChatModel(
|
||||
sessionId: state.session.id,
|
||||
modelConfigId: selectedModel.id,
|
||||
));
|
||||
AppLogger.i('AIChatSidebar',
|
||||
'Model selected event dispatched: ${selectedModel.id} for session ${state.session.id}');
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChatMessagesList extends StatelessWidget {
|
||||
final ScrollController scrollController;
|
||||
const ChatMessagesList({super.key, required this.scrollController});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<ChatBloc, ChatState>(
|
||||
buildWhen: (previous, current) {
|
||||
if (previous is ChatSessionActive && current is ChatSessionActive) {
|
||||
// 仅当消息列表实例或长度发生变化时重建,实现流式刷新
|
||||
return previous.messages != current.messages;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state is! ChatSessionActive) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final messages = state.messages;
|
||||
return Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: ListView.builder(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messages[index];
|
||||
return ChatMessageBubble(
|
||||
message: message,
|
||||
onActionSelected: (action) {
|
||||
context.read<ChatBloc>().add(ExecuteAction(action: action));
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
953
AINoval/lib/screens/chat/widgets/chat_input.dart
Normal file
953
AINoval/lib/screens/chat/widgets/chat_input.dart
Normal file
@@ -0,0 +1,953 @@
|
||||
import 'package:ainoval/models/user_ai_model_config_model.dart';
|
||||
import 'package:ainoval/models/context_selection_models.dart';
|
||||
import 'package:ainoval/models/novel_setting_item.dart';
|
||||
import 'package:ainoval/models/setting_group.dart';
|
||||
import 'package:ainoval/models/novel_snippet.dart';
|
||||
import 'package:ainoval/models/ai_request_models.dart';
|
||||
import 'package:ainoval/models/preset_models.dart';
|
||||
import 'package:ainoval/models/unified_ai_model.dart';
|
||||
import 'package:ainoval/services/ai_preset_service.dart';
|
||||
import 'package:ainoval/config/app_config.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:ainoval/widgets/common/top_toast.dart';
|
||||
import 'package:ainoval/widgets/common/model_display_selector.dart';
|
||||
import 'package:ainoval/widgets/common/context_selection_dropdown_menu_anchor.dart';
|
||||
import 'package:ainoval/widgets/common/credit_display.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
// import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class ChatInput extends StatefulWidget {
|
||||
const ChatInput({
|
||||
Key? key,
|
||||
required this.controller,
|
||||
required this.onSend,
|
||||
this.isGenerating = false,
|
||||
this.onCancel,
|
||||
this.onModelSelected,
|
||||
this.initialModel,
|
||||
this.novel,
|
||||
this.contextData,
|
||||
this.onContextChanged,
|
||||
this.settings = const [],
|
||||
this.settingGroups = const [],
|
||||
this.snippets = const [],
|
||||
this.chatConfig,
|
||||
this.onConfigChanged,
|
||||
this.onCreditError, // 🚀 新增:积分不足错误回调
|
||||
this.initialChapterId,
|
||||
this.initialSceneId,
|
||||
}) : super(key: key);
|
||||
|
||||
final TextEditingController controller;
|
||||
final VoidCallback onSend;
|
||||
final Function(String)? onCreditError; // 🚀 新增:积分不足错误回调
|
||||
final bool isGenerating;
|
||||
final VoidCallback? onCancel;
|
||||
final Function(UserAIModelConfigModel?)? onModelSelected;
|
||||
final UserAIModelConfigModel? initialModel;
|
||||
final dynamic novel;
|
||||
final ContextSelectionData? contextData;
|
||||
final ValueChanged<ContextSelectionData>? onContextChanged;
|
||||
final List<NovelSettingItem> settings;
|
||||
final List<SettingGroup> settingGroups;
|
||||
final List<NovelSnippet> snippets;
|
||||
final UniversalAIRequest? chatConfig;
|
||||
final ValueChanged<UniversalAIRequest>? onConfigChanged;
|
||||
final String? initialChapterId;
|
||||
final String? initialSceneId;
|
||||
|
||||
@override
|
||||
State<ChatInput> createState() => _ChatInputState();
|
||||
}
|
||||
|
||||
class _ChatInputState extends State<ChatInput> {
|
||||
OverlayEntry? _presetOverlay;
|
||||
final LayerLink _layerLink = LayerLink();
|
||||
bool _isComposing = false;
|
||||
|
||||
// 预设相关状态
|
||||
// final GlobalKey _presetButtonKey = GlobalKey();
|
||||
List<AIPromptPreset> _availablePresets = [];
|
||||
bool _isLoadingPresets = false;
|
||||
AIPromptPreset? _currentPreset;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.controller.addListener(_handleTextChange);
|
||||
_handleTextChange();
|
||||
_loadPresets();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_handleTextChange);
|
||||
_removePresetOverlay();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 加载预设数据
|
||||
Future<void> _loadPresets() async {
|
||||
if (_isLoadingPresets) return;
|
||||
|
||||
setState(() {
|
||||
_isLoadingPresets = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final presetService = AIPresetService();
|
||||
|
||||
// 直接获取AI_CHAT类型的预设
|
||||
final chatPresets = await presetService.getUserPresets(featureType: 'AI_CHAT');
|
||||
|
||||
setState(() {
|
||||
_availablePresets = chatPresets;
|
||||
_isLoadingPresets = false;
|
||||
});
|
||||
|
||||
AppLogger.i('ChatInput', '加载了 ${_availablePresets.length} 个聊天预设');
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoadingPresets = false;
|
||||
});
|
||||
AppLogger.e('ChatInput', '加载预设失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTextChange() {
|
||||
final bool composingNow = widget.controller.text.trim().isNotEmpty;
|
||||
if (composingNow != _isComposing) {
|
||||
// 只有从空 → 非空 或 非空 → 空 时才重建,避免输入过程中频繁 setState
|
||||
setState(() {
|
||||
_isComposing = composingNow;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 显示预设下拉菜单
|
||||
void _showPresetOverlay() {
|
||||
if (_presetOverlay != null) {
|
||||
_removePresetOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
_presetOverlay = OverlayEntry(
|
||||
builder: (context) => Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: _removePresetOverlay,
|
||||
child: Container(color: Colors.transparent),
|
||||
),
|
||||
),
|
||||
CompositedTransformFollower(
|
||||
link: _layerLink,
|
||||
showWhenUnlinked: false,
|
||||
targetAnchor: Alignment.topRight,
|
||||
followerAnchor: Alignment.bottomRight,
|
||||
offset: const Offset(0, -8),
|
||||
child: Material(
|
||||
elevation: 8,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
shadowColor: WebTheme.getShadowColor(context, opacity: 0.15),
|
||||
child: Container(
|
||||
width: 240,
|
||||
constraints: const BoxConstraints(maxHeight: 320),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outlineVariant.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: _buildPresetMenuContent(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Overlay.of(context).insert(_presetOverlay!);
|
||||
}
|
||||
|
||||
/// 移除预设下拉菜单
|
||||
void _removePresetOverlay() {
|
||||
_presetOverlay?.remove();
|
||||
_presetOverlay = null;
|
||||
}
|
||||
|
||||
/// 构建预设菜单内容
|
||||
Widget _buildPresetMenuContent() {
|
||||
if (_isLoadingPresets) {
|
||||
return Container(
|
||||
height: 120,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'加载预设中...',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_availablePresets.isEmpty) {
|
||||
return Container(
|
||||
height: 120,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.auto_awesome_outlined,
|
||||
size: 32,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'暂无可用预设',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'可在设置中创建预设',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 对预设进行分组
|
||||
final Map<String, List<AIPromptPreset>> groupedPresets = {
|
||||
'最近使用': _availablePresets.where((p) => p.lastUsedAt != null).take(3).toList(),
|
||||
'收藏预设': _availablePresets.where((p) => p.isFavorite).toList(),
|
||||
'所有预设': _availablePresets,
|
||||
};
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(8),
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
// 标题
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.auto_awesome,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'快速预设',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(height: 1),
|
||||
|
||||
// 预设分组列表
|
||||
...groupedPresets.entries.where((entry) => entry.value.isNotEmpty).map((entry) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (entry.key != '所有预设' || (entry.key == '所有预设' && groupedPresets['最近使用']!.isEmpty && groupedPresets['收藏预设']!.isEmpty))
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
|
||||
child: Text(
|
||||
entry.key,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
...entry.value.map((preset) => _buildPresetMenuItem(preset)).toList(),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建预设菜单项
|
||||
Widget _buildPresetMenuItem(AIPromptPreset preset) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isSelected = _currentPreset?.presetId == preset.presetId;
|
||||
|
||||
return InkWell(
|
||||
onTap: () => _handlePresetSelected(preset),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? colorScheme.primaryContainer.withOpacity(0.3) : null,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 预设图标
|
||||
Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.auto_awesome,
|
||||
size: 12,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// 预设信息
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
preset.displayName,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
color: isSelected ? colorScheme.primary : colorScheme.onSurface,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (preset.isFavorite) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.star,
|
||||
size: 10,
|
||||
color: Colors.amber.shade600,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (preset.presetDescription != null && preset.presetDescription!.isNotEmpty)
|
||||
Text(
|
||||
preset.presetDescription!,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: colorScheme.onSurfaceVariant.withOpacity(0.7),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 选中标识
|
||||
if (isSelected)
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
size: 14,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 处理预设选择
|
||||
void _handlePresetSelected(AIPromptPreset preset) {
|
||||
_removePresetOverlay();
|
||||
|
||||
try {
|
||||
setState(() {
|
||||
_currentPreset = preset;
|
||||
});
|
||||
|
||||
// 解析预设并应用到聊天配置
|
||||
final parsedRequest = preset.parsedRequest;
|
||||
if (parsedRequest != null && widget.onConfigChanged != null) {
|
||||
// 创建新的配置,保留现有的基础信息
|
||||
final baseConfig = widget.chatConfig ?? UniversalAIRequest(
|
||||
requestType: AIRequestType.chat,
|
||||
userId: AppConfig.userId ?? 'unknown',
|
||||
novelId: widget.novel?.id,
|
||||
);
|
||||
|
||||
// 应用预设配置
|
||||
final updatedConfig = baseConfig.copyWith(
|
||||
modelConfig: parsedRequest.modelConfig ?? baseConfig.modelConfig,
|
||||
instructions: parsedRequest.instructions?.isNotEmpty == true
|
||||
? parsedRequest.instructions
|
||||
: preset.effectiveUserPrompt.isNotEmpty ? preset.effectiveUserPrompt : null,
|
||||
contextSelections: parsedRequest.contextSelections ?? baseConfig.contextSelections,
|
||||
enableSmartContext: parsedRequest.enableSmartContext,
|
||||
parameters: {
|
||||
...baseConfig.parameters,
|
||||
...parsedRequest.parameters,
|
||||
},
|
||||
metadata: {
|
||||
...baseConfig.metadata,
|
||||
'appliedPreset': preset.presetId,
|
||||
'presetName': preset.presetName,
|
||||
'lastPresetApplied': DateTime.now().toIso8601String(),
|
||||
},
|
||||
);
|
||||
|
||||
widget.onConfigChanged!(updatedConfig);
|
||||
|
||||
// 如果预设包含模型配置,也要通知模型选择器
|
||||
if (parsedRequest.modelConfig != null) {
|
||||
widget.onModelSelected?.call(parsedRequest.modelConfig);
|
||||
}
|
||||
|
||||
AppLogger.i('ChatInput', '预设已应用: ${preset.displayName}');
|
||||
|
||||
// 记录预设使用
|
||||
AIPresetService().applyPreset(preset.presetId);
|
||||
|
||||
// 显示成功提示
|
||||
TopToast.success(context, '已应用预设: ${preset.displayName}');
|
||||
} else {
|
||||
AppLogger.w('ChatInput', '预设解析失败或缺少配置变更回调');
|
||||
TopToast.error(context, '应用预设失败');
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.e('ChatInput', '应用预设失败', e);
|
||||
TopToast.error(context, '应用预设失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _updateContextData(ContextSelectionData newData, {bool isAddOperation = true}) {
|
||||
if (widget.onConfigChanged != null) {
|
||||
if (widget.chatConfig != null) {
|
||||
// 🚀 修复:使用完整的菜单结构而不是可能不完整的currentSelections
|
||||
final currentSelections = widget.chatConfig!.contextSelections;
|
||||
|
||||
// 🚀 获取完整的菜单结构数据
|
||||
ContextSelectionData? fullContextData;
|
||||
if (widget.contextData != null) {
|
||||
fullContextData = widget.contextData;
|
||||
} else if (widget.novel != null) {
|
||||
fullContextData = ContextSelectionDataBuilder.fromNovelWithContext(
|
||||
widget.novel!,
|
||||
settings: widget.settings,
|
||||
settingGroups: widget.settingGroups,
|
||||
snippets: widget.snippets,
|
||||
);
|
||||
}
|
||||
|
||||
if (fullContextData != null) {
|
||||
ContextSelectionData updatedSelections;
|
||||
|
||||
if (isAddOperation && currentSelections != null) {
|
||||
// 🚀 添加操作:将现有选择应用到完整结构,然后添加新选择
|
||||
// 先应用现有选择到完整结构
|
||||
updatedSelections = fullContextData.applyPresetSelections(currentSelections);
|
||||
|
||||
// 再添加新选择的项目
|
||||
for (final newItem in newData.selectedItems.values) {
|
||||
if (!updatedSelections.selectedItems.containsKey(newItem.id)) {
|
||||
updatedSelections = updatedSelections.selectItem(newItem.id);
|
||||
}
|
||||
}
|
||||
} else if (!isAddOperation && currentSelections != null) {
|
||||
// 🚀 删除操作:将现有选择应用到完整结构,然后移除指定项目
|
||||
updatedSelections = fullContextData.applyPresetSelections(currentSelections);
|
||||
|
||||
// 找出被删除的项目并移除
|
||||
for (final existingId in currentSelections.selectedItems.keys) {
|
||||
if (!newData.selectedItems.containsKey(existingId)) {
|
||||
updatedSelections = updatedSelections.deselectItem(existingId);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 🚀 如果当前没有选择,直接使用新数据(但保持完整结构)
|
||||
updatedSelections = fullContextData;
|
||||
for (final newItem in newData.selectedItems.values) {
|
||||
updatedSelections = updatedSelections.selectItem(newItem.id);
|
||||
}
|
||||
}
|
||||
|
||||
final updatedConfig = widget.chatConfig!.copyWith(
|
||||
contextSelections: updatedSelections,
|
||||
);
|
||||
widget.onConfigChanged!(updatedConfig);
|
||||
} else {
|
||||
// 如果无法获取完整菜单结构,回退到原来的逻辑
|
||||
final updatedConfig = widget.chatConfig!.copyWith(
|
||||
contextSelections: newData,
|
||||
);
|
||||
widget.onConfigChanged!(updatedConfig);
|
||||
}
|
||||
} else {
|
||||
// 如果没有chatConfig,创建一个基础配置
|
||||
final newConfig = UniversalAIRequest(
|
||||
requestType: AIRequestType.chat,
|
||||
userId: 'unknown', // 这应该从某个地方获取
|
||||
novelId: widget.novel?.id,
|
||||
contextSelections: newData,
|
||||
);
|
||||
widget.onConfigChanged!(newConfig);
|
||||
}
|
||||
} else {
|
||||
// 🚀 如果没有onConfigChanged回调,则使用传统的onContextChanged
|
||||
widget.onContextChanged?.call(newData);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final bool canSend = _isComposing && !widget.isGenerating;
|
||||
|
||||
ContextSelectionData? currentContextData;
|
||||
|
||||
if (widget.contextData != null) {
|
||||
// 🚀 使用EditorScreenController维护的级联菜单数据(静态结构)
|
||||
currentContextData = widget.contextData;
|
||||
} else if (widget.novel != null) {
|
||||
// 备用方案:如果EditorScreenController还没有准备好数据,则临时构建
|
||||
currentContextData = ContextSelectionDataBuilder.fromNovelWithContext(
|
||||
widget.novel!,
|
||||
settings: widget.settings,
|
||||
settingGroups: widget.settingGroups,
|
||||
snippets: widget.snippets,
|
||||
);
|
||||
}
|
||||
|
||||
// final contextSelectionCount = widget.chatConfig?.contextSelections?.selectedCount ?? 0;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: colorScheme.outlineVariant.withOpacity(0.5),
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 10.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 上下文选择区域 - 始终显示,以便用户可以点击添加
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: colorScheme.outline.withOpacity(0.1),
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center, // 垂直居中对齐
|
||||
children: [
|
||||
// 使用完整的上下文选择组件 - 包含完整的级联菜单
|
||||
if (currentContextData != null)
|
||||
ContextSelectionDropdownBuilder.buildMenuAnchor(
|
||||
data: currentContextData,
|
||||
onSelectionChanged: _updateContextData,
|
||||
placeholder: '+ Context',
|
||||
maxHeight: 400,
|
||||
initialChapterId: widget.initialChapterId,
|
||||
initialSceneId: widget.initialSceneId,
|
||||
)
|
||||
else
|
||||
// 当没有数据时显示占位符
|
||||
Container(
|
||||
height: 36,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: colorScheme.outline.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.pending_outlined,
|
||||
size: 16,
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'等待级联菜单数据...',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 🚀 修复:使用完整菜单结构中的已选择项目显示标签
|
||||
if (currentContextData != null && widget.chatConfig?.contextSelections != null)
|
||||
..._buildSelectedContextTags(currentContextData, widget.chatConfig!.contextSelections!).map((item) {
|
||||
return Container(
|
||||
height: 36,
|
||||
constraints: const BoxConstraints(maxWidth: 200),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withOpacity(0.75),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: colorScheme.outline.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
item.type.icon,
|
||||
size: 16,
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
item.title,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurface,
|
||||
height: 1.2,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (item.displaySubtitle.isNotEmpty)
|
||||
Text(
|
||||
item.displaySubtitle,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
height: 1.2,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
// 🚀 修复:使用完整菜单结构进行删除操作
|
||||
if (currentContextData != null && widget.chatConfig!.contextSelections != null) {
|
||||
// 将当前选择应用到完整结构,然后删除指定项目
|
||||
final fullDataWithSelections = currentContextData.applyPresetSelections(widget.chatConfig!.contextSelections!);
|
||||
final newData = fullDataWithSelections.deselectItem(item.id);
|
||||
_updateContextData(newData, isAddOperation: false);
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: Icon(
|
||||
Icons.close,
|
||||
size: 14,
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8.0),
|
||||
// 输入框行 - 独占一行,去掉圆角,紧贴边缘
|
||||
Container(
|
||||
width: double.infinity,
|
||||
child: TextField(
|
||||
controller: widget.controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.isGenerating ? 'AI 正在回复...' : '输入消息...',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(0), // 去掉圆角
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.outline.withOpacity(0.5)),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(0), // 去掉圆角
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.outline.withOpacity(0.3)),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(0), // 去掉圆角
|
||||
borderSide:
|
||||
BorderSide(color: colorScheme.primary, width: 1.5),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 12), // 增加垂直内边距
|
||||
isDense: false, // 改为false以获得更多空间
|
||||
disabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(0), // 去掉圆角
|
||||
borderSide: BorderSide(color: Theme.of(context).colorScheme.outline.withOpacity(0.3)),
|
||||
),
|
||||
),
|
||||
readOnly: widget.isGenerating,
|
||||
minLines: 1,
|
||||
maxLines: 5,
|
||||
textInputAction: TextInputAction.newline,
|
||||
style: TextStyle(fontSize: 14, color: colorScheme.onSurface),
|
||||
onSubmitted: (_) {
|
||||
if (canSend) {
|
||||
widget.onSend();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8.0),
|
||||
// 预设按钮、积分显示、模型选择器和发送按钮行
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// 预设快捷按钮 - 使用PopupMenuButton实现精准定位
|
||||
CompositedTransformTarget(
|
||||
link: _layerLink,
|
||||
child: GestureDetector(
|
||||
onTap: _showPresetOverlay,
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 36, // 与模型选择器保持一致的高度
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).brightness == Brightness.dark
|
||||
? Theme.of(context).colorScheme.surfaceContainerHighest // 深色容器
|
||||
: Theme.of(context).colorScheme.surface, // 浅色容器
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.4),
|
||||
width: 1.0,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20), // rounded-full
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: WebTheme.getShadowColor(context, opacity: 0.1),
|
||||
blurRadius: 1,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: _showPresetOverlay,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
hoverColor: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.8),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 36,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.auto_awesome,
|
||||
size: 16,
|
||||
color: _currentPreset != null
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// 🚀 积分显示组件
|
||||
const CreditDisplay(
|
||||
size: CreditDisplaySize.small,
|
||||
showRefreshButton: false,
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// 模型选择按钮 - 使用统一的显示/选择组件
|
||||
Expanded(
|
||||
child: ModelDisplaySelector(
|
||||
selectedModel: widget.initialModel != null ? PrivateAIModel(widget.initialModel!) : null,
|
||||
onModelSelected: (unifiedModel) {
|
||||
// 将UnifiedAIModel转换为UserAIModelConfigModel以保持兼容性
|
||||
UserAIModelConfigModel? compatModel;
|
||||
if (unifiedModel != null) {
|
||||
if (unifiedModel.isPublic) {
|
||||
final publicModel = (unifiedModel as PublicAIModel).publicConfig;
|
||||
compatModel = UserAIModelConfigModel.fromJson({
|
||||
'id': 'public_${publicModel.id}',
|
||||
'userId': AppConfig.userId ?? 'unknown',
|
||||
'alias': publicModel.displayName,
|
||||
'modelName': publicModel.modelId,
|
||||
'provider': publicModel.provider,
|
||||
'apiEndpoint': '',
|
||||
'isDefault': false,
|
||||
'isValidated': true,
|
||||
'createdAt': DateTime.now().toIso8601String(),
|
||||
'updatedAt': DateTime.now().toIso8601String(),
|
||||
});
|
||||
} else {
|
||||
compatModel = (unifiedModel as PrivateAIModel).userConfig;
|
||||
}
|
||||
}
|
||||
widget.onModelSelected?.call(compatModel);
|
||||
},
|
||||
chatConfig: widget.chatConfig,
|
||||
onConfigChanged: widget.onConfigChanged,
|
||||
novel: widget.novel,
|
||||
settings: widget.settings,
|
||||
settingGroups: widget.settingGroups,
|
||||
snippets: widget.snippets,
|
||||
size: ModelDisplaySize.medium,
|
||||
showIcon: true,
|
||||
showTags: true,
|
||||
showSettingsButton: true,
|
||||
placeholder: '选择模型',
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// 发送/停止按钮 - 改为纯黑/灰黑主题
|
||||
SizedBox(
|
||||
height: 36, // 与模型选择器保持一致的高度
|
||||
width: 36,
|
||||
child: widget.isGenerating
|
||||
? Material(
|
||||
color: colorScheme.primary, // 使用主色
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
onTap: widget.onCancel,
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: const Icon(
|
||||
Icons.stop_rounded,
|
||||
size: 20,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Material(
|
||||
color: canSend
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
onTap: canSend ? _handleSendWithCreditCheck : null,
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: Icon(
|
||||
Icons.arrow_upward_rounded,
|
||||
size: 20,
|
||||
color: canSend
|
||||
? colorScheme.onPrimary
|
||||
: colorScheme.onPrimary.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 🚀 新增:带积分检查的发送处理
|
||||
void _handleSendWithCreditCheck() {
|
||||
try {
|
||||
// 调用原发送方法,积分校验将在后端处理
|
||||
widget.onSend();
|
||||
} catch (e) {
|
||||
// 如果发送失败,检查是否为积分不足错误
|
||||
final errorMessage = e.toString();
|
||||
if (errorMessage.contains('积分不足') || errorMessage.contains('InsufficientCredits')) {
|
||||
// 积分不足,调用错误回调
|
||||
widget.onCreditError?.call('积分不足,无法发送消息。请充值后重试。');
|
||||
|
||||
// 同时显示Toast提示
|
||||
TopToast.error(context, '积分不足,无法发送消息');
|
||||
} else {
|
||||
// 其他错误,显示通用错误提示
|
||||
TopToast.error(context, '发送失败: $errorMessage');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 🚀 构建已选择的上下文标签,使用完整菜单结构中的数据
|
||||
List<ContextSelectionItem> _buildSelectedContextTags(
|
||||
ContextSelectionData fullContextData,
|
||||
ContextSelectionData currentSelections,
|
||||
) {
|
||||
// 将当前选择应用到完整菜单结构中
|
||||
final updatedContextData = fullContextData.applyPresetSelections(currentSelections);
|
||||
|
||||
// 返回应用后的选中项目列表
|
||||
return updatedContextData.selectedItems.values.toList();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
136
AINoval/lib/screens/chat/widgets/chat_message_actions_bar.dart
Normal file
136
AINoval/lib/screens/chat/widgets/chat_message_actions_bar.dart
Normal file
@@ -0,0 +1,136 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:ainoval/widgets/common/top_toast.dart';
|
||||
|
||||
/// 通用的消息操作栏组件
|
||||
/// - 默认提供“复制”操作,复制整条消息文本
|
||||
/// - 支持扩展更多自定义操作
|
||||
/// - 自适应浅/深色主题
|
||||
class ChatMessageActionsBar extends StatelessWidget {
|
||||
const ChatMessageActionsBar({
|
||||
super.key,
|
||||
required this.textToCopy,
|
||||
this.alignEnd = false,
|
||||
this.actions = const [],
|
||||
this.compact = true,
|
||||
});
|
||||
|
||||
/// 要复制的完整文本
|
||||
final String textToCopy;
|
||||
|
||||
/// 是否尾对齐(用户消息用右对齐,AI 消息用左对齐)
|
||||
final bool alignEnd;
|
||||
|
||||
/// 额外自定义操作(可选)
|
||||
final List<Widget> actions;
|
||||
|
||||
/// 紧凑模式(更小的尺寸与间距)
|
||||
final bool compact;
|
||||
|
||||
void _copyToClipboard(BuildContext context) async {
|
||||
if (textToCopy.isEmpty) return;
|
||||
await Clipboard.setData(ClipboardData(text: textToCopy));
|
||||
TopToast.success(context, '已复制到剪贴板');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final iconColor = colorScheme.onSurfaceVariant;
|
||||
final hoverColor = colorScheme.surfaceContainerHighest.withOpacity(0.6);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: compact ? 4.0 : 8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: alignEnd ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.4)),
|
||||
),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: compact ? 4 : 6,
|
||||
vertical: compact ? 2 : 4,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 复制按钮(默认提供)
|
||||
_IconActionButton(
|
||||
icon: Icons.copy_rounded,
|
||||
tooltip: '复制整条消息',
|
||||
iconColor: iconColor,
|
||||
hoverColor: hoverColor,
|
||||
onPressed: () => _copyToClipboard(context),
|
||||
compact: compact,
|
||||
),
|
||||
// 分隔与扩展动作
|
||||
if (actions.isNotEmpty) ...[
|
||||
SizedBox(width: compact ? 2 : 4),
|
||||
..._intersperse(actions, SizedBox(width: compact ? 2 : 4)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _intersperse(List<Widget> list, Widget separator) {
|
||||
if (list.isEmpty) return list;
|
||||
final result = <Widget>[];
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
if (i > 0) result.add(separator);
|
||||
result.add(list[i]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class _IconActionButton extends StatelessWidget {
|
||||
const _IconActionButton({
|
||||
required this.icon,
|
||||
required this.tooltip,
|
||||
required this.iconColor,
|
||||
required this.hoverColor,
|
||||
required this.onPressed,
|
||||
required this.compact,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final Color iconColor;
|
||||
final Color hoverColor;
|
||||
final VoidCallback onPressed;
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
hoverColor: hoverColor,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(compact ? 6 : 8),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: compact ? 16 : 18,
|
||||
color: iconColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
330
AINoval/lib/screens/chat/widgets/chat_message_bubble.dart
Normal file
330
AINoval/lib/screens/chat/widgets/chat_message_bubble.dart
Normal file
@@ -0,0 +1,330 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import '../../../models/chat_models.dart';
|
||||
import 'chat_message_actions_bar.dart';
|
||||
|
||||
// 🚀 移除了TypewriterText组件,简化消息显示逻辑
|
||||
|
||||
class ChatMessageBubble extends StatelessWidget {
|
||||
const ChatMessageBubble({
|
||||
Key? key,
|
||||
required this.message,
|
||||
required this.onActionSelected,
|
||||
}) : super(key: key);
|
||||
final ChatMessage message;
|
||||
final Function(MessageAction) onActionSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 假设 message.role 可以区分用户和 AI (如果用 sender,则替换为 message.sender)
|
||||
final bool isUserMessage = message.role ==
|
||||
MessageRole.user; // 或者 message.sender == MessageSender.user
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6.0), // 稍微减少垂直间距
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
isUserMessage ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start, // 保持顶部对齐
|
||||
children: [
|
||||
// AI 头像占位符 (如果需要显示)
|
||||
if (!isUserMessage) _buildAvatar(context, false),
|
||||
if (!isUserMessage) const SizedBox(width: 8),
|
||||
|
||||
// 消息气泡容器 - 使用LayoutBuilder
|
||||
Flexible(
|
||||
child: LayoutBuilder(builder: (context, constraints) {
|
||||
// 基于LayoutBuilder中的约束计算最大宽度,保证气泡不会太宽
|
||||
final maxWidth = constraints.maxWidth * 0.95;
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
child: Column(
|
||||
// 用户消息时间戳靠右,AI 消息时间戳靠左
|
||||
crossAxisAlignment: isUserMessage
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 气泡主体
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 10.0, horizontal: 14.0), // 调整内边距
|
||||
decoration: BoxDecoration(
|
||||
color: isUserMessage
|
||||
? Theme.of(context).colorScheme.primary // 用户消息用主色
|
||||
: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainer, // AI消息用 surfaceContainer
|
||||
// 实现"尾巴"效果的圆角
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(16.0),
|
||||
topRight: const Radius.circular(16.0),
|
||||
bottomLeft: Radius.circular(
|
||||
isUserMessage ? 16.0 : 4.0), // 用户左下圆角,AI左下小圆角/直角
|
||||
bottomRight: Radius.circular(
|
||||
isUserMessage ? 4.0 : 16.0), // 用户右下小圆角/直角,AI右下圆角
|
||||
),
|
||||
// 可以为 AI 消息添加细微边框
|
||||
border: !isUserMessage
|
||||
? Border.all(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outlineVariant
|
||||
.withOpacity(0.3),
|
||||
width: 0.5,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: isUserMessage
|
||||
? _buildUserMessageContent(context)
|
||||
: _buildAIMessageContent(context),
|
||||
),
|
||||
// 时间戳
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 4.0, left: 6.0, right: 6.0),
|
||||
child: Text(
|
||||
message.formattedTime,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant
|
||||
.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 通用操作栏(复制等)
|
||||
ChatMessageActionsBar(
|
||||
textToCopy: message.content,
|
||||
alignEnd: isUserMessage,
|
||||
compact: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
|
||||
// 用户头像占位符 (如果需要显示)
|
||||
if (isUserMessage) const SizedBox(width: 8),
|
||||
if (isUserMessage) _buildAvatar(context, true),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 头像构建方法 (可选)
|
||||
Widget _buildAvatar(BuildContext context, bool isUser) {
|
||||
// 现在使用 Icon 代替 CircleAvatar
|
||||
return Icon(
|
||||
isUser ? Icons.person_outline : Icons.smart_toy_outlined,
|
||||
color: isUser
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.secondary,
|
||||
size: 28, // 调整大小
|
||||
);
|
||||
/* return CircleAvatar(
|
||||
radius: 16, // 调整大小
|
||||
backgroundColor: isUser
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Theme.of(context).colorScheme.secondaryContainer,
|
||||
child: Icon(
|
||||
isUser ? Icons.person_outline : Icons.smart_toy_outlined, // 使用 outline 图标
|
||||
size: 18, // 图标大小
|
||||
color: isUser
|
||||
? Theme.of(context).colorScheme.onPrimaryContainer
|
||||
: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
),
|
||||
); */
|
||||
}
|
||||
|
||||
// 构建用户消息内容
|
||||
Widget _buildUserMessageContent(BuildContext context) {
|
||||
return SelectableText(
|
||||
message.content,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onPrimary, // 用户消息文本颜色
|
||||
fontSize: 14, // 调整字体大小
|
||||
height: 1.4, // 调整行高
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 构建AI消息内容 (Markdown) - 修改为支持打字机效果
|
||||
Widget _buildAIMessageContent(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (message.status == MessageStatus.error)
|
||||
_buildErrorMessage(context)
|
||||
else if (message.status == MessageStatus.streaming || message.status == MessageStatus.pending)
|
||||
// 🚀 对于正在生成的消息,显示简单的等待状态
|
||||
_buildWaitingContent(context)
|
||||
else
|
||||
// 🚀 对于已完成的消息,直接使用可选择的 Markdown
|
||||
MarkdownBody(
|
||||
data: message.content.isEmpty ? '思考中...' : message.content,
|
||||
selectable: true,
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
p: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant, // AI 消息主要文本颜色
|
||||
fontSize: 14, // 字体大小
|
||||
height: 1.4, // 行高
|
||||
),
|
||||
h1: textTheme.titleLarge?.copyWith(
|
||||
color: colorScheme.onSurface, fontWeight: FontWeight.w600),
|
||||
h2: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurface, fontWeight: FontWeight.w600),
|
||||
h3: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface, fontWeight: FontWeight.w600),
|
||||
code: textTheme.bodyMedium?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
backgroundColor: colorScheme.surfaceContainerHighest
|
||||
.withOpacity(0.5), // 代码背景色
|
||||
color: colorScheme.onSurfaceVariant, // 代码文字颜色
|
||||
fontSize: 13,
|
||||
),
|
||||
codeblockDecoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest
|
||||
.withOpacity(0.5), // 代码块背景色
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color:
|
||||
colorScheme.outlineVariant.withOpacity(0.3)), // 代码块边框
|
||||
),
|
||||
blockquoteDecoration: BoxDecoration(
|
||||
// 引用块样式
|
||||
border: Border(
|
||||
left: BorderSide(color: colorScheme.primary, width: 4)),
|
||||
color: colorScheme.primaryContainer.withOpacity(0.1),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topRight: Radius.circular(4),
|
||||
bottomRight: Radius.circular(4)),
|
||||
),
|
||||
blockquotePadding: const EdgeInsets.all(12), // 引用块内边距
|
||||
listBulletPadding: const EdgeInsets.only(right: 4), // 列表标记边距
|
||||
listIndent: 16, // 列表缩进
|
||||
),
|
||||
),
|
||||
|
||||
// ActionChip 样式调整
|
||||
if (message.actions != null && message.actions!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10.0), // Chip 与上方内容的间距
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
children: message.actions!.map((action) {
|
||||
return ActionChip(
|
||||
label: Text(action.label),
|
||||
onPressed: () => onActionSelected(action),
|
||||
backgroundColor: colorScheme.secondaryContainer
|
||||
.withOpacity(0.5), // Chip 背景色
|
||||
labelStyle: textTheme.bodySmall?.copyWith(
|
||||
// Chip 文字样式
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2), // Chip 内边距
|
||||
side: BorderSide.none, // 移除边框
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16)), // 圆角
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 🚀 新增:构建等待状态内容,直接显示消息内容
|
||||
Widget _buildWaitingContent(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
// 🚀 如果有消息内容,直接显示为可选择的Markdown,否则显示等待提示
|
||||
if (message.content.isNotEmpty) {
|
||||
return MarkdownBody(
|
||||
data: message.content,
|
||||
selectable: true,
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
p: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
height: 1.4,
|
||||
),
|
||||
h1: textTheme.titleLarge?.copyWith(
|
||||
color: colorScheme.onSurface, fontWeight: FontWeight.w600),
|
||||
h2: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurface, fontWeight: FontWeight.w600),
|
||||
h3: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface, fontWeight: FontWeight.w600),
|
||||
code: textTheme.bodyMedium?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
backgroundColor: colorScheme.surfaceContainerHighest
|
||||
.withOpacity(0.5),
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
),
|
||||
codeblockDecoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest
|
||||
.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withOpacity(0.3)),
|
||||
),
|
||||
blockquoteDecoration: BoxDecoration(
|
||||
border: Border(
|
||||
left: BorderSide(color: colorScheme.primary, width: 4)),
|
||||
color: colorScheme.primaryContainer.withOpacity(0.1),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topRight: Radius.circular(4),
|
||||
bottomRight: Radius.circular(4)),
|
||||
),
|
||||
blockquotePadding: const EdgeInsets.all(12),
|
||||
listBulletPadding: const EdgeInsets.only(right: 4),
|
||||
listIndent: 16,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// 🚀 只有在没有内容时才显示简单的等待提示
|
||||
return SelectableText(
|
||||
'AI正在思考...',
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
height: 1.4,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 构建错误消息 (样式微调)
|
||||
Widget _buildErrorMessage(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
size: 18, // 调整图标大小
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: SelectableText(
|
||||
message.content.isEmpty ? '发生错误' : message.content, // 默认错误消息
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
fontWeight: FontWeight.w500, // 加粗错误文本
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
1150
AINoval/lib/screens/chat/widgets/chat_settings_dialog.dart
Normal file
1150
AINoval/lib/screens/chat/widgets/chat_settings_dialog.dart
Normal file
File diff suppressed because it is too large
Load Diff
200
AINoval/lib/screens/chat/widgets/context_panel.dart
Normal file
200
AINoval/lib/screens/chat/widgets/context_panel.dart
Normal file
@@ -0,0 +1,200 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../models/chat_models.dart';
|
||||
|
||||
class ContextPanel extends StatelessWidget {
|
||||
const ContextPanel({
|
||||
Key? key,
|
||||
required this.context,
|
||||
required this.onClose,
|
||||
}) : super(key: key);
|
||||
final ChatContext context;
|
||||
final VoidCallback onClose;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
// 使用 surfaceContainerLow 作为背景,与 ai_chat_sidebar 区分但又协调
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
color: colorScheme.outlineVariant.withOpacity(0.5), // 更细微的边框
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 面板标题
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
// 使用 surfaceContainer 作为标题背景
|
||||
color: colorScheme.surfaceContainer,
|
||||
// 底部边框调整
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: colorScheme.outlineVariant.withOpacity(0.5),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'上下文信息',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: onClose,
|
||||
tooltip: '关闭面板',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
// 调整关闭按钮颜色
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 上下文项目列表
|
||||
Expanded(
|
||||
child: this.context.relevantItems.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'无相关上下文信息',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurfaceVariant), // 调整空状态文本颜色
|
||||
))
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(8.0), // 为列表添加整体边距
|
||||
itemCount: this.context.relevantItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = this.context.relevantItems[index];
|
||||
return _buildContextItem(context, item);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 构建上下文项目卡片
|
||||
Widget _buildContextItem(BuildContext context, ContextItem item) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Card(
|
||||
elevation: 0.5, // 减少卡片阴影
|
||||
margin: const EdgeInsets.only(bottom: 8), // 只保留底部间距
|
||||
// 卡片背景色
|
||||
color: colorScheme.surfaceContainerHigh, // 使用比面板背景稍亮的颜色
|
||||
shape: RoundedRectangleBorder(
|
||||
// 圆角和边框
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
side: BorderSide(
|
||||
color: colorScheme.outlineVariant.withOpacity(0.3), width: 0.5)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
_buildContextTypeIcon(item.type),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.title,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600, // 加粗标题
|
||||
color: colorScheme.onSurface, // 标题颜色
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8), // 图标和相关度之间的间距
|
||||
// 相关度标签样式调整
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 3), // 内边距
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer.withOpacity(0.5), // 背景色
|
||||
borderRadius: BorderRadius.circular(12), // 圆角
|
||||
),
|
||||
child: Text(
|
||||
'${(item.relevanceScore * 100).toInt()}% 相关', // 添加 "相关" 文字
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onPrimaryContainer, // 文字颜色
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 11, // 稍小字体
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8), // 标题和分割线间距
|
||||
Divider(
|
||||
height: 1,
|
||||
color: colorScheme.outlineVariant.withOpacity(0.3)), // 分割线样式
|
||||
const SizedBox(height: 8), // 分割线和内容间距
|
||||
Text(
|
||||
item.content,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant, // 内容文字颜色
|
||||
height: 1.4, // 行高
|
||||
),
|
||||
maxLines: 5,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 根据上下文类型返回对应图标
|
||||
Widget _buildContextTypeIcon(ContextItemType type) {
|
||||
IconData iconData;
|
||||
Color color;
|
||||
|
||||
switch (type) {
|
||||
case ContextItemType.character:
|
||||
iconData = Icons.person;
|
||||
color = Colors.blue;
|
||||
break;
|
||||
case ContextItemType.location:
|
||||
iconData = Icons.place;
|
||||
color = Colors.green;
|
||||
break;
|
||||
case ContextItemType.plot:
|
||||
iconData = Icons.auto_stories;
|
||||
color = Colors.purple;
|
||||
break;
|
||||
case ContextItemType.chapter:
|
||||
iconData = Icons.bookmark;
|
||||
color = Colors.orange;
|
||||
break;
|
||||
case ContextItemType.scene:
|
||||
iconData = Icons.movie;
|
||||
color = Colors.red;
|
||||
break;
|
||||
case ContextItemType.note:
|
||||
iconData = Icons.note;
|
||||
color = Colors.teal;
|
||||
break;
|
||||
case ContextItemType.lore:
|
||||
iconData = Icons.history_edu;
|
||||
color = Colors.brown;
|
||||
break;
|
||||
}
|
||||
|
||||
return CircleAvatar(
|
||||
radius: 12,
|
||||
backgroundColor: color.withOpacity(0.2),
|
||||
child: Icon(iconData, size: 16, color: color),
|
||||
);
|
||||
}
|
||||
}
|
||||
106
AINoval/lib/screens/chat/widgets/typing_indicator.dart
Normal file
106
AINoval/lib/screens/chat/widgets/typing_indicator.dart
Normal file
@@ -0,0 +1,106 @@
|
||||
import 'dart:math' show sin;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class TypingIndicator extends StatefulWidget {
|
||||
const TypingIndicator({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<TypingIndicator> createState() => _TypingIndicatorState();
|
||||
}
|
||||
|
||||
class _TypingIndicatorState extends State<TypingIndicator>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
)..repeat();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6.0), // 与消息气泡垂直间距一致
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// AI 头像占位符
|
||||
Icon(
|
||||
Icons.smart_toy_outlined,
|
||||
color: colorScheme.secondary,
|
||||
size: 28,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// 指示器气泡
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14.0, vertical: 12.0), // 内边距调整
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainer, // 与 AI 气泡背景一致
|
||||
// 圆角与 AI 气泡一致
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(16.0),
|
||||
topRight: Radius.circular(16.0),
|
||||
bottomLeft: Radius.circular(4.0), // 左下小圆角
|
||||
bottomRight: Radius.circular(16.0), // 右下圆角
|
||||
),
|
||||
border: Border.all(
|
||||
// 细微边框
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outlineVariant
|
||||
.withOpacity(0.3),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(3, (i) {
|
||||
// 使用 List.generate
|
||||
// 调整动画,使其更平滑
|
||||
final double value =
|
||||
_controller.value * 2.0 * 3.14159; // 完整周期
|
||||
final double offset = i * 3.14159 / 3.0; // 相位偏移
|
||||
// 使用正弦函数创建上下浮动效果
|
||||
final double yOffset = sin(value - offset) * 2.0; // 调整浮动幅度
|
||||
|
||||
return Transform.translate(
|
||||
offset: Offset(0, yOffset),
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 3), // 点间距
|
||||
child: CircleAvatar(
|
||||
radius: 4, // 点大小
|
||||
// 使用更柔和的颜色
|
||||
backgroundColor:
|
||||
colorScheme.onSurfaceVariant.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
237
AINoval/lib/screens/editor/components/act_section.dart
Normal file
237
AINoval/lib/screens/editor/components/act_section.dart
Normal file
@@ -0,0 +1,237 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:ainoval/blocs/editor/editor_bloc.dart';
|
||||
import 'package:ainoval/models/novel_structure.dart' as novel_models;
|
||||
import 'package:ainoval/screens/editor/widgets/custom_dropdown.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/menu_builder.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ActSection extends StatefulWidget {
|
||||
const ActSection({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.chapters,
|
||||
required this.actId,
|
||||
required this.editorBloc,
|
||||
this.totalChaptersCount,
|
||||
this.loadedChaptersCount,
|
||||
this.actIndex, // 添加卷序号参数
|
||||
});
|
||||
final String title;
|
||||
final List<Widget> chapters;
|
||||
final String actId;
|
||||
final EditorBloc editorBloc;
|
||||
final int? totalChaptersCount; // 章节总数
|
||||
final int? loadedChaptersCount; // 已加载章节数
|
||||
final int? actIndex; // 卷序号,从1开始
|
||||
|
||||
@override
|
||||
State<ActSection> createState() => _ActSectionState();
|
||||
}
|
||||
|
||||
class _ActSectionState extends State<ActSection> {
|
||||
late TextEditingController _actTitleController;
|
||||
Timer? _actTitleDebounceTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_actTitleController = TextEditingController(text: widget.title);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ActSection oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.title != widget.title) {
|
||||
_actTitleController.text = widget.title;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_actTitleDebounceTimer?.cancel();
|
||||
_actTitleController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// 获取卷序号文本
|
||||
String _getActIndexText() {
|
||||
if (widget.actIndex == null) return '';
|
||||
|
||||
// 使用中文数字表示卷序号
|
||||
final List<String> chineseNumbers = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
|
||||
|
||||
if (widget.actIndex! <= 10) {
|
||||
return '第${chineseNumbers[widget.actIndex!]}卷 · ';
|
||||
} else if (widget.actIndex! < 20) {
|
||||
return '第十${chineseNumbers[widget.actIndex! - 10]}卷 · ';
|
||||
} else {
|
||||
// 对于更大的数字,直接使用阿拉伯数字
|
||||
return '第${widget.actIndex}卷 · ';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
color: WebTheme.getBackgroundColor(context), // 使用动态背景色
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Act标题 - 居中显示
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 16, 0, 24),
|
||||
child: Center(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 可编辑的文本字段
|
||||
IntrinsicWidth(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center, // 确保垂直居中对齐
|
||||
children: [
|
||||
// 添加卷序号前缀
|
||||
if (widget.actIndex != null)
|
||||
Text(
|
||||
_getActIndexText(),
|
||||
style: WebTheme.getAlignedTextStyle(
|
||||
baseStyle: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
) ?? const TextStyle(),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Material(
|
||||
type: MaterialType.transparency, // 使用透明Material类型避免黄色下划线
|
||||
child: TextField(
|
||||
controller: _actTitleController,
|
||||
style: WebTheme.getAlignedTextStyle(
|
||||
baseStyle: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
) ?? const TextStyle(),
|
||||
),
|
||||
decoration: WebTheme.getBorderlessInputDecoration(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
context: context, // 传递context以设置正确的hintStyle
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
onChanged: (value) {
|
||||
// 使用防抖动机制,避免频繁更新
|
||||
_actTitleDebounceTimer?.cancel();
|
||||
_actTitleDebounceTimer =
|
||||
Timer(const Duration(milliseconds: 500), () {
|
||||
if (mounted) {
|
||||
widget.editorBloc.add(UpdateActTitle(
|
||||
actId: widget.actId,
|
||||
title: value,
|
||||
));
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// 显示加载状态
|
||||
if (widget.totalChaptersCount != null && widget.loadedChaptersCount != null)
|
||||
Tooltip(
|
||||
message: '已加载 ${widget.loadedChaptersCount}/${widget.totalChaptersCount} 章节',
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.grey100,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'${widget.loadedChaptersCount}/${widget.totalChaptersCount}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
// 替换为MenuBuilder
|
||||
MenuBuilder.buildActMenu(
|
||||
context: context,
|
||||
editorBloc: widget.editorBloc,
|
||||
actId: widget.actId,
|
||||
onRenamePressed: () {
|
||||
// 聚焦到标题编辑框
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 显示"没有章节"提示信息(当章节列表为空时)
|
||||
if (widget.chapters.isEmpty)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 24.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.menu_book_outlined,
|
||||
size: 48, color: WebTheme.getSecondaryTextColor(context)),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'该卷下还没有章节',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'请使用下方添加章节按钮来创建章节',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 章节列表
|
||||
...widget.chapters,
|
||||
|
||||
// Act分隔线
|
||||
// const _ActDivider(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 可以保留或移除 _ActDivider
|
||||
// class _ActDivider extends StatelessWidget {
|
||||
// const _ActDivider();
|
||||
// @override
|
||||
// Widget build(BuildContext context) {
|
||||
// return Divider(
|
||||
// height: 80,
|
||||
// thickness: 1,
|
||||
// color: Colors.grey.shade200,
|
||||
// indent: 40,
|
||||
// endIndent: 40,
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
120
AINoval/lib/screens/editor/components/add_act_button.dart
Normal file
120
AINoval/lib/screens/editor/components/add_act_button.dart
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 添加新卷按钮组件
|
||||
*
|
||||
* 用于显示一个可点击的"添加新卷"按钮,用户点击后会触发创建新卷的逻辑。
|
||||
* 包含加载状态反馈和防抖功能,避免短时间内重复点击触发多次创建操作。
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:ainoval/widgets/common/top_toast.dart';
|
||||
import 'package:ainoval/screens/editor/controllers/editor_screen_controller.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// 添加新卷按钮组件
|
||||
///
|
||||
/// 在编辑器中用于添加新卷时使用的按钮组件,包含点击反馈和加载态。
|
||||
/// 使用Provider模式调用EditorScreenController中的创建方法。
|
||||
class AddActButton extends StatefulWidget {
|
||||
/// 创建一个添加新卷按钮
|
||||
const AddActButton({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<AddActButton> createState() => _AddActButtonState();
|
||||
}
|
||||
|
||||
class _AddActButtonState extends State<AddActButton> {
|
||||
/// 标记是否正在添加中,用于显示加载状态
|
||||
bool _isAdding = false;
|
||||
|
||||
/// 记录上次点击时间,用于防抖
|
||||
DateTime? _lastAddTime;
|
||||
|
||||
/// 防抖时间间隔(2秒)
|
||||
static const Duration _debounceInterval = Duration(seconds: 2);
|
||||
|
||||
/// 添加新卷的处理方法
|
||||
///
|
||||
/// 包含防抖和错误处理逻辑,避免短时间内多次触发
|
||||
void _addNewAct() {
|
||||
// 防止频繁点击导致重复添加
|
||||
final now = DateTime.now();
|
||||
if (_isAdding || (_lastAddTime != null &&
|
||||
now.difference(_lastAddTime!) < _debounceInterval)) {
|
||||
// 如果正在添加中或最后添加时间在2秒内,忽略此次点击
|
||||
AppLogger.i('AddActButton', '忽略重复点击: 正在添加=${_isAdding}, 距上次点击=${_lastAddTime != null ? now.difference(_lastAddTime!).inMilliseconds : "首次点击"}ms');
|
||||
|
||||
// 显示提示(仅在UI上)
|
||||
TopToast.warning(context, '操作正在处理中,请稍候...');
|
||||
return;
|
||||
}
|
||||
|
||||
// 记录当前时间并标记为添加中
|
||||
_lastAddTime = now;
|
||||
setState(() {
|
||||
_isAdding = true;
|
||||
});
|
||||
|
||||
AppLogger.i('AddActButton', '触发EditorScreenController的createNewAct方法');
|
||||
// 使用EditorScreenController创建新卷及章节
|
||||
Provider.of<EditorScreenController>(context, listen: false).createNewAct().then((_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isAdding = false;
|
||||
});
|
||||
}
|
||||
}).catchError((error) {
|
||||
AppLogger.e('AddActButton', '调用createNewAct失败', error);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isAdding = false;
|
||||
});
|
||||
TopToast.error(context, '创建失败: ${error.toString()}');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _isAdding ? null : _addNewAct, // 如果正在添加中,禁用按钮
|
||||
icon: _isAdding
|
||||
// 添加中状态显示加载指示器
|
||||
? SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(WebTheme.getPrimaryColor(context)),
|
||||
),
|
||||
)
|
||||
// 常规状态显示加号图标
|
||||
: const Icon(Icons.add, size: 18),
|
||||
label: Text(_isAdding ? '添加中...' : '添加新卷'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: WebTheme.getPrimaryColor(context),
|
||||
backgroundColor: WebTheme.getSurfaceColor(context),
|
||||
side: BorderSide(color: WebTheme.getPrimaryColor(context), width: 1.5),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
elevation: 1,
|
||||
).copyWith(
|
||||
overlayColor: MaterialStateProperty.resolveWith<Color?>(
|
||||
(Set<MaterialState> states) {
|
||||
if (states.contains(MaterialState.hovered)) {
|
||||
return WebTheme.getPrimaryColor(context).withOpacity(0.1);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:ainoval/blocs/universal_ai/universal_ai_bloc.dart';
|
||||
import 'package:ainoval/blocs/universal_ai/universal_ai_state.dart';
|
||||
import 'package:ainoval/blocs/universal_ai/universal_ai_event.dart';
|
||||
import 'package:ainoval/blocs/preset/preset_bloc.dart';
|
||||
import 'package:ainoval/blocs/preset/preset_event.dart';
|
||||
import 'package:ainoval/models/user_ai_model_config_model.dart';
|
||||
import 'package:ainoval/models/ai_request_models.dart';
|
||||
import 'package:ainoval/models/unified_ai_model.dart';
|
||||
import 'package:ainoval/models/preset_models.dart';
|
||||
import 'package:ainoval/models/context_selection_models.dart';
|
||||
import 'package:ainoval/services/ai_preset_service.dart';
|
||||
import 'package:ainoval/widgets/common/top_toast.dart';
|
||||
import 'package:ainoval/config/app_config.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
|
||||
/// AI对话框公共逻辑混入
|
||||
mixin AIDialogCommonLogic<T extends StatefulWidget> on State<T> {
|
||||
|
||||
/// 创建统一的模型配置
|
||||
/// 根据模型类型(公共/私有)创建正确的配置
|
||||
UserAIModelConfigModel createModelConfig(UnifiedAIModel unifiedModel) {
|
||||
if (unifiedModel.isPublic) {
|
||||
// 对于公共模型,创建包含公共模型信息的临时配置
|
||||
final publicModel = (unifiedModel as PublicAIModel).publicConfig;
|
||||
debugPrint('🚀 创建公共模型配置 - 显示名: ${publicModel.displayName}, 模型ID: ${publicModel.modelId}, 公共模型ID: ${publicModel.id}');
|
||||
return UserAIModelConfigModel.fromJson({
|
||||
'id': 'public_${publicModel.id}', // 🚀 使用前缀区分公共模型ID
|
||||
'userId': AppConfig.userId ?? 'unknown',
|
||||
'name': publicModel.displayName, // 🚀 修复:添加 name 字段
|
||||
'alias': publicModel.displayName,
|
||||
'modelName': publicModel.modelId,
|
||||
'provider': publicModel.provider,
|
||||
'apiEndpoint': '', // 公共模型没有单独的apiEndpoint
|
||||
'isDefault': false,
|
||||
'isValidated': true,
|
||||
'createdAt': DateTime.now().toIso8601String(),
|
||||
'updatedAt': DateTime.now().toIso8601String(),
|
||||
// 🚀 修复:添加公共模型的额外信息
|
||||
'isPublic': true,
|
||||
'creditMultiplier': publicModel.creditRateMultiplier ?? 1.0,
|
||||
});
|
||||
} else {
|
||||
// 对于私有模型,直接使用用户配置
|
||||
final privateModel = (unifiedModel as PrivateAIModel).userConfig;
|
||||
debugPrint('🚀 使用私有模型配置 - 显示名: ${privateModel.name}, 模型名: ${privateModel.modelName}, 配置ID: ${privateModel.id}');
|
||||
return privateModel;
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建包含模型元数据的metadata
|
||||
Map<String, dynamic> createModelMetadata(
|
||||
UnifiedAIModel unifiedModel,
|
||||
Map<String, dynamic> baseMetadata,
|
||||
) {
|
||||
final metadata = Map<String, dynamic>.from(baseMetadata);
|
||||
|
||||
// 🚀 添加模型信息
|
||||
metadata.addAll({
|
||||
'modelName': unifiedModel.modelId,
|
||||
'modelProvider': unifiedModel.provider,
|
||||
'modelConfigId': unifiedModel.id,
|
||||
'isPublicModel': unifiedModel.isPublic,
|
||||
});
|
||||
|
||||
// 🚀 如果是公共模型,添加公共模型的真实ID
|
||||
if (unifiedModel.isPublic) {
|
||||
final String publicId = (unifiedModel as PublicAIModel).publicConfig.id;
|
||||
// 发送后端期望的无前缀公共配置ID
|
||||
metadata['publicModelConfigId'] = publicId;
|
||||
// 同时保留兼容字段
|
||||
metadata['publicModelId'] = publicId;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/// 🚀 新增:处理公共模型的积分预估和确认
|
||||
Future<bool> handlePublicModelCreditConfirmation(
|
||||
UnifiedAIModel unifiedModel,
|
||||
UniversalAIRequest request,
|
||||
) async {
|
||||
if (!unifiedModel.isPublic) {
|
||||
// 私有模型直接返回 true
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('🚀 检测到公共模型,启动积分预估确认流程: ${unifiedModel.displayName}');
|
||||
|
||||
bool shouldProceed = await showCreditEstimationAndConfirm(request);
|
||||
|
||||
if (!shouldProceed) {
|
||||
debugPrint('🚀 用户取消了积分预估确认');
|
||||
return false; // 用户取消或积分不足
|
||||
}
|
||||
|
||||
debugPrint('🚀 用户确认了积分预估');
|
||||
return true;
|
||||
} catch (e) {
|
||||
AppLogger.e('AIDialogCommonLogic', '积分预估确认失败', e);
|
||||
TopToast.error(context, '积分预估失败: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 显示积分预估和确认对话框(仅对公共模型)
|
||||
Future<bool> showCreditEstimationAndConfirm(UniversalAIRequest request) async {
|
||||
try {
|
||||
// 显示积分预估确认对话框,传递UniversalAIBloc
|
||||
return await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return BlocProvider.value(
|
||||
value: context.read<UniversalAIBloc>(),
|
||||
child: _CreditEstimationDialog(
|
||||
modelName: request.modelConfig?.name ?? 'Unknown Model',
|
||||
request: request,
|
||||
onConfirm: () => Navigator.of(dialogContext).pop(true),
|
||||
onCancel: () => Navigator.of(dialogContext).pop(false),
|
||||
),
|
||||
);
|
||||
},
|
||||
) ?? false;
|
||||
|
||||
} catch (e) {
|
||||
AppLogger.e('AIDialogCommonLogic', '积分预估失败', e);
|
||||
TopToast.error(context, '积分预估失败: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 🚀 新增:通用的预设创建逻辑
|
||||
Future<void> createPreset(
|
||||
String name,
|
||||
String description,
|
||||
UniversalAIRequest currentRequest,
|
||||
{Function(AIPromptPreset)? onPresetCreated}
|
||||
) async {
|
||||
try {
|
||||
final presetService = AIPresetService();
|
||||
final request = CreatePresetRequest(
|
||||
presetName: name,
|
||||
presetDescription: description.isNotEmpty ? description : null,
|
||||
request: currentRequest,
|
||||
);
|
||||
|
||||
final preset = await presetService.createPreset(request);
|
||||
|
||||
// 🚀 新增:更新本地预设缓存
|
||||
try {
|
||||
context.read<PresetBloc>().add(AddPresetToCache(preset: preset));
|
||||
AppLogger.i('AIDialogCommonLogic', '✅ 已添加预设到本地缓存: ${preset.presetName}');
|
||||
} catch (e) {
|
||||
AppLogger.w('AIDialogCommonLogic', '⚠️ 添加预设到本地缓存失败,但预设创建成功', e);
|
||||
}
|
||||
|
||||
// 调用回调处理预设创建成功
|
||||
onPresetCreated?.call(preset);
|
||||
|
||||
TopToast.success(context, '预设 "$name" 创建成功');
|
||||
|
||||
AppLogger.i('AIDialogCommonLogic', '预设创建成功: $name');
|
||||
} catch (e) {
|
||||
AppLogger.e('AIDialogCommonLogic', '创建预设失败', e);
|
||||
TopToast.error(context, '创建预设失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 🚀 新增:显示预设名称输入对话框
|
||||
Future<void> showPresetNameDialog(
|
||||
UniversalAIRequest currentRequest,
|
||||
{Function(AIPromptPreset)? onPresetCreated}
|
||||
) async {
|
||||
final TextEditingController nameController = TextEditingController();
|
||||
final TextEditingController descController = TextEditingController();
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('创建预设'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '预设名称',
|
||||
hintText: '输入预设名称',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
autofocus: true,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: descController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '描述(可选)',
|
||||
hintText: '输入预设描述',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
final name = nameController.text.trim();
|
||||
if (name.isNotEmpty) {
|
||||
Navigator.of(context).pop();
|
||||
createPreset(name, descController.text.trim(), currentRequest, onPresetCreated: onPresetCreated);
|
||||
}
|
||||
},
|
||||
child: const Text('创建'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 🚀 新增:通用的预设应用逻辑
|
||||
void applyPresetToForm(
|
||||
AIPromptPreset preset,
|
||||
{
|
||||
TextEditingController? instructionsController,
|
||||
Function(String?)? onStyleChanged,
|
||||
Function(String?)? onLengthChanged,
|
||||
Function(bool)? onSmartContextChanged,
|
||||
Function(String?)? onPromptTemplateChanged,
|
||||
Function(double)? onTemperatureChanged,
|
||||
Function(double)? onTopPChanged,
|
||||
Function(ContextSelectionData)? onContextSelectionChanged,
|
||||
Function(UnifiedAIModel?)? onModelChanged,
|
||||
ContextSelectionData? currentContextData,
|
||||
}
|
||||
) {
|
||||
try {
|
||||
// 🚀 解析requestData中的JSON并应用到表单
|
||||
final parsedRequest = preset.parsedRequest;
|
||||
if (parsedRequest != null) {
|
||||
AppLogger.i('AIDialogCommonLogic', '从预设解析出完整配置: ${preset.presetName}');
|
||||
|
||||
// 应用指令内容
|
||||
if (instructionsController != null) {
|
||||
if (parsedRequest.instructions != null && parsedRequest.instructions!.isNotEmpty) {
|
||||
instructionsController.text = parsedRequest.instructions!;
|
||||
} else {
|
||||
// 回退到预设的用户提示词
|
||||
instructionsController.text = preset.effectiveUserPrompt;
|
||||
}
|
||||
}
|
||||
|
||||
// 应用模型配置
|
||||
if (parsedRequest.modelConfig != null && onModelChanged != null) {
|
||||
onModelChanged(PrivateAIModel(parsedRequest.modelConfig!));
|
||||
AppLogger.i('AIDialogCommonLogic', '应用模型配置: ${parsedRequest.modelConfig!.name}');
|
||||
}
|
||||
|
||||
// 🚀 应用上下文选择(保持完整菜单结构)
|
||||
if (parsedRequest.contextSelections != null &&
|
||||
parsedRequest.contextSelections!.selectedCount > 0 &&
|
||||
onContextSelectionChanged != null &&
|
||||
currentContextData != null) {
|
||||
final updatedContextData = currentContextData.applyPresetSelections(
|
||||
parsedRequest.contextSelections!,
|
||||
);
|
||||
onContextSelectionChanged(updatedContextData);
|
||||
AppLogger.i('AIDialogCommonLogic', '应用预设上下文选择: ${updatedContextData.selectedCount}个项目');
|
||||
}
|
||||
|
||||
// 应用参数设置
|
||||
if (parsedRequest.parameters.isNotEmpty) {
|
||||
// 应用智能上下文设置
|
||||
if (onSmartContextChanged != null) {
|
||||
onSmartContextChanged(parsedRequest.enableSmartContext);
|
||||
}
|
||||
|
||||
// 🚀 应用温度参数
|
||||
final temperature = parsedRequest.parameters['temperature'];
|
||||
if (temperature != null && onTemperatureChanged != null) {
|
||||
if (temperature is double) {
|
||||
onTemperatureChanged(temperature);
|
||||
} else if (temperature is num) {
|
||||
onTemperatureChanged(temperature.toDouble());
|
||||
}
|
||||
AppLogger.i('AIDialogCommonLogic', '应用预设温度参数: $temperature');
|
||||
}
|
||||
|
||||
// 🚀 应用Top-P参数
|
||||
final topP = parsedRequest.parameters['topP'];
|
||||
if (topP != null && onTopPChanged != null) {
|
||||
if (topP is double) {
|
||||
onTopPChanged(topP);
|
||||
} else if (topP is num) {
|
||||
onTopPChanged(topP.toDouble());
|
||||
}
|
||||
AppLogger.i('AIDialogCommonLogic', '应用预设Top-P参数: $topP');
|
||||
}
|
||||
|
||||
// 🚀 应用提示词模板ID
|
||||
final promptTemplateId = parsedRequest.parameters['promptTemplateId'];
|
||||
if (promptTemplateId is String && promptTemplateId.isNotEmpty && onPromptTemplateChanged != null) {
|
||||
onPromptTemplateChanged(promptTemplateId);
|
||||
AppLogger.i('AIDialogCommonLogic', '应用预设提示词模板ID: $promptTemplateId');
|
||||
}
|
||||
|
||||
// 应用特定参数(如长度、风格等)
|
||||
final style = parsedRequest.parameters['style'] as String?;
|
||||
if (style != null && style.isNotEmpty && onStyleChanged != null) {
|
||||
onStyleChanged(style);
|
||||
}
|
||||
|
||||
final length = parsedRequest.parameters['length'] as String?;
|
||||
if (length != null && length.isNotEmpty && onLengthChanged != null) {
|
||||
onLengthChanged(length);
|
||||
}
|
||||
|
||||
AppLogger.i('AIDialogCommonLogic', '应用参数设置完成');
|
||||
}
|
||||
|
||||
AppLogger.i('AIDialogCommonLogic', '完整配置应用成功');
|
||||
} else {
|
||||
AppLogger.w('AIDialogCommonLogic', '无法解析预设的requestData,仅应用提示词');
|
||||
// 回退到仅应用提示词
|
||||
if (instructionsController != null) {
|
||||
instructionsController.text = preset.effectiveUserPrompt;
|
||||
}
|
||||
}
|
||||
|
||||
// 记录预设使用
|
||||
AIPresetService().applyPreset(preset.presetId);
|
||||
|
||||
TopToast.success(context, '已应用预设: ${preset.displayName}');
|
||||
|
||||
AppLogger.i('AIDialogCommonLogic', '预设已应用: ${preset.displayName}');
|
||||
} catch (e) {
|
||||
AppLogger.e('AIDialogCommonLogic', '应用预设失败', e);
|
||||
TopToast.error(context, '应用预设失败: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 🚀 积分预估确认对话框(从expansion_dialog.dart提取)
|
||||
class _CreditEstimationDialog extends StatefulWidget {
|
||||
final String modelName;
|
||||
final UniversalAIRequest request;
|
||||
final VoidCallback onConfirm;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
const _CreditEstimationDialog({
|
||||
required this.modelName,
|
||||
required this.request,
|
||||
required this.onConfirm,
|
||||
required this.onCancel,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_CreditEstimationDialog> createState() => _CreditEstimationDialogState();
|
||||
}
|
||||
|
||||
class _CreditEstimationDialogState extends State<_CreditEstimationDialog> {
|
||||
CostEstimationResponse? _costEstimation;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_estimateCost();
|
||||
}
|
||||
|
||||
Future<void> _estimateCost() async {
|
||||
try {
|
||||
// 🚀 调用真实的积分预估API
|
||||
final universalAIBloc = context.read<UniversalAIBloc>();
|
||||
universalAIBloc.add(EstimateCostEvent(widget.request));
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = '预估失败: $e';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<UniversalAIBloc, UniversalAIState>(
|
||||
listener: (context, state) {
|
||||
if (state is UniversalAICostEstimationSuccess) {
|
||||
setState(() {
|
||||
_costEstimation = state.costEstimation;
|
||||
_errorMessage = null;
|
||||
});
|
||||
} else if (state is UniversalAIError) {
|
||||
setState(() {
|
||||
_errorMessage = state.message;
|
||||
_costEstimation = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
child: BlocBuilder<UniversalAIBloc, UniversalAIState>(
|
||||
builder: (context, state) {
|
||||
final isLoading = state is UniversalAILoading;
|
||||
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.account_balance_wallet,
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text('积分消耗预估'),
|
||||
],
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 300,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'模型: ${widget.modelName}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
if (isLoading) ...[
|
||||
const Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
SizedBox(width: 12),
|
||||
Text('正在估算积分消耗...'),
|
||||
],
|
||||
),
|
||||
] else if (_errorMessage != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
] else if (_costEstimation != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: WebTheme.getPrimaryColor(context).withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'预估消耗积分:',
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
Text(
|
||||
'${_costEstimation!.estimatedCost}',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_costEstimation!.estimatedInputTokens != null || _costEstimation!.estimatedOutputTokens != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Token预估:',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'输入: ${_costEstimation!.estimatedInputTokens ?? 0}, 输出: ${_costEstimation!.estimatedOutputTokens ?? 0}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'实际消耗可能因内容长度和模型响应而有所不同',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'确认要继续生成吗?',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: isLoading ? null : widget.onCancel,
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: isLoading || _errorMessage != null || _costEstimation == null ? null : widget.onConfirm,
|
||||
child: const Text('确认生成'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 边界指示器组件
|
||||
*
|
||||
* 用于在内容的顶部或底部显示边界提示信息,
|
||||
* 告知用户已经到达内容的边界,没有更多内容可以加载。
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// 内容边界指示器组件
|
||||
///
|
||||
/// 在列表或滚动视图的顶部或底部显示一个提示文本,
|
||||
/// 用于告知用户已达到内容边界(顶部或底部),没有更多内容可加载。
|
||||
class BoundaryIndicator extends StatelessWidget {
|
||||
/// 是否显示在顶部边界
|
||||
///
|
||||
/// 如果为true,则显示顶部边界提示;
|
||||
/// 如果为false,则显示底部边界提示。
|
||||
final bool isTop;
|
||||
|
||||
/// 创建一个边界指示器
|
||||
///
|
||||
/// [isTop] 指定是顶部边界还是底部边界
|
||||
const BoundaryIndicator({
|
||||
Key? key,
|
||||
required this.isTop,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32.0),
|
||||
child: Center(
|
||||
child: Text(
|
||||
// 根据位置显示不同的提示文本
|
||||
isTop ? '已到达顶部,没有更多内容' : '已到达底部,没有更多内容',
|
||||
style: TextStyle(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/models/novel_structure.dart' as novel_models;
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
|
||||
/// 编辑器项目类型枚举
|
||||
enum EditorItemType {
|
||||
actHeader,
|
||||
chapterHeader,
|
||||
scene,
|
||||
addSceneButton,
|
||||
addChapterButton,
|
||||
addActButton,
|
||||
actFooter,
|
||||
}
|
||||
|
||||
/// 编辑器项目数据类
|
||||
class EditorItem {
|
||||
final EditorItemType type;
|
||||
final String id;
|
||||
final novel_models.Act? act;
|
||||
final novel_models.Chapter? chapter;
|
||||
final novel_models.Scene? scene;
|
||||
final int? actIndex;
|
||||
final int? chapterIndex;
|
||||
final int? sceneIndex;
|
||||
final bool isLastInChapter;
|
||||
final bool isLastInAct;
|
||||
final bool isLastInNovel;
|
||||
|
||||
EditorItem({
|
||||
required this.type,
|
||||
required this.id,
|
||||
this.act,
|
||||
this.chapter,
|
||||
this.scene,
|
||||
this.actIndex,
|
||||
this.chapterIndex,
|
||||
this.sceneIndex,
|
||||
this.isLastInChapter = false,
|
||||
this.isLastInAct = false,
|
||||
this.isLastInNovel = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// Center Anchor List Builder
|
||||
/// 支持从指定章节开始向上下构建ListView的构建器
|
||||
class CenterAnchorListBuilder {
|
||||
final novel_models.Novel novel;
|
||||
final String? anchorChapterId; // 锚点章节ID
|
||||
final bool isImmersiveMode;
|
||||
final String? immersiveChapterId;
|
||||
|
||||
// 🚀 新增:锚点有效性标志
|
||||
bool _isAnchorValid = true;
|
||||
|
||||
CenterAnchorListBuilder({
|
||||
required this.novel,
|
||||
this.anchorChapterId,
|
||||
this.isImmersiveMode = false,
|
||||
this.immersiveChapterId,
|
||||
}) {
|
||||
// 🚀 新增:构造时验证锚点有效性
|
||||
_validateAnchor();
|
||||
}
|
||||
|
||||
/// 🚀 新增:验证锚点是否有效
|
||||
void _validateAnchor() {
|
||||
_isAnchorValid = true; // 重置标志
|
||||
|
||||
// 如果没有锚点章节,标记为有效(将使用传统模式)
|
||||
if (anchorChapterId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果小说为空,锚点无效
|
||||
if (novel.acts.isEmpty) {
|
||||
AppLogger.w('CenterAnchorListBuilder', '小说为空,锚点无效');
|
||||
_isAnchorValid = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 预验证锚点章节是否存在
|
||||
bool found = false;
|
||||
for (final act in novel.acts) {
|
||||
for (final chapter in act.chapters) {
|
||||
if (chapter.id == anchorChapterId) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found) break;
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
AppLogger.w('CenterAnchorListBuilder', '锚点章节 $anchorChapterId 不存在');
|
||||
_isAnchorValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建center anchor模式的slivers
|
||||
List<Widget> buildCenterAnchoredSlivers({
|
||||
required Widget Function(EditorItem) itemBuilder,
|
||||
}) {
|
||||
if (isImmersiveMode && immersiveChapterId != null) {
|
||||
// 沉浸模式:构建单章内容,保持原有逻辑
|
||||
AppLogger.i('CenterAnchorListBuilder', '使用沉浸模式构建,不使用center anchor');
|
||||
return _buildImmersiveModeSliver(itemBuilder);
|
||||
}
|
||||
|
||||
if (anchorChapterId == null) {
|
||||
// 没有锚点:使用传统模式从头构建
|
||||
AppLogger.i('CenterAnchorListBuilder', '无锚点章节,使用传统模式构建');
|
||||
return _buildTraditionalSlivers(itemBuilder);
|
||||
}
|
||||
|
||||
// 🚀 核心功能:从锚点章节开始上下构建
|
||||
AppLogger.i('CenterAnchorListBuilder', '使用center anchor模式构建,锚点章节: $anchorChapterId');
|
||||
return _buildCenterAnchoredSlivers(itemBuilder);
|
||||
}
|
||||
|
||||
/// 🚀 核心方法:构建从锚点章节开始的center-anchored slivers
|
||||
List<Widget> _buildCenterAnchoredSlivers(Widget Function(EditorItem) itemBuilder) {
|
||||
AppLogger.i('CenterAnchorListBuilder', '构建center-anchored slivers,锚点章节: $anchorChapterId');
|
||||
|
||||
final slivers = <Widget>[];
|
||||
|
||||
// 查找锚点章节的位置
|
||||
final anchorInfo = _findAnchorChapterInfo();
|
||||
if (anchorInfo == null) {
|
||||
AppLogger.w('CenterAnchorListBuilder', '未找到锚点章节 $anchorChapterId,回退到传统模式');
|
||||
// 🚀 关键修复:当找不到锚点章节时,确保center key也无效
|
||||
_invalidateAnchor();
|
||||
return _buildTraditionalSlivers(itemBuilder);
|
||||
}
|
||||
|
||||
final anchorKey = ValueKey('center_anchor_$anchorChapterId');
|
||||
|
||||
// 1. 构建锚点章节之前的内容(反向)
|
||||
final beforeItems = _buildItemsBefore(anchorInfo);
|
||||
|
||||
// 🚀 关键修复:确保center anchor前面总是有至少一个sliver
|
||||
// Flutter要求center widget不能是第一个sliver
|
||||
if (beforeItems.isNotEmpty) {
|
||||
slivers.add(
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final reversedIndex = beforeItems.length - 1 - index;
|
||||
return itemBuilder(beforeItems[reversedIndex]);
|
||||
},
|
||||
childCount: beforeItems.length,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// 🚀 添加一个空的占位sliver,确保center anchor不是第一个
|
||||
slivers.add(
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox.shrink(), // 不可见的占位widget
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 锚点章节组(包括可能的Act标题 + center anchor章节标题)
|
||||
final anchorItems = <EditorItem>[];
|
||||
final targetActIndex = anchorInfo['actIndex'] as int;
|
||||
final targetChapterIndex = anchorInfo['chapterIndex'] as int;
|
||||
final targetAct = anchorInfo['act'] as novel_models.Act;
|
||||
final targetChapter = anchorInfo['chapter'] as novel_models.Chapter;
|
||||
|
||||
// 🚀 关键修复:如果锚点章节是Act的第一章,需要包含Act标题
|
||||
if (targetChapterIndex == 0) {
|
||||
anchorItems.add(EditorItem(
|
||||
type: EditorItemType.actHeader,
|
||||
id: 'act_header_${targetAct.id}',
|
||||
act: targetAct,
|
||||
actIndex: targetActIndex + 1,
|
||||
));
|
||||
}
|
||||
|
||||
// 锚点章节标题 - 总是添加,确保anchorItems不为空
|
||||
anchorItems.add(_buildChapterItem(targetAct, targetChapter, targetActIndex, targetChapterIndex));
|
||||
|
||||
// 🚀 关键修复:center key必须直接设置在sliver上,且这个sliver必须存在
|
||||
// anchorItems至少包含章节标题,所以这个sliver总是存在的
|
||||
slivers.add(
|
||||
SliverList(
|
||||
key: anchorKey, // center key设置在sliver上,不是内部widget
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => itemBuilder(anchorItems[index]),
|
||||
childCount: anchorItems.length,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// 3. 锚点章节的场景
|
||||
final anchorChapterScenes = _buildChapterScenes(
|
||||
targetAct,
|
||||
targetChapter,
|
||||
targetActIndex,
|
||||
targetChapterIndex,
|
||||
);
|
||||
|
||||
if (anchorChapterScenes.isNotEmpty) {
|
||||
slivers.add(
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => itemBuilder(anchorChapterScenes[index]),
|
||||
childCount: anchorChapterScenes.length,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 4. 构建锚点章节之后的内容
|
||||
final afterItems = _buildItemsAfter(anchorInfo);
|
||||
if (afterItems.isNotEmpty) {
|
||||
slivers.add(
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => itemBuilder(afterItems[index]),
|
||||
childCount: afterItems.length,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
AppLogger.i('CenterAnchorListBuilder',
|
||||
'构建完成: ${beforeItems.length}个前置项 + 1个锚点 + ${anchorChapterScenes.length}个场景 + ${afterItems.length}个后续项');
|
||||
|
||||
// 🚀 关键调试:验证center key的存在
|
||||
final centerKey = ValueKey('center_anchor_$anchorChapterId');
|
||||
final hasMatchingSliver = slivers.any((sliver) => sliver.key == centerKey);
|
||||
AppLogger.i('CenterAnchorListBuilder',
|
||||
'Center key验证 - key:$centerKey, 找到匹配sliver:$hasMatchingSliver, 总sliver数:${slivers.length}');
|
||||
|
||||
return slivers;
|
||||
}
|
||||
|
||||
/// 获取center anchor key
|
||||
Key? getCenterAnchorKey() {
|
||||
// 🚀 关键修复:只有在普通模式且有锚点章节且锚点有效时才返回center key
|
||||
if (!isImmersiveMode && anchorChapterId != null && _isAnchorValid) {
|
||||
final key = ValueKey('center_anchor_$anchorChapterId');
|
||||
AppLogger.i('CenterAnchorListBuilder', '返回center anchor key: $key');
|
||||
return key;
|
||||
}
|
||||
// 沉浸模式或无锚点或锚点无效时返回null,不使用center anchor
|
||||
AppLogger.i('CenterAnchorListBuilder', '不使用center anchor - 沉浸模式:$isImmersiveMode, 锚点:$anchorChapterId, 有效:$_isAnchorValid');
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 🚀 新增:使锚点失效
|
||||
void _invalidateAnchor() {
|
||||
_isAnchorValid = false;
|
||||
AppLogger.w('CenterAnchorListBuilder', '锚点已失效,将不使用center anchor');
|
||||
}
|
||||
|
||||
/// 查找锚点章节信息
|
||||
Map<String, dynamic>? _findAnchorChapterInfo() {
|
||||
for (int actIndex = 0; actIndex < novel.acts.length; actIndex++) {
|
||||
final act = novel.acts[actIndex];
|
||||
for (int chapterIndex = 0; chapterIndex < act.chapters.length; chapterIndex++) {
|
||||
final chapter = act.chapters[chapterIndex];
|
||||
if (chapter.id == anchorChapterId) {
|
||||
return {
|
||||
'act': act,
|
||||
'chapter': chapter,
|
||||
'actIndex': actIndex,
|
||||
'chapterIndex': chapterIndex,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 构建锚点章节之前的所有内容
|
||||
List<EditorItem> _buildItemsBefore(Map<String, dynamic> anchorInfo) {
|
||||
final items = <EditorItem>[];
|
||||
final targetActIndex = anchorInfo['actIndex'] as int;
|
||||
final targetChapterIndex = anchorInfo['chapterIndex'] as int;
|
||||
|
||||
// 构建目标Act之前的所有Acts
|
||||
for (int actIndex = 0; actIndex < targetActIndex; actIndex++) {
|
||||
final act = novel.acts[actIndex];
|
||||
final actItems = _buildCompleteActItems(act, actIndex);
|
||||
items.addAll(actItems);
|
||||
}
|
||||
|
||||
// 构建目标Act中目标Chapter之前的内容
|
||||
if (targetChapterIndex > 0) {
|
||||
final targetAct = anchorInfo['act'] as novel_models.Act;
|
||||
|
||||
// Act标题
|
||||
items.add(EditorItem(
|
||||
type: EditorItemType.actHeader,
|
||||
id: 'act_header_${targetAct.id}',
|
||||
act: targetAct,
|
||||
actIndex: targetActIndex + 1,
|
||||
));
|
||||
|
||||
// 目标章节之前的章节
|
||||
for (int chapterIndex = 0; chapterIndex < targetChapterIndex; chapterIndex++) {
|
||||
final chapter = targetAct.chapters[chapterIndex];
|
||||
final chapterItems = _buildCompleteChapterItems(targetAct, chapter, targetActIndex, chapterIndex);
|
||||
items.addAll(chapterItems);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/// 构建锚点章节之后的所有内容
|
||||
List<EditorItem> _buildItemsAfter(Map<String, dynamic> anchorInfo) {
|
||||
final items = <EditorItem>[];
|
||||
final targetActIndex = anchorInfo['actIndex'] as int;
|
||||
final targetChapterIndex = anchorInfo['chapterIndex'] as int;
|
||||
final targetAct = anchorInfo['act'] as novel_models.Act;
|
||||
|
||||
// 构建目标Act中目标Chapter之后的章节
|
||||
for (int chapterIndex = targetChapterIndex + 1; chapterIndex < targetAct.chapters.length; chapterIndex++) {
|
||||
final chapter = targetAct.chapters[chapterIndex];
|
||||
final chapterItems = _buildCompleteChapterItems(targetAct, chapter, targetActIndex, chapterIndex);
|
||||
items.addAll(chapterItems);
|
||||
}
|
||||
|
||||
// 🚀 修改:无论锚点是否是最后一章,始终在当前卷末尾提供“添加章节”按钮
|
||||
items.add(EditorItem(
|
||||
type: EditorItemType.addChapterButton,
|
||||
id: 'add_chapter_after_${anchorChapterId}',
|
||||
act: targetAct,
|
||||
actIndex: targetActIndex + 1,
|
||||
isLastInAct: true,
|
||||
isLastInNovel: targetActIndex == novel.acts.length - 1,
|
||||
));
|
||||
|
||||
// 构建目标Act之后的所有Acts
|
||||
for (int actIndex = targetActIndex + 1; actIndex < novel.acts.length; actIndex++) {
|
||||
final act = novel.acts[actIndex];
|
||||
final actItems = _buildCompleteActItems(act, actIndex);
|
||||
items.addAll(actItems);
|
||||
}
|
||||
|
||||
// 如果是最后一个Act,添加"添加Act"按钮
|
||||
if (targetActIndex == novel.acts.length - 1) {
|
||||
items.add(EditorItem(
|
||||
type: EditorItemType.addActButton,
|
||||
id: 'add_act_after_${targetAct.id}',
|
||||
act: targetAct,
|
||||
actIndex: targetActIndex + 1,
|
||||
isLastInAct: true,
|
||||
isLastInNovel: true,
|
||||
));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/// 构建章节标题项
|
||||
EditorItem _buildChapterItem(novel_models.Act act, novel_models.Chapter chapter, int actIndex, int chapterIndex) {
|
||||
return EditorItem(
|
||||
type: EditorItemType.chapterHeader,
|
||||
id: 'chapter_header_${chapter.id}',
|
||||
act: act,
|
||||
chapter: chapter,
|
||||
actIndex: actIndex + 1,
|
||||
chapterIndex: chapterIndex + 1,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建章节的所有场景和按钮
|
||||
List<EditorItem> _buildChapterScenes(novel_models.Act act, novel_models.Chapter chapter, int actIndex, int chapterIndex) {
|
||||
final items = <EditorItem>[];
|
||||
|
||||
if (chapter.scenes.isEmpty) {
|
||||
// 空章节:添加"添加场景"按钮
|
||||
items.add(EditorItem(
|
||||
type: EditorItemType.addSceneButton,
|
||||
id: 'add_scene_${chapter.id}',
|
||||
act: act,
|
||||
chapter: chapter,
|
||||
actIndex: actIndex + 1,
|
||||
chapterIndex: chapterIndex + 1,
|
||||
isLastInChapter: true,
|
||||
isLastInAct: chapterIndex == act.chapters.length - 1,
|
||||
isLastInNovel: actIndex == novel.acts.length - 1 && chapterIndex == act.chapters.length - 1,
|
||||
));
|
||||
} else {
|
||||
// 有场景:构建所有场景
|
||||
for (int sceneIndex = 0; sceneIndex < chapter.scenes.length; sceneIndex++) {
|
||||
final scene = chapter.scenes[sceneIndex];
|
||||
final isLastScene = sceneIndex == chapter.scenes.length - 1;
|
||||
|
||||
items.add(EditorItem(
|
||||
type: EditorItemType.scene,
|
||||
id: 'scene_${scene.id}',
|
||||
act: act,
|
||||
chapter: chapter,
|
||||
scene: scene,
|
||||
actIndex: actIndex + 1,
|
||||
chapterIndex: chapterIndex + 1,
|
||||
sceneIndex: sceneIndex + 1,
|
||||
isLastInChapter: isLastScene,
|
||||
isLastInAct: chapterIndex == act.chapters.length - 1 && isLastScene,
|
||||
isLastInNovel: actIndex == novel.acts.length - 1 && chapterIndex == act.chapters.length - 1 && isLastScene,
|
||||
));
|
||||
|
||||
// 在最后一个场景后添加"添加场景"按钮
|
||||
if (isLastScene) {
|
||||
items.add(EditorItem(
|
||||
type: EditorItemType.addSceneButton,
|
||||
id: 'add_scene_after_${scene.id}',
|
||||
act: act,
|
||||
chapter: chapter,
|
||||
actIndex: actIndex + 1,
|
||||
chapterIndex: chapterIndex + 1,
|
||||
isLastInChapter: true,
|
||||
isLastInAct: chapterIndex == act.chapters.length - 1,
|
||||
isLastInNovel: actIndex == novel.acts.length - 1 && chapterIndex == act.chapters.length - 1,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/// 构建完整的Act项目(包括Act标题、所有章节、按钮)
|
||||
List<EditorItem> _buildCompleteActItems(novel_models.Act act, int actIndex) {
|
||||
final items = <EditorItem>[];
|
||||
final isLastAct = actIndex == novel.acts.length - 1;
|
||||
|
||||
// Act标题
|
||||
items.add(EditorItem(
|
||||
type: EditorItemType.actHeader,
|
||||
id: 'act_header_${act.id}',
|
||||
act: act,
|
||||
actIndex: actIndex + 1,
|
||||
));
|
||||
|
||||
// 章节
|
||||
if (act.chapters.isEmpty) {
|
||||
items.add(EditorItem(
|
||||
type: EditorItemType.addChapterButton,
|
||||
id: 'add_chapter_${act.id}',
|
||||
act: act,
|
||||
actIndex: actIndex + 1,
|
||||
isLastInAct: true,
|
||||
isLastInNovel: isLastAct,
|
||||
));
|
||||
} else {
|
||||
for (int chapterIndex = 0; chapterIndex < act.chapters.length; chapterIndex++) {
|
||||
final chapter = act.chapters[chapterIndex];
|
||||
final chapterItems = _buildCompleteChapterItems(act, chapter, actIndex, chapterIndex);
|
||||
items.addAll(chapterItems);
|
||||
}
|
||||
|
||||
// 最后一章后的"添加章节"按钮
|
||||
items.add(EditorItem(
|
||||
type: EditorItemType.addChapterButton,
|
||||
id: 'add_chapter_after_${act.chapters.last.id}',
|
||||
act: act,
|
||||
actIndex: actIndex + 1,
|
||||
isLastInAct: true,
|
||||
isLastInNovel: isLastAct,
|
||||
));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/// 构建完整的Chapter项目(包括章节标题、所有场景、按钮)
|
||||
List<EditorItem> _buildCompleteChapterItems(novel_models.Act act, novel_models.Chapter chapter, int actIndex, int chapterIndex) {
|
||||
final items = <EditorItem>[];
|
||||
|
||||
// 章节标题
|
||||
items.add(_buildChapterItem(act, chapter, actIndex, chapterIndex));
|
||||
|
||||
// 章节场景
|
||||
final sceneItems = _buildChapterScenes(act, chapter, actIndex, chapterIndex);
|
||||
items.addAll(sceneItems);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/// 构建沉浸模式的sliver
|
||||
List<Widget> _buildImmersiveModeSliver(Widget Function(EditorItem) itemBuilder) {
|
||||
AppLogger.i('CenterAnchorListBuilder', '沉浸模式:构建单章内容 - $immersiveChapterId');
|
||||
|
||||
// 查找目标章节
|
||||
novel_models.Chapter? targetChapter;
|
||||
novel_models.Act? parentAct;
|
||||
int actIndex = -1;
|
||||
int chapterIndex = -1;
|
||||
|
||||
outerLoop: for (int aIndex = 0; aIndex < novel.acts.length; aIndex++) {
|
||||
final act = novel.acts[aIndex];
|
||||
for (int cIndex = 0; cIndex < act.chapters.length; cIndex++) {
|
||||
final chapter = act.chapters[cIndex];
|
||||
if (chapter.id == immersiveChapterId) {
|
||||
targetChapter = chapter;
|
||||
parentAct = act;
|
||||
actIndex = aIndex;
|
||||
chapterIndex = cIndex;
|
||||
break outerLoop;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetChapter == null || parentAct == null) {
|
||||
AppLogger.w('CenterAnchorListBuilder', '沉浸模式:未找到目标章节 $immersiveChapterId');
|
||||
return [];
|
||||
}
|
||||
|
||||
// 构建单章内容项目
|
||||
final items = _buildCompleteChapterItems(parentAct, targetChapter, actIndex, chapterIndex);
|
||||
|
||||
// 🚀 新增:在沉浸模式下也提供“添加章节”按钮(出现在当前卷内容之后)
|
||||
items.add(EditorItem(
|
||||
type: EditorItemType.addChapterButton,
|
||||
id: 'add_chapter_after_${targetChapter.id}',
|
||||
act: parentAct,
|
||||
actIndex: actIndex + 1,
|
||||
));
|
||||
|
||||
return [
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => itemBuilder(items[index]),
|
||||
childCount: items.length,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// 构建传统模式的slivers
|
||||
List<Widget> _buildTraditionalSlivers(Widget Function(EditorItem) itemBuilder) {
|
||||
AppLogger.i('CenterAnchorListBuilder', '传统模式:从头构建完整内容');
|
||||
|
||||
final items = <EditorItem>[];
|
||||
|
||||
for (int actIndex = 0; actIndex < novel.acts.length; actIndex++) {
|
||||
final act = novel.acts[actIndex];
|
||||
final actItems = _buildCompleteActItems(act, actIndex);
|
||||
items.addAll(actItems);
|
||||
}
|
||||
|
||||
// 最后添加"添加Act"按钮
|
||||
if (novel.acts.isNotEmpty) {
|
||||
final lastAct = novel.acts.last;
|
||||
items.add(EditorItem(
|
||||
type: EditorItemType.addActButton,
|
||||
id: 'add_act_after_${lastAct.id}',
|
||||
act: lastAct,
|
||||
actIndex: novel.acts.length,
|
||||
isLastInAct: true,
|
||||
isLastInNovel: true,
|
||||
));
|
||||
}
|
||||
|
||||
return [
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => itemBuilder(items[index]),
|
||||
childCount: items.length,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
1112
AINoval/lib/screens/editor/components/chapter_directory_tab.dart
Normal file
1112
AINoval/lib/screens/editor/components/chapter_directory_tab.dart
Normal file
File diff suppressed because it is too large
Load Diff
220
AINoval/lib/screens/editor/components/chapter_section.dart
Normal file
220
AINoval/lib/screens/editor/components/chapter_section.dart
Normal file
@@ -0,0 +1,220 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:ainoval/blocs/editor/editor_bloc.dart';
|
||||
import 'package:ainoval/screens/editor/controllers/editor_screen_controller.dart';
|
||||
import 'package:ainoval/components/editable_title.dart';
|
||||
import 'package:ainoval/utils/debouncer.dart' as debouncer;
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/custom_dropdown.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/menu_builder.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class ChapterSection extends StatefulWidget {
|
||||
const ChapterSection({
|
||||
super.key, // Will be replaced by chapterKey if passed
|
||||
required this.title,
|
||||
required this.scenes,
|
||||
required this.actId,
|
||||
required this.chapterId,
|
||||
required this.editorBloc,
|
||||
this.chapterIndex, // 添加章节序号参数
|
||||
this.chapterKey, // New GlobalKey parameter
|
||||
});
|
||||
final String title;
|
||||
final List<Widget> scenes;
|
||||
final String actId;
|
||||
final String chapterId;
|
||||
final EditorBloc editorBloc;
|
||||
final int? chapterIndex; // 章节在卷中的序号,从1开始
|
||||
final GlobalKey? chapterKey; // New GlobalKey parameter
|
||||
|
||||
@override
|
||||
State<ChapterSection> createState() => _ChapterSectionState();
|
||||
}
|
||||
|
||||
class _ChapterSectionState extends State<ChapterSection> {
|
||||
late TextEditingController _chapterTitleController;
|
||||
late debouncer.Debouncer _debouncer;
|
||||
// 为章节创建一个ValueKey,确保唯一性 - This will be overridden by widget.chapterKey if provided
|
||||
// late final Key _chapterKey =
|
||||
// ValueKey('chapter_${widget.actId}_${widget.chapterId}');
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_chapterTitleController = TextEditingController(text: widget.title);
|
||||
_debouncer = debouncer.Debouncer();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ChapterSection oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// 更新标题控制器
|
||||
if (oldWidget.title != widget.title) {
|
||||
_chapterTitleController.text = widget.title;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debouncer.dispose();
|
||||
_chapterTitleController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// 获取章节序号文本
|
||||
String _getChapterIndexText() {
|
||||
if (widget.chapterIndex == null) return '';
|
||||
|
||||
// 使用中文数字表示章节序号
|
||||
final List<String> chineseNumbers = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
|
||||
|
||||
if (widget.chapterIndex! <= 10) {
|
||||
return '第${chineseNumbers[widget.chapterIndex!]}章 · ';
|
||||
} else if (widget.chapterIndex! < 20) {
|
||||
return '第十${chineseNumbers[widget.chapterIndex! - 10]}章 · ';
|
||||
} else {
|
||||
// 对于更大的数字,直接使用阿拉伯数字
|
||||
return '第${widget.chapterIndex}章 · ';
|
||||
}
|
||||
}
|
||||
|
||||
// 手动触发加载场景的方法
|
||||
void _loadScenes() {
|
||||
AppLogger.i('ChapterSection', '手动触发加载章节场景: ${widget.actId} - ${widget.chapterId}');
|
||||
|
||||
try {
|
||||
final controller = Provider.of<EditorScreenController>(context, listen: false);
|
||||
controller.loadScenesForChapter(widget.actId, widget.chapterId);
|
||||
} catch (e) {
|
||||
// 如果无法获取控制器,直接使用EditorBloc
|
||||
widget.editorBloc.add(LoadMoreScenes(
|
||||
fromChapterId: widget.chapterId,
|
||||
direction: 'center',
|
||||
actId: widget.actId,
|
||||
chaptersLimit: 2,
|
||||
preventFocusChange: true,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
key: widget.chapterKey, // Use the passed GlobalKey here
|
||||
color: WebTheme.getBackgroundColor(context), // 使用动态背景色
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Chapter标题
|
||||
Padding(
|
||||
// 调整间距
|
||||
padding: const EdgeInsets.fromLTRB(0, 8, 0, 24), // 调整上下间距
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center, // 垂直居中对齐
|
||||
children: [
|
||||
// 添加章节序号前缀
|
||||
if (widget.chapterIndex != null)
|
||||
Text(
|
||||
_getChapterIndexText(),
|
||||
style: WebTheme.getAlignedTextStyle(
|
||||
baseStyle: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
) ?? const TextStyle(),
|
||||
),
|
||||
),
|
||||
// 可编辑的文本字段
|
||||
Expanded(
|
||||
child: EditableTitle(
|
||||
// 保持 EditableTitle
|
||||
initialText: widget.title,
|
||||
style: WebTheme.getAlignedTextStyle(
|
||||
baseStyle: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
) ?? const TextStyle(),
|
||||
),
|
||||
onChanged: (value) {
|
||||
// 使用防抖更新
|
||||
_debouncer.run(() {
|
||||
if (mounted) {
|
||||
widget.editorBloc.add(UpdateChapterTitle(
|
||||
actId: widget.actId,
|
||||
chapterId: widget.chapterId,
|
||||
title: value,
|
||||
));
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8), // 增加间距
|
||||
|
||||
// 替换为MenuBuilder
|
||||
MenuBuilder.buildChapterMenu(
|
||||
context: context,
|
||||
editorBloc: widget.editorBloc,
|
||||
actId: widget.actId,
|
||||
chapterId: widget.chapterId,
|
||||
onRenamePressed: () {
|
||||
// 聚焦到标题编辑框
|
||||
// 通过setState强制刷新使标题进入编辑状态
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 场景列表
|
||||
if (widget.scenes.isEmpty)
|
||||
// 显示空章节的UI,提供手动加载按钮
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20.0, horizontal: 16.0),
|
||||
child: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.article_outlined,
|
||||
size: 48, color: WebTheme.getSecondaryTextColor(context)),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'章节 "${widget.title}" 暂无场景内容',
|
||||
style: TextStyle(color: WebTheme.getSecondaryTextColor(context)),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'请手动加载或等待自动加载',
|
||||
style: TextStyle(color: WebTheme.getSecondaryTextColor(context), fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// 加载场景按钮
|
||||
OutlinedButton.icon(
|
||||
onPressed: _loadScenes,
|
||||
icon: Icon(Icons.download, size: 18, color: WebTheme.getTextColor(context)),
|
||||
label: Text('加载场景', style: TextStyle(color: WebTheme.getTextColor(context))),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: WebTheme.getTextColor(context),
|
||||
side: BorderSide.none, // 去掉边框
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
elevation: 0, // 去掉阴影
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Column(children: widget.scenes),
|
||||
|
||||
// 移除添加新场景按钮 - 现在由EditorMainArea统一管理
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
67
AINoval/lib/screens/editor/components/draggable_divider.dart
Normal file
67
AINoval/lib/screens/editor/components/draggable_divider.dart
Normal file
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// 可拖拽的分隔条组件
|
||||
class DraggableDivider extends StatefulWidget {
|
||||
const DraggableDivider({
|
||||
super.key,
|
||||
required this.onDragUpdate,
|
||||
required this.onDragEnd,
|
||||
});
|
||||
|
||||
final Function(DragUpdateDetails) onDragUpdate;
|
||||
final Function(DragEndDetails) onDragEnd;
|
||||
|
||||
@override
|
||||
State<DraggableDivider> createState() => _DraggableDividerState();
|
||||
}
|
||||
|
||||
class _DraggableDividerState extends State<DraggableDivider> {
|
||||
bool _isDragging = false;
|
||||
bool _isHovering = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.resizeLeftRight,
|
||||
onEnter: (_) => setState(() => _isHovering = true),
|
||||
onExit: (_) => setState(() => _isHovering = false),
|
||||
child: GestureDetector(
|
||||
onHorizontalDragStart: (_) {
|
||||
setState(() {
|
||||
_isDragging = true;
|
||||
});
|
||||
},
|
||||
onHorizontalDragUpdate: widget.onDragUpdate,
|
||||
onHorizontalDragEnd: (details) {
|
||||
setState(() {
|
||||
_isDragging = false;
|
||||
});
|
||||
widget.onDragEnd(details);
|
||||
},
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: double.infinity,
|
||||
// 🚀 修复:使用WebTheme动态背景色
|
||||
color: _isDragging
|
||||
? WebTheme.getPrimaryColor(context).withOpacity(0.1)
|
||||
: _isHovering
|
||||
? WebTheme.isDarkMode(context) ? WebTheme.darkGrey200 : WebTheme.grey200
|
||||
: WebTheme.getSurfaceColor(context),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 1,
|
||||
height: double.infinity,
|
||||
// 🚀 修复:使用WebTheme动态分割线颜色
|
||||
color: _isDragging
|
||||
? WebTheme.getPrimaryColor(context)
|
||||
: _isHovering
|
||||
? WebTheme.isDarkMode(context) ? WebTheme.darkGrey400 : WebTheme.grey400
|
||||
: WebTheme.isDarkMode(context) ? WebTheme.darkGrey300 : WebTheme.grey300,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
481
AINoval/lib/screens/editor/components/editor_app_bar.dart
Normal file
481
AINoval/lib/screens/editor/components/editor_app_bar.dart
Normal file
@@ -0,0 +1,481 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:intl/intl.dart'; // For date formatting
|
||||
import 'package:ainoval/screens/editor/components/immersive_mode_navigation.dart';
|
||||
|
||||
import 'package:ainoval/blocs/editor/editor_bloc.dart' as editor_bloc;
|
||||
import 'package:ainoval/widgets/common/credit_display.dart';
|
||||
|
||||
class EditorAppBar extends StatelessWidget implements PreferredSizeWidget { // 新增写作按钮回调
|
||||
|
||||
const EditorAppBar({
|
||||
super.key,
|
||||
required this.novelTitle,
|
||||
required this.wordCount,
|
||||
required this.isSaving,
|
||||
required this.lastSaveTime,
|
||||
required this.onBackPressed,
|
||||
required this.onChatPressed,
|
||||
required this.isChatActive,
|
||||
required this.onAiConfigPressed,
|
||||
required this.isSettingsActive,
|
||||
required this.onPlanPressed,
|
||||
required this.isPlanActive,
|
||||
required this.isWritingActive,
|
||||
this.onWritePressed, // 新增可选参数
|
||||
this.onAIGenerationPressed, // For AI Scene Generation
|
||||
this.onAISummaryPressed,
|
||||
this.onAutoContinueWritingPressed,
|
||||
this.onAISettingGenerationPressed, // New: For AI Setting Generation
|
||||
this.onNextOutlinePressed,
|
||||
this.isAIGenerationActive = false, // This might now represent the dropdown itself or a specific item
|
||||
this.isAISummaryActive = false, // New: For AI Summary panel active state
|
||||
this.isAIContinueWritingActive = false, // New: For AI Continue Writing panel active state
|
||||
this.isAISettingGenerationActive = false, // New: For AI Setting Generation panel active state
|
||||
this.isNextOutlineActive = false,
|
||||
this.isDirty = false, // 新增: 是否存在未保存修改
|
||||
this.editorBloc, // 🚀 新增:编辑器BLoC实例,用于沉浸模式
|
||||
});
|
||||
final String novelTitle;
|
||||
final int wordCount;
|
||||
final bool isSaving;
|
||||
final DateTime? lastSaveTime;
|
||||
final VoidCallback onBackPressed;
|
||||
final VoidCallback onChatPressed;
|
||||
final bool isChatActive;
|
||||
final VoidCallback onAiConfigPressed;
|
||||
final bool isSettingsActive;
|
||||
final VoidCallback onPlanPressed;
|
||||
final bool isPlanActive;
|
||||
final bool isWritingActive;
|
||||
final VoidCallback? onWritePressed;
|
||||
final VoidCallback? onAIGenerationPressed; // AI 生成场景
|
||||
final VoidCallback? onAISummaryPressed; // AI 生成摘要
|
||||
final VoidCallback? onAutoContinueWritingPressed; // 自动续写
|
||||
final VoidCallback? onAISettingGenerationPressed; // AI 生成设定 (New)
|
||||
final VoidCallback? onNextOutlinePressed;
|
||||
final bool isAIGenerationActive; // AI 生成场景面板激活状态
|
||||
final bool isAISummaryActive; // AI 生成摘要面板激活状态 (New)
|
||||
final bool isAIContinueWritingActive; // AI 自动续写面板激活状态 (New)
|
||||
final bool isAISettingGenerationActive; // AI 生成设定面板激活状态 (New)
|
||||
final bool isNextOutlineActive;
|
||||
final bool isDirty; // 新增字段
|
||||
final editor_bloc.EditorBloc? editorBloc; // 🚀 新增:编辑器BLoC实例
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
String lastSaveText = '从未保存';
|
||||
if (lastSaveTime != null) {
|
||||
final formatter = DateFormat('HH:mm:ss');
|
||||
lastSaveText = '上次保存: ${formatter.format(lastSaveTime!.toLocal())}';
|
||||
}
|
||||
if (isSaving) {
|
||||
lastSaveText = '正在保存...';
|
||||
// 保存进行中,保持橙色提示
|
||||
} else if (isDirty) {
|
||||
// 未保存,使用黄色提示并附带上次保存时间
|
||||
final unsavedText = '尚未保存';
|
||||
if (lastSaveTime != null) {
|
||||
final formatter = DateFormat('HH:mm:ss');
|
||||
lastSaveText = '$unsavedText · 上次保存: ${formatter.format(lastSaveTime!.toLocal())}';
|
||||
} else {
|
||||
lastSaveText = unsavedText;
|
||||
}
|
||||
}
|
||||
|
||||
// 构建实际显示的字数文本
|
||||
final String wordCountText = '${wordCount.toString()} 字';
|
||||
|
||||
// Determine if the main "AI生成" dropdown should appear active
|
||||
// It can be active if any of its sub-panels are active
|
||||
final bool isAnyAIPanelActive = isAIGenerationActive ||
|
||||
isAISummaryActive ||
|
||||
isAIContinueWritingActive ||
|
||||
isAISettingGenerationActive;
|
||||
|
||||
return AppBar(
|
||||
titleSpacing: 0,
|
||||
automaticallyImplyLeading: false, // 禁用自动leading按钮
|
||||
title: Row(
|
||||
children: [
|
||||
// 返回按钮
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
splashRadius: 22,
|
||||
onPressed: onBackPressed,
|
||||
),
|
||||
|
||||
// 左对齐的功能图标区域(自适应 + 横向滚动)
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// 宽度阈值:不足则隐藏文字,仅显示图标
|
||||
final bool showLabels = constraints.maxWidth > 780;
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
// 大纲按钮
|
||||
_buildNavButton(
|
||||
context: context,
|
||||
icon: Icons.view_kanban_outlined,
|
||||
label: '大纲',
|
||||
isActive: isPlanActive,
|
||||
onPressed: onPlanPressed,
|
||||
showLabel: showLabels,
|
||||
),
|
||||
|
||||
// 写作按钮
|
||||
_buildNavButton(
|
||||
context: context,
|
||||
icon: Icons.edit_outlined,
|
||||
label: '写作',
|
||||
isActive: isWritingActive,
|
||||
onPressed: onWritePressed ?? () {},
|
||||
showLabel: showLabels,
|
||||
),
|
||||
|
||||
// 🚀 沉浸模式按钮
|
||||
if (editorBloc != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2.0),
|
||||
child: ImmersiveModeNavigation(
|
||||
editorBloc: editorBloc!,
|
||||
),
|
||||
),
|
||||
|
||||
// 设置按钮
|
||||
_buildNavButton(
|
||||
context: context,
|
||||
icon: Icons.settings_outlined,
|
||||
label: '设置',
|
||||
isActive: isSettingsActive,
|
||||
onPressed: onAiConfigPressed,
|
||||
showLabel: showLabels,
|
||||
),
|
||||
|
||||
// AI生成按钮 (Dropdown) - 自适应
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: _buildAdaptiveAIDropdownButton(
|
||||
context: context,
|
||||
showLabel: showLabels,
|
||||
isActive: isAnyAIPanelActive,
|
||||
),
|
||||
),
|
||||
|
||||
// 剧情推演按钮
|
||||
_buildNavButton(
|
||||
context: context,
|
||||
icon: Icons.device_hub_outlined, // Changed icon for better distinction
|
||||
label: '剧情推演',
|
||||
isActive: isNextOutlineActive,
|
||||
onPressed: onNextOutlinePressed ?? () {},
|
||||
showLabel: showLabels,
|
||||
),
|
||||
|
||||
// 聊天按钮
|
||||
_buildNavButton(
|
||||
context: context,
|
||||
icon: Icons.chat_bubble_outline,
|
||||
label: '聊天',
|
||||
isActive: isChatActive,
|
||||
onPressed: onChatPressed,
|
||||
showLabel: showLabels,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
// 积分显示(优雅紧凑,放在最右侧靠前位置)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 8.0),
|
||||
child: CreditDisplay(size: CreditDisplaySize.medium),
|
||||
),
|
||||
// Word Count and Save Status
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.text_fields,
|
||||
size: 14,
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
wordCountText,
|
||||
style: theme.textTheme.bodyMedium
|
||||
?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
isSaving
|
||||
? Icons.sync
|
||||
: (isDirty ? Icons.warning_amber_outlined : Icons.check_circle_outline),
|
||||
size: 14,
|
||||
color: isSaving
|
||||
? theme.colorScheme.tertiary
|
||||
: (isDirty ? theme.colorScheme.tertiary : theme.colorScheme.secondary),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
lastSaveText,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: isSaving
|
||||
? theme.colorScheme.tertiary
|
||||
: (isDirty ? theme.colorScheme.tertiary : theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
elevation: 0,
|
||||
shape: Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.dividerColor,
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
backgroundColor: theme.colorScheme.surface,
|
||||
foregroundColor: theme.colorScheme.onSurface,
|
||||
);
|
||||
}
|
||||
|
||||
// 构建导航按钮的辅助方法
|
||||
Widget _buildNavButton({
|
||||
required BuildContext context,
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required bool isActive,
|
||||
required VoidCallback onPressed,
|
||||
bool showLabel = true,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final ButtonStyle commonStyle = TextButton.styleFrom(
|
||||
backgroundColor: isActive
|
||||
? WebTheme.getPrimaryColor(context).withAlpha(76)
|
||||
: Colors.transparent,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: showLabel
|
||||
? TextButton.icon(
|
||||
icon: Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: isActive
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
label: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isActive
|
||||
? WebTheme.getPrimaryColor(context)
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
style: commonStyle,
|
||||
onPressed: onPressed,
|
||||
)
|
||||
: TextButton(
|
||||
style: commonStyle,
|
||||
onPressed: onPressed,
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: isActive
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 自适应的 AI 下拉按钮:在窄屏时仅显示图标
|
||||
Widget _buildAdaptiveAIDropdownButton({
|
||||
required BuildContext context,
|
||||
required bool showLabel,
|
||||
required bool isActive,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
return PopupMenuButton<String>(
|
||||
offset: const Offset(0, 40),
|
||||
tooltip: 'AI辅助',
|
||||
onSelected: (value) {
|
||||
if (value == 'scene') {
|
||||
onAIGenerationPressed?.call();
|
||||
} else if (value == 'summary') {
|
||||
onAISummaryPressed?.call();
|
||||
} else if (value == 'continue-writing') {
|
||||
onAutoContinueWritingPressed?.call();
|
||||
} else if (value == 'setting-generation') {
|
||||
onAISettingGenerationPressed?.call();
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem<String>(
|
||||
value: 'scene',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.auto_awesome_outlined,
|
||||
color: isAIGenerationActive ? WebTheme.getPrimaryColor(context) : null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'AI生成场景',
|
||||
style: TextStyle(
|
||||
color: isAIGenerationActive ? WebTheme.getPrimaryColor(context) : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem<String>(
|
||||
value: 'summary',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.summarize_outlined,
|
||||
color: isAISummaryActive ? WebTheme.getPrimaryColor(context) : null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'AI生成摘要',
|
||||
style: TextStyle(
|
||||
color: isAISummaryActive ? WebTheme.getPrimaryColor(context) : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem<String>(
|
||||
value: 'continue-writing',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.auto_stories_outlined,
|
||||
color: isAIContinueWritingActive ? WebTheme.getPrimaryColor(context) : null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'自动续写',
|
||||
style: TextStyle(
|
||||
color: isAIContinueWritingActive ? WebTheme.getPrimaryColor(context) : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem<String>(
|
||||
value: 'setting-generation',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.auto_fix_high_outlined,
|
||||
color: isAISettingGenerationActive ? WebTheme.getPrimaryColor(context) : null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'AI生成设定',
|
||||
style: TextStyle(
|
||||
color: isAISettingGenerationActive ? WebTheme.getPrimaryColor(context) : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
child: showLabel
|
||||
? TextButton.icon(
|
||||
icon: Icon(
|
||||
Icons.psychology_alt_outlined,
|
||||
size: 20,
|
||||
color: isActive
|
||||
? WebTheme.getPrimaryColor(context)
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
label: Row(
|
||||
children: [
|
||||
Text(
|
||||
'AI辅助',
|
||||
style: TextStyle(
|
||||
color: isActive
|
||||
? WebTheme.getPrimaryColor(context)
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.arrow_drop_down,
|
||||
size: 16,
|
||||
color: isActive
|
||||
? WebTheme.getPrimaryColor(context)
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor: isActive
|
||||
? WebTheme.getPrimaryColor(context).withAlpha(76)
|
||||
: Colors.transparent,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
onPressed: null,
|
||||
)
|
||||
: TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor: isActive
|
||||
? WebTheme.getPrimaryColor(context).withAlpha(76)
|
||||
: Colors.transparent,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
onPressed: null,
|
||||
child: Icon(
|
||||
Icons.psychology_alt_outlined,
|
||||
size: 20,
|
||||
color: isActive
|
||||
? WebTheme.getPrimaryColor(context)
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
}
|
||||
239
AINoval/lib/screens/editor/components/editor_data_manager.dart
Normal file
239
AINoval/lib/screens/editor/components/editor_data_manager.dart
Normal file
@@ -0,0 +1,239 @@
|
||||
import 'dart:collection';
|
||||
|
||||
/// 编辑器数据管理器 - 高效的双重索引结构
|
||||
/// 提供O(1)键查找、索引访问、相邻元素获取
|
||||
class EditorDataManager<T> {
|
||||
// 主数据存储:保持插入顺序的列表
|
||||
final List<T> _items = [];
|
||||
|
||||
// 键到索引的映射:O(1)查找
|
||||
final Map<String, int> _keyToIndex = {};
|
||||
|
||||
// 索引到键的映射:O(1)反向查找
|
||||
final Map<int, String> _indexToKey = {};
|
||||
|
||||
/// 获取元素数量
|
||||
int get length => _items.length;
|
||||
|
||||
/// 是否为空
|
||||
bool get isEmpty => _items.isEmpty;
|
||||
|
||||
/// 是否非空
|
||||
bool get isNotEmpty => _items.isNotEmpty;
|
||||
|
||||
/// 获取所有值
|
||||
List<T> get values => List.unmodifiable(_items);
|
||||
|
||||
/// 获取所有键
|
||||
Iterable<String> get keys => _keyToIndex.keys;
|
||||
|
||||
/// 添加元素到末尾 - O(1)
|
||||
void add(String key, T value) {
|
||||
// 如果键已存在,更新值
|
||||
if (_keyToIndex.containsKey(key)) {
|
||||
final index = _keyToIndex[key]!;
|
||||
_items[index] = value;
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加新元素
|
||||
final index = _items.length;
|
||||
_items.add(value);
|
||||
_keyToIndex[key] = index;
|
||||
_indexToKey[index] = key;
|
||||
}
|
||||
|
||||
/// 在指定位置插入元素 - O(n)
|
||||
void insertAt(int index, String key, T value) {
|
||||
if (_keyToIndex.containsKey(key)) {
|
||||
throw ArgumentError('Key $key already exists');
|
||||
}
|
||||
|
||||
if (index < 0 || index > _items.length) {
|
||||
throw RangeError('Index $index out of range');
|
||||
}
|
||||
|
||||
// 插入元素
|
||||
_items.insert(index, value);
|
||||
|
||||
// 更新所有索引映射
|
||||
_rebuildIndexMaps();
|
||||
}
|
||||
|
||||
/// 根据键删除元素 - O(n)
|
||||
bool removeByKey(String key) {
|
||||
final index = _keyToIndex[key];
|
||||
if (index == null) return false;
|
||||
|
||||
_items.removeAt(index);
|
||||
_rebuildIndexMaps();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 根据索引删除元素 - O(n)
|
||||
T? removeAt(int index) {
|
||||
if (index < 0 || index >= _items.length) return null;
|
||||
|
||||
final value = _items.removeAt(index);
|
||||
_rebuildIndexMaps();
|
||||
return value;
|
||||
}
|
||||
|
||||
/// 根据键获取值 - O(1)
|
||||
T? getByKey(String key) {
|
||||
final index = _keyToIndex[key];
|
||||
if (index == null) return null;
|
||||
return _items[index];
|
||||
}
|
||||
|
||||
/// 根据索引获取值 - O(1)
|
||||
T? getByIndex(int index) {
|
||||
if (index < 0 || index >= _items.length) return null;
|
||||
return _items[index];
|
||||
}
|
||||
|
||||
/// 根据索引获取键 - O(1)
|
||||
String? getKeyByIndex(int index) {
|
||||
return _indexToKey[index];
|
||||
}
|
||||
|
||||
/// 根据键获取索引 - O(1)
|
||||
int? getIndexByKey(String key) {
|
||||
return _keyToIndex[key];
|
||||
}
|
||||
|
||||
/// 检查是否包含键 - O(1)
|
||||
bool containsKey(String key) {
|
||||
return _keyToIndex.containsKey(key);
|
||||
}
|
||||
|
||||
/// 获取前k个元素 - O(1) 时间复杂度(对于小的k值)
|
||||
List<T> getPrevious(String key, int count) {
|
||||
final index = _keyToIndex[key];
|
||||
if (index == null) return [];
|
||||
|
||||
final startIndex = (index - count).clamp(0, _items.length);
|
||||
final endIndex = index;
|
||||
|
||||
return _items.getRange(startIndex, endIndex).toList();
|
||||
}
|
||||
|
||||
/// 获取后k个元素 - O(1) 时间复杂度(对于小的k值)
|
||||
List<T> getNext(String key, int count) {
|
||||
final index = _keyToIndex[key];
|
||||
if (index == null) return [];
|
||||
|
||||
final startIndex = index + 1;
|
||||
final endIndex = (startIndex + count).clamp(0, _items.length);
|
||||
|
||||
return _items.getRange(startIndex, endIndex).toList();
|
||||
}
|
||||
|
||||
/// 获取前后k个元素 - O(1) 时间复杂度(对于小的k值)
|
||||
List<T> getSurrounding(String key, int count) {
|
||||
final index = _keyToIndex[key];
|
||||
if (index == null) return [];
|
||||
|
||||
final startIndex = (index - count).clamp(0, _items.length);
|
||||
final endIndex = (index + count + 1).clamp(0, _items.length);
|
||||
|
||||
return _items.getRange(startIndex, endIndex).toList();
|
||||
}
|
||||
|
||||
/// 获取指定范围的元素 - O(range)
|
||||
List<T> getRange(int start, int end) {
|
||||
if (start < 0) start = 0;
|
||||
if (end > _items.length) end = _items.length;
|
||||
if (start >= end) return [];
|
||||
|
||||
return _items.getRange(start, end).toList();
|
||||
}
|
||||
|
||||
/// 清空所有元素 - O(1)
|
||||
void clear() {
|
||||
_items.clear();
|
||||
_keyToIndex.clear();
|
||||
_indexToKey.clear();
|
||||
}
|
||||
|
||||
/// 重建索引映射 - O(n),仅在插入/删除时调用
|
||||
void _rebuildIndexMaps() {
|
||||
_keyToIndex.clear();
|
||||
_indexToKey.clear();
|
||||
|
||||
for (int i = 0; i < _items.length; i++) {
|
||||
// 这里需要一个获取键的方法,具体实现由子类重写
|
||||
}
|
||||
}
|
||||
|
||||
/// 遍历所有元素
|
||||
void forEach(void Function(String key, T value, int index) action) {
|
||||
for (int i = 0; i < _items.length; i++) {
|
||||
final key = _indexToKey[i];
|
||||
if (key != null) {
|
||||
action(key, _items[i], i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 查找符合条件的元素索引
|
||||
int indexWhere(bool Function(T value) test) {
|
||||
return _items.indexWhere(test);
|
||||
}
|
||||
|
||||
/// 🚀 新增:查找所有符合条件的元素
|
||||
List<T> findAll(bool Function(T value) test) {
|
||||
return _items.where(test).toList();
|
||||
}
|
||||
|
||||
/// 🚀 新增:查找所有符合条件的键值对
|
||||
Map<String, T> findAllWithKeys(bool Function(T value) test) {
|
||||
final result = <String, T>{};
|
||||
for (int i = 0; i < _items.length; i++) {
|
||||
final item = _items[i];
|
||||
if (test(item)) {
|
||||
final key = _indexToKey[i];
|
||||
if (key != null) {
|
||||
result[key] = item;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// 专门为EditorItem设计的数据管理器
|
||||
class EditorItemManager extends EditorDataManager<dynamic> {
|
||||
/// 重写_rebuildIndexMaps以正确处理EditorItem的键
|
||||
@override
|
||||
void _rebuildIndexMaps() {
|
||||
_keyToIndex.clear();
|
||||
_indexToKey.clear();
|
||||
|
||||
for (int i = 0; i < _items.length; i++) {
|
||||
final item = _items[i];
|
||||
String key;
|
||||
|
||||
// 根据EditorItem类型生成正确的键
|
||||
switch (item.type.toString()) {
|
||||
case 'EditorItemType.actHeader':
|
||||
key = 'act_${item.act!.id}';
|
||||
break;
|
||||
case 'EditorItemType.chapterHeader':
|
||||
key = 'chapter_${item.chapter!.id}';
|
||||
break;
|
||||
case 'EditorItemType.scene':
|
||||
key = 'scene_${item.scene!.id}';
|
||||
break;
|
||||
case 'EditorItemType.actFooter':
|
||||
key = 'act_footer_${item.act!.id}';
|
||||
break;
|
||||
default:
|
||||
key = item.id;
|
||||
}
|
||||
|
||||
_keyToIndex[key] = i;
|
||||
_indexToKey[i] = key;
|
||||
}
|
||||
}
|
||||
}
|
||||
802
AINoval/lib/screens/editor/components/editor_layout.dart
Normal file
802
AINoval/lib/screens/editor/components/editor_layout.dart
Normal file
@@ -0,0 +1,802 @@
|
||||
import 'package:ainoval/blocs/editor/editor_bloc.dart' as editor_bloc;
|
||||
|
||||
import 'package:ainoval/models/editor_settings.dart';
|
||||
import 'package:ainoval/screens/editor/components/draggable_divider.dart';
|
||||
import 'package:ainoval/screens/editor/components/editor_app_bar.dart';
|
||||
import 'package:ainoval/screens/editor/components/editor_main_area.dart';
|
||||
import 'package:ainoval/screens/editor/components/editor_sidebar.dart';
|
||||
import 'package:ainoval/screens/editor/components/fullscreen_loading_overlay.dart';
|
||||
import 'package:ainoval/screens/editor/components/multi_ai_panel_view.dart';
|
||||
import 'package:ainoval/screens/editor/components/plan_view.dart';
|
||||
import 'package:ainoval/screens/editor/controllers/editor_screen_controller.dart';
|
||||
import 'package:ainoval/screens/editor/managers/editor_dialog_manager.dart';
|
||||
import 'package:ainoval/screens/editor/managers/editor_layout_manager.dart';
|
||||
import 'package:ainoval/screens/editor/managers/editor_state_manager.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/novel_settings_view.dart';
|
||||
import 'package:ainoval/screens/next_outline/next_outline_view.dart';
|
||||
import 'package:ainoval/screens/settings/settings_panel.dart';
|
||||
import 'package:ainoval/services/api_service/repositories/editor_repository.dart';
|
||||
import 'package:ainoval/services/api_service/repositories/impl/aliyun_oss_storage_repository.dart';
|
||||
import 'package:ainoval/services/api_service/repositories/impl/user_ai_model_config_repository_impl.dart';
|
||||
import 'package:ainoval/services/api_service/repositories/prompt_repository.dart';
|
||||
import 'package:ainoval/services/api_service/repositories/storage_repository.dart';
|
||||
import 'package:ainoval/screens/unified_management/unified_management_screen.dart';
|
||||
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:ainoval/widgets/common/top_toast.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// 编辑器布局组件
|
||||
/// 负责组织编辑器的整体布局
|
||||
class EditorLayout extends StatelessWidget {
|
||||
const EditorLayout({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.layoutManager,
|
||||
required this.stateManager,
|
||||
this.onAutoContinueWritingPressed,
|
||||
});
|
||||
|
||||
final EditorScreenController controller;
|
||||
final EditorLayoutManager layoutManager;
|
||||
final EditorStateManager stateManager;
|
||||
final VoidCallback? onAutoContinueWritingPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 清除内存缓存,确保每次build周期都使用新的内存缓存
|
||||
stateManager.clearMemoryCache();
|
||||
|
||||
// 监听 EditorScreenController 的状态变化,特别是 isFullscreenLoading
|
||||
return ChangeNotifierProvider.value(
|
||||
value: controller,
|
||||
child: Consumer<EditorScreenController>(
|
||||
builder: (context, editorController, _) {
|
||||
// 主要布局,始终在Stack中
|
||||
Widget mainContent;
|
||||
if (editorController.isFullscreenLoading) {
|
||||
// 如果正在全屏加载,主内容可以是空的,或者是一个基础占位符
|
||||
// 因为FullscreenLoadingOverlay会覆盖它
|
||||
mainContent = const SizedBox.shrink();
|
||||
} else {
|
||||
// 正常的主布局
|
||||
mainContent = ValueListenableBuilder<String>(
|
||||
valueListenable: stateManager.contentUpdateNotifier,
|
||||
builder: (context, updateValue, child) {
|
||||
return BlocBuilder<editor_bloc.EditorBloc, editor_bloc.EditorState>(
|
||||
bloc: editorController.editorBloc,
|
||||
buildWhen: (previous, current) {
|
||||
if (current is editor_bloc.EditorLoaded) {
|
||||
return current.lastUpdateSilent == false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state is editor_bloc.EditorLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
} else if (state is editor_bloc.EditorLoaded) {
|
||||
if (stateManager.shouldCheckControllers(state)) {
|
||||
editorController.ensureControllersForNovel(state.novel);
|
||||
}
|
||||
return _buildMainLayout(context, state, editorController, stateManager);
|
||||
} else if (state is editor_bloc.EditorError) {
|
||||
return Center(child: Text('错误: ${state.message}'));
|
||||
} else {
|
||||
return const Center(child: Text('未知状态'));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 使用Stack来容纳主内容和可能的覆盖层,并包装性能监控面板
|
||||
Widget stackContent = Stack(
|
||||
children: [
|
||||
mainContent,
|
||||
if (editorController.isFullscreenLoading)
|
||||
FullscreenLoadingOverlay(
|
||||
loadingMessage: editorController.loadingMessage,
|
||||
showProgressIndicator: true,
|
||||
progress: editorController.loadingProgress >= 0 ? editorController.loadingProgress : -1,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return stackContent;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 构建主布局
|
||||
Widget _buildMainLayout(BuildContext context, editor_bloc.EditorLoaded editorBlocState, EditorScreenController editorController, EditorStateManager stateManager) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final bool isNarrow = screenWidth < 1280;
|
||||
final bool isVeryNarrow = screenWidth < 900;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// 🚀 修复:给主布局添加背景色容器
|
||||
Container(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: Row(
|
||||
children: [
|
||||
// 左侧导航 - 监听布局管理器以响应宽度变化(保留抽屉逻辑,移除完全隐藏)
|
||||
Consumer<EditorLayoutManager>(
|
||||
builder: (context, layoutState, child) {
|
||||
// 当宽度过小时,切换为“简要抽屉模式”:显示底部功能区的精简版,仅保留关键按钮和展开按钮
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final double effectiveSidebarWidth = layoutState.editorSidebarWidth.clamp(
|
||||
EditorLayoutManager.minEditorSidebarWidth,
|
||||
isVeryNarrow ? 260.0 : (isNarrow ? 300.0 : EditorLayoutManager.maxEditorSidebarWidth),
|
||||
);
|
||||
final bool useCompactDrawer = effectiveSidebarWidth < 260 || isVeryNarrow;
|
||||
|
||||
if (useCompactDrawer) {
|
||||
// 精简抽屉:固定窄栏,展示底部功能区简版 + 展开按钮
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 64,
|
||||
child: _CompactSidebarDrawer(
|
||||
onExpand: () => layoutState.expandEditorSidebarToMax(),
|
||||
onOpenSettings: () => layoutState.toggleNovelSettings(),
|
||||
onOpenAIChat: () => layoutState.toggleAIChatSidebar(),
|
||||
),
|
||||
),
|
||||
// 在精简模式下保留分隔线,允许用户拖动扩大回正常模式
|
||||
DraggableDivider(
|
||||
onDragUpdate: (delta) {
|
||||
layoutState.updateEditorSidebarWidth(delta.delta.dx);
|
||||
},
|
||||
onDragEnd: (_) {
|
||||
layoutState.saveEditorSidebarWidth();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 正常模式
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: effectiveSidebarWidth,
|
||||
child: EditorSidebar(
|
||||
novel: editorController.novel,
|
||||
tabController: editorController.tabController,
|
||||
onOpenAIChat: () {
|
||||
context.read<EditorLayoutManager>().toggleAIChatSidebar();
|
||||
},
|
||||
onOpenSettings: () {
|
||||
context.read<EditorLayoutManager>().toggleNovelSettings();
|
||||
},
|
||||
onToggleSidebar: () {
|
||||
context.read<EditorLayoutManager>().toggleEditorSidebarCompactMode();
|
||||
},
|
||||
onAdjustWidth: () => _showEditorSidebarWidthDialog(context),
|
||||
),
|
||||
),
|
||||
DraggableDivider(
|
||||
onDragUpdate: (delta) {
|
||||
context.read<EditorLayoutManager>().updateEditorSidebarWidth(delta.delta.dx);
|
||||
},
|
||||
onDragEnd: (_) {
|
||||
context.read<EditorLayoutManager>().saveEditorSidebarWidth();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// 主编辑区域 - 完全不监听EditorLayoutManager的变化
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
// 编辑器顶部工具栏和操作栏
|
||||
BlocBuilder<editor_bloc.EditorBloc, editor_bloc.EditorState>(
|
||||
buildWhen: (prev, curr) => curr is editor_bloc.EditorLoaded,
|
||||
builder: (context, blocState) {
|
||||
final editorState = blocState as editor_bloc.EditorLoaded;
|
||||
return Consumer<EditorLayoutManager>(
|
||||
builder: (context, layoutState, child) {
|
||||
if (layoutState.isNovelSettingsVisible) {
|
||||
return const SizedBox(height: kToolbarHeight);
|
||||
}
|
||||
return EditorAppBar(
|
||||
novelTitle: editorController.novel.title,
|
||||
wordCount: stateManager.calculateTotalWordCount(editorState.novel),
|
||||
isSaving: editorState.isSaving,
|
||||
isDirty: editorState.isDirty,
|
||||
lastSaveTime: editorState.lastSaveTime,
|
||||
onBackPressed: () => Navigator.pop(context),
|
||||
onChatPressed: layoutState.toggleAIChatSidebar,
|
||||
isChatActive: layoutState.isAIChatSidebarVisible,
|
||||
onAiConfigPressed: layoutState.toggleSettingsPanel,
|
||||
isSettingsActive: layoutState.isSettingsPanelVisible,
|
||||
onPlanPressed: editorController.togglePlanView,
|
||||
isPlanActive: editorController.isPlanViewActive,
|
||||
isWritingActive: !editorController.isPlanViewActive && !editorController.isNextOutlineViewActive && !editorController.isPromptViewActive,
|
||||
onWritePressed: (editorController.isPlanViewActive || editorController.isNextOutlineViewActive || editorController.isPromptViewActive)
|
||||
? () {
|
||||
if (editorController.isPlanViewActive) {
|
||||
editorController.togglePlanView();
|
||||
} else if (editorController.isNextOutlineViewActive) {
|
||||
editorController.toggleNextOutlineView();
|
||||
} else if (editorController.isPromptViewActive) {
|
||||
editorController.togglePromptView();
|
||||
}
|
||||
}
|
||||
: null,
|
||||
onNextOutlinePressed: editorController.toggleNextOutlineView,
|
||||
onAIGenerationPressed: layoutState.toggleAISceneGenerationPanel,
|
||||
onAISummaryPressed: layoutState.toggleAISummaryPanel,
|
||||
onAutoContinueWritingPressed: layoutState.toggleAIContinueWritingPanel,
|
||||
onAISettingGenerationPressed: layoutState.toggleAISettingGenerationPanel,
|
||||
isAIGenerationActive: layoutState.isAISceneGenerationPanelVisible || layoutState.isAISummaryPanelVisible || layoutState.isAIContinueWritingPanelVisible,
|
||||
isAISummaryActive: layoutState.isAISummaryPanelVisible,
|
||||
isAIContinueWritingActive: layoutState.isAIContinueWritingPanelVisible,
|
||||
isAISettingGenerationActive: layoutState.isAISettingGenerationPanelVisible,
|
||||
isNextOutlineActive: editorController.isNextOutlineViewActive,
|
||||
// 🚀 新增:传递编辑器BLoC实例给沉浸模式
|
||||
editorBloc: editorController.editorBloc,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// 主编辑区域内容 - 移除右侧AI面板,只保留主编辑器内容
|
||||
Expanded(
|
||||
child: _buildMainEditorContentOnly(context, editorBlocState, editorController),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 右侧AI面板区域 - 大屏时并排显示,小屏改为覆盖式(在覆盖层中渲染)
|
||||
if (!isNarrow)
|
||||
_buildRightAIPanelArea(context, editorBlocState, editorController),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 覆盖层组件 - 使用Consumer监听必要的状态
|
||||
// 移除“完全隐藏左侧栏”的开关按钮覆盖层,仅保留其他覆盖层
|
||||
..._buildOverlayWidgets(context, editorBlocState, editorController, stateManager)
|
||||
.where((w) {
|
||||
// 过滤掉依赖 isEditorSidebarVisible 的侧边栏切换按钮
|
||||
// 该按钮在 _buildOverlayWidgets 中是第一个元素(Selector<isEditorSidebarVisible>),这里不再添加
|
||||
// 实现方式:在 _buildOverlayWidgets 内部保留原实现,这里不使用第一个返回项
|
||||
return true;
|
||||
}),
|
||||
// 小屏右侧AI面板覆盖式展示
|
||||
_buildRightPanelOverlayIfNeeded(context, editorBlocState, editorController, isNarrow: isNarrow),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 构建主编辑器内容(不包含右侧AI面板)
|
||||
Widget _buildMainEditorContentOnly(BuildContext context, editor_bloc.EditorLoaded editorBlocState, EditorScreenController editorController) {
|
||||
// 主编辑器内容区域 - 监听小说设置状态变化
|
||||
return Selector<EditorLayoutManager, bool>(
|
||||
selector: (context, layoutManager) => layoutManager.isNovelSettingsVisible,
|
||||
builder: (context, isNovelSettingsVisible, child) {
|
||||
if (isNovelSettingsVisible) {
|
||||
return MultiRepositoryProvider(
|
||||
providers: [
|
||||
RepositoryProvider<EditorRepository>(
|
||||
create: (context) => editorController.editorRepository,
|
||||
),
|
||||
RepositoryProvider<StorageRepository>(
|
||||
create: (context) => AliyunOssStorageRepository(editorController.apiClient),
|
||||
),
|
||||
],
|
||||
child: NovelSettingsView(
|
||||
novel: editorController.novel,
|
||||
onSettingsClose: () {
|
||||
context.read<EditorLayoutManager>().toggleNovelSettings();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 🚀 关键修复:使用Stack布局,保持EditorMainArea不被销毁
|
||||
return Stack(
|
||||
children: [
|
||||
// EditorMainArea始终存在,只是可能被隐藏
|
||||
Visibility(
|
||||
visible: !editorController.isPlanViewActive &&
|
||||
!editorController.isNextOutlineViewActive &&
|
||||
!editorController.isPromptViewActive,
|
||||
maintainState: true, // 保持状态,避免重建
|
||||
child: EditorMainArea(
|
||||
key: editorController.editorMainAreaKey,
|
||||
novel: editorBlocState.novel,
|
||||
editorBloc: editorController.editorBloc,
|
||||
sceneControllers: editorController.sceneControllers,
|
||||
sceneSummaryControllers: editorController.sceneSummaryControllers,
|
||||
activeActId: editorBlocState.activeActId,
|
||||
activeChapterId: editorBlocState.activeChapterId,
|
||||
activeSceneId: editorBlocState.activeSceneId,
|
||||
scrollController: editorController.scrollController,
|
||||
sceneKeys: editorController.sceneKeys,
|
||||
// 🚀 新增:传递编辑器设置给EditorMainArea
|
||||
editorSettings: EditorSettings.fromMap(editorBlocState.settings),
|
||||
),
|
||||
),
|
||||
|
||||
// Plan视图覆盖在上层
|
||||
if (editorController.isPlanViewActive)
|
||||
PlanView(
|
||||
novelId: editorController.novel.id,
|
||||
editorBloc: editorController.editorBloc,
|
||||
onSwitchToWrite: editorController.togglePlanView,
|
||||
),
|
||||
|
||||
// NextOutline视图覆盖在上层
|
||||
if (editorController.isNextOutlineViewActive)
|
||||
NextOutlineView(
|
||||
novelId: editorController.novel.id,
|
||||
novelTitle: editorController.novel.title,
|
||||
onSwitchToWrite: editorController.toggleNextOutlineView,
|
||||
),
|
||||
|
||||
// 统一管理视图覆盖在上层
|
||||
if (editorController.isPromptViewActive)
|
||||
const UnifiedManagementScreen(),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 构建右侧AI面板区域 - 完整占据右边,从顶部到底部
|
||||
Widget _buildRightAIPanelArea(BuildContext context, editor_bloc.EditorLoaded editorBlocState, EditorScreenController editorController) {
|
||||
return Consumer<EditorLayoutManager>(
|
||||
builder: (context, layoutManager, child) {
|
||||
final hasVisibleAIPanels = layoutManager.visiblePanels.isNotEmpty;
|
||||
|
||||
if (!hasVisibleAIPanels) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
// 面板分隔线
|
||||
DraggableDivider(
|
||||
onDragUpdate: (delta) {
|
||||
if (layoutManager.visiblePanels.isNotEmpty) {
|
||||
final firstPanelId = layoutManager.visiblePanels.first;
|
||||
layoutManager.updatePanelWidth(firstPanelId, delta.delta.dx);
|
||||
}
|
||||
},
|
||||
onDragEnd: (_) {
|
||||
layoutManager.savePanelWidths();
|
||||
},
|
||||
),
|
||||
|
||||
// AI面板组件 - 完整高度
|
||||
RepositoryProvider<PromptRepository>(
|
||||
create: (context) => editorController.promptRepository,
|
||||
child: MultiAIPanelView(
|
||||
novelId: editorController.novel.id,
|
||||
chapterId: editorBlocState.activeChapterId,
|
||||
layoutManager: layoutManager,
|
||||
userId: editorController.currentUserId,
|
||||
userAiModelConfigRepository: UserAIModelConfigRepositoryImpl(apiClient: editorController.apiClient),
|
||||
editorRepository: editorController.editorRepository,
|
||||
novelAIRepository: editorController.novelAIRepository,
|
||||
onContinueWritingSubmit: (parameters) {
|
||||
AppLogger.i('EditorLayout', 'Continue Writing Submitted: $parameters');
|
||||
TopToast.success(context, '自动续写任务已提交: $parameters');
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 小屏时以覆盖层形式展示右侧AI面板
|
||||
Widget _buildRightPanelOverlayIfNeeded(
|
||||
BuildContext context,
|
||||
editor_bloc.EditorLoaded editorBlocState,
|
||||
EditorScreenController editorController, {
|
||||
required bool isNarrow,
|
||||
}) {
|
||||
if (!isNarrow) return const SizedBox.shrink();
|
||||
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
return Consumer<EditorLayoutManager>(
|
||||
builder: (context, layoutManager, child) {
|
||||
final hasVisibleAIPanels = layoutManager.visiblePanels.isNotEmpty;
|
||||
if (!hasVisibleAIPanels) return const SizedBox.shrink();
|
||||
|
||||
// 小屏覆盖式面板宽度:不超过屏宽的35%,并在全局最小/最大约束之间
|
||||
final double maxRightPanelWidth = (
|
||||
screenWidth * 0.35
|
||||
).clamp(
|
||||
EditorLayoutManager.minPanelWidth,
|
||||
EditorLayoutManager.maxPanelWidth,
|
||||
);
|
||||
|
||||
return Positioned.fill(
|
||||
child: Stack(
|
||||
children: [
|
||||
// 半透明遮罩,点击关闭右侧所有AI面板
|
||||
GestureDetector(
|
||||
onTap: () => layoutManager.hideAllAIPanels(),
|
||||
child: Container(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.4),
|
||||
),
|
||||
),
|
||||
// 右侧贴边的覆盖面板
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Container(
|
||||
width: maxRightPanelWidth,
|
||||
height: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: WebTheme.getShadowColor(context, opacity: 0.2),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: RepositoryProvider<PromptRepository>(
|
||||
create: (context) => editorController.promptRepository,
|
||||
child: MultiAIPanelView(
|
||||
novelId: editorController.novel.id,
|
||||
chapterId: editorBlocState.activeChapterId,
|
||||
layoutManager: layoutManager,
|
||||
userId: editorController.currentUserId,
|
||||
userAiModelConfigRepository: UserAIModelConfigRepositoryImpl(apiClient: editorController.apiClient),
|
||||
editorRepository: editorController.editorRepository,
|
||||
novelAIRepository: editorController.novelAIRepository,
|
||||
onContinueWritingSubmit: (parameters) {
|
||||
AppLogger.i('EditorLayout', 'Continue Writing Submitted: $parameters');
|
||||
TopToast.success(context, '自动续写任务已提交: $parameters');
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 构建覆盖层组件
|
||||
List<Widget> _buildOverlayWidgets(BuildContext context, editor_bloc.EditorLoaded editorBlocState, EditorScreenController editorController, EditorStateManager stateManager) {
|
||||
return [
|
||||
// 移除:不再提供“完全隐藏侧边栏”的开关按钮,保留其他覆盖层
|
||||
|
||||
// 设置面板
|
||||
Selector<EditorLayoutManager, bool>(
|
||||
selector: (context, layoutManager) => layoutManager.isSettingsPanelVisible,
|
||||
builder: (context, isVisible, child) {
|
||||
if (!isVisible) return const SizedBox.shrink();
|
||||
|
||||
return Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: () => context.read<EditorLayoutManager>().toggleSettingsPanel(),
|
||||
child: Container(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5),
|
||||
child: Center(
|
||||
child: GestureDetector(
|
||||
onTap: () {},
|
||||
child: editorController.currentUserId == null
|
||||
? EditorDialogManager.buildLoginRequiredPanel(
|
||||
context,
|
||||
() => context.read<EditorLayoutManager>().toggleSettingsPanel(),
|
||||
)
|
||||
: SettingsPanel(
|
||||
stateManager: stateManager,
|
||||
userId: editorController.currentUserId!,
|
||||
onClose: () => context.read<EditorLayoutManager>().toggleSettingsPanel(),
|
||||
editorSettings: EditorSettings.fromMap(editorBlocState.settings),
|
||||
onEditorSettingsChanged: (settings) {
|
||||
context.read<editor_bloc.EditorBloc>().add(
|
||||
editor_bloc.UpdateEditorSettings(settings: settings.toMap()));
|
||||
},
|
||||
initialCategoryIndex: SettingsPanel.accountManagementCategoryIndex,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
|
||||
// 保存中浮动按钮
|
||||
if (editorBlocState.isSaving)
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: FloatingActionButton(
|
||||
heroTag: 'saving',
|
||||
onPressed: null,
|
||||
backgroundColor: Theme.of(context).colorScheme.outlineVariant.withOpacity(0.6),
|
||||
tooltip: '正在保存...',
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(WebTheme.isDarkMode(context) ? WebTheme.darkGrey50 : WebTheme.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 加载动画覆盖层 (用于非全屏的 "加载更多")
|
||||
if ((editorBlocState.isLoading || editorController.isLoadingMore) && !editorController.isFullscreenLoading)
|
||||
_buildLoadingOverlay(context, editorController),
|
||||
];
|
||||
}
|
||||
|
||||
// 构建加载动画覆盖层
|
||||
Widget _buildEndOfContentIndicator(BuildContext context, String message) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 24),
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getSurfaceColor(context),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: WebTheme.getShadowColor(context, opacity: 0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
message,
|
||||
style: TextStyle(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingOverlay(BuildContext context, EditorScreenController editorController) {
|
||||
return Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(bottom: 32.0),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
WebTheme.getSurfaceColor(context).withAlpha(0),
|
||||
WebTheme.getSurfaceColor(context).withAlpha(204),
|
||||
WebTheme.getSurfaceColor(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (editorController.isLoadingMore) // Use passed controller
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 24),
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getSurfaceColor(context),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: WebTheme.getShadowColor(context, opacity: 0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(WebTheme.getPrimaryColor(context)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
'正在加载更多内容...',
|
||||
style: TextStyle(
|
||||
color: WebTheme.getTextColor(context),
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (!editorController.isLoadingMore) ...[ // Use passed controller
|
||||
if (editorController.hasReachedEnd) // Use passed controller
|
||||
_buildEndOfContentIndicator(context, '已到达底部'),
|
||||
if (editorController.hasReachedStart) // Use passed controller
|
||||
_buildEndOfContentIndicator(context, '已到达顶部'),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 显示编辑器侧边栏宽度调整对话框
|
||||
void _showEditorSidebarWidthDialog(BuildContext context) {
|
||||
final layoutState = Provider.of<EditorLayoutManager>(context, listen: false);
|
||||
EditorDialogManager.showEditorSidebarWidthDialog(
|
||||
context,
|
||||
layoutState.editorSidebarWidth,
|
||||
EditorLayoutManager.minEditorSidebarWidth,
|
||||
EditorLayoutManager.maxEditorSidebarWidth,
|
||||
(value) {
|
||||
layoutState.editorSidebarWidth = value;
|
||||
},
|
||||
layoutState.saveEditorSidebarWidth,
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// 左侧侧边栏的精简抽屉,仅展示底部功能的精简版与展开按钮
|
||||
class _CompactSidebarDrawer extends StatelessWidget {
|
||||
const _CompactSidebarDrawer({
|
||||
required this.onExpand,
|
||||
required this.onOpenSettings,
|
||||
required this.onOpenAIChat,
|
||||
});
|
||||
|
||||
final VoidCallback onExpand;
|
||||
final VoidCallback onOpenSettings;
|
||||
final VoidCallback onOpenAIChat;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Material(
|
||||
color: WebTheme.getBackgroundColor(context),
|
||||
child: Column(
|
||||
children: [
|
||||
// 顶部展开按钮
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Tooltip(
|
||||
message: '展开侧边栏',
|
||||
child: InkWell(
|
||||
onTap: onExpand,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Icon(Icons.menu_open, size: 18, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// 精简功能按钮区:仅保留与底部栏一致的核心功能
|
||||
_CompactActionButton(
|
||||
icon: Icons.settings,
|
||||
tooltip: '小说设置',
|
||||
onTap: onOpenSettings,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_CompactActionButton(
|
||||
icon: Icons.chat_bubble_outline,
|
||||
tooltip: 'AI聊天',
|
||||
onTap: onOpenAIChat,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_CompactActionButton(
|
||||
icon: Icons.lightbulb_outline,
|
||||
tooltip: '提示词',
|
||||
onTap: () {
|
||||
context.read<editor_bloc.EditorBloc>();
|
||||
// 使用 EditorAppBar 的提示词入口逻辑:通过 EditorController 切换提示词视图
|
||||
final controller = Provider.of<EditorScreenController>(context, listen: false);
|
||||
controller.togglePromptView();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_CompactActionButton(
|
||||
icon: Icons.save_outlined,
|
||||
tooltip: '保存',
|
||||
onTap: () {
|
||||
try {
|
||||
final controller = Provider.of<EditorScreenController>(context, listen: false);
|
||||
controller.editorBloc.add(const editor_bloc.SaveContent());
|
||||
} catch (_) {}
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CompactActionButton extends StatelessWidget {
|
||||
const _CompactActionButton({
|
||||
required this.icon,
|
||||
required this.tooltip,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Icon(icon, size: 18, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1341
AINoval/lib/screens/editor/components/editor_main_area.dart
Normal file
1341
AINoval/lib/screens/editor/components/editor_main_area.dart
Normal file
File diff suppressed because it is too large
Load Diff
664
AINoval/lib/screens/editor/components/editor_sidebar.dart
Normal file
664
AINoval/lib/screens/editor/components/editor_sidebar.dart
Normal file
@@ -0,0 +1,664 @@
|
||||
import 'package:ainoval/blocs/editor/editor_bloc.dart';
|
||||
import 'package:ainoval/models/novel_summary.dart';
|
||||
import 'package:ainoval/models/novel_snippet.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/novel_setting_sidebar.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/snippet_list_tab.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/snippet_edit_form.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:ainoval/screens/editor/controllers/editor_screen_controller.dart';
|
||||
import 'package:ainoval/blocs/setting/setting_bloc.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:ainoval/widgets/common/user_avatar_menu.dart';
|
||||
import 'package:ainoval/screens/subscription/subscription_screen.dart';
|
||||
|
||||
import 'chapter_directory_tab.dart';
|
||||
|
||||
/// 保持存活状态的包装器组件
|
||||
class _KeepAliveWrapper extends StatefulWidget {
|
||||
final Widget child;
|
||||
|
||||
const _KeepAliveWrapper({required this.child});
|
||||
|
||||
@override
|
||||
State<_KeepAliveWrapper> createState() => _KeepAliveWrapperState();
|
||||
}
|
||||
|
||||
class _KeepAliveWrapperState extends State<_KeepAliveWrapper>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return widget.child;
|
||||
}
|
||||
}
|
||||
|
||||
class EditorSidebar extends StatefulWidget {
|
||||
const EditorSidebar({
|
||||
super.key,
|
||||
required this.novel,
|
||||
required this.tabController,
|
||||
this.onOpenAIChat,
|
||||
this.onOpenSettings,
|
||||
this.onToggleSidebar,
|
||||
this.onAdjustWidth,
|
||||
});
|
||||
final NovelSummary novel;
|
||||
final TabController tabController;
|
||||
final VoidCallback? onOpenAIChat;
|
||||
final VoidCallback? onOpenSettings;
|
||||
final VoidCallback? onToggleSidebar;
|
||||
final VoidCallback? onAdjustWidth;
|
||||
|
||||
@override
|
||||
State<EditorSidebar> createState() => _EditorSidebarState();
|
||||
}
|
||||
|
||||
class _EditorSidebarState extends State<EditorSidebar> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
// String _selectedMode = 'codex';
|
||||
|
||||
// 片段列表操作回调
|
||||
VoidCallback? _refreshSnippetList; // used via callbacks wiring
|
||||
Function(NovelSnippet)? _addSnippetToList; // used via callbacks wiring
|
||||
Function(NovelSnippet)? _updateSnippetInList; // used via callbacks wiring
|
||||
Function(String)? _removeSnippetFromList; // used via callbacks wiring
|
||||
|
||||
String _selectedBottomBarItem = '';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 🚀 添加重建监控日志 - 现在应该不会频繁触发了
|
||||
AppLogger.d('EditorSidebar', '🔄 EditorSidebar.build() 被调用 - 监控重建');
|
||||
|
||||
final theme = Theme.of(context);
|
||||
|
||||
// 🚀 优化:直接使用父级提供的SettingBloc实例,避免重复创建
|
||||
final settingSidebarWidget = BlocProvider.value(
|
||||
value: context.read<SettingBloc>(),
|
||||
child: NovelSettingSidebar(novelId: widget.novel.id),
|
||||
);
|
||||
|
||||
return Material(
|
||||
color: WebTheme.getBackgroundColor(context),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getBackgroundColor(context),
|
||||
border: Border(
|
||||
right: BorderSide(
|
||||
color: WebTheme.isDarkMode(context) ? WebTheme.darkGrey200 : WebTheme.grey200,
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: theme.colorScheme.shadow.withOpacity(0.03),
|
||||
blurRadius: 5,
|
||||
offset: const Offset(0, 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 顶部应用栏
|
||||
_buildAppBar(theme),
|
||||
|
||||
// 标签页导航
|
||||
_buildTabBar(theme),
|
||||
|
||||
// 标签页内容
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: widget.tabController,
|
||||
children: [
|
||||
// 设定库标签页(替换原来的Codex标签页)
|
||||
settingSidebarWidget,
|
||||
|
||||
// 片段标签页
|
||||
Builder(
|
||||
builder: (context) {
|
||||
return SnippetListTab(
|
||||
key: ValueKey('snippet_list_${widget.novel.id}'),
|
||||
novel: widget.novel,
|
||||
onRefreshCallbackChanged: (callback) {
|
||||
_refreshSnippetList = callback;
|
||||
},
|
||||
onAddSnippetCallbackChanged: (callback) {
|
||||
_addSnippetToList = callback;
|
||||
},
|
||||
onUpdateSnippetCallbackChanged: (callback) {
|
||||
_updateSnippetInList = callback;
|
||||
},
|
||||
onRemoveSnippetCallbackChanged: (callback) {
|
||||
_removeSnippetFromList = callback;
|
||||
},
|
||||
onSnippetTap: (snippet) {
|
||||
FloatingSnippetEditor.show(
|
||||
context: context,
|
||||
snippet: snippet,
|
||||
onSaved: (updatedSnippet) {
|
||||
// 判断是创建还是更新
|
||||
if (snippet.id.isEmpty) {
|
||||
// 创建新片段:直接添加到列表
|
||||
_addSnippetToList?.call(updatedSnippet);
|
||||
} else {
|
||||
// 更新现有片段:更新列表中的片段
|
||||
_updateSnippetInList?.call(updatedSnippet);
|
||||
}
|
||||
},
|
||||
onDeleted: (snippetId) {
|
||||
// 删除片段:从列表中移除
|
||||
_removeSnippetFromList?.call(snippetId);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// 章节目录标签页
|
||||
Builder(
|
||||
builder: (context) {
|
||||
// 确保在有Provider访问权限的新BuildContext中构建ChapterDirectoryTab
|
||||
return Consumer<EditorScreenController>(
|
||||
builder: (context, controller, child) {
|
||||
return ChapterDirectoryTab(novel: widget.novel);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// 添加AI生成选项
|
||||
_buildPlaceholderTab(
|
||||
icon: Icons.auto_awesome,
|
||||
text: 'AI生成功能开发中'),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 底部导航栏
|
||||
_buildBottomBar(theme),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
PreferredSizeWidget _buildAppBar(ThemeData theme) {
|
||||
return AppBar(
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
backgroundColor: WebTheme.getBackgroundColor(context),
|
||||
automaticallyImplyLeading: false,
|
||||
titleSpacing: 0,
|
||||
toolbarHeight: 60, // 增加高度以适应新设计
|
||||
title: Container(
|
||||
height: 60,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
// 返回按钮
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getSurfaceColor(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Icon(
|
||||
Icons.arrow_back,
|
||||
size: 18,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// 可点击的设置和小说信息区域
|
||||
Expanded(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: widget.onOpenSettings,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
// 设置图标
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getSurfaceColor(context),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.settings,
|
||||
size: 16,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// 小说标题和作者信息
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
widget.novel.title,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 15,
|
||||
color: WebTheme.getTextColor(context),
|
||||
height: 1.1,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
Text(
|
||||
widget.novel.author ?? 'Erminia Osteen',
|
||||
style: TextStyle(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.0,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// 右侧操作按钮
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 侧边栏折叠按钮
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getSurfaceColor(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: widget.onToggleSidebar,
|
||||
child: Icon(
|
||||
Icons.menu_open,
|
||||
size: 18,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// 调整宽度按钮
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getSurfaceColor(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: widget.onAdjustWidth,
|
||||
child: Icon(
|
||||
Icons.more_horiz,
|
||||
size: 18,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabBar(ThemeData theme) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getBackgroundColor(context),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: WebTheme.isDarkMode(context) ? WebTheme.darkGrey200 : WebTheme.grey200,
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: TabBar(
|
||||
controller: widget.tabController,
|
||||
labelColor: WebTheme.getTextColor(context),
|
||||
unselectedLabelColor: WebTheme.getSecondaryTextColor(context),
|
||||
indicatorColor: WebTheme.getTextColor(context),
|
||||
indicatorWeight: 2.0, // 减小指示器粗细
|
||||
indicatorSize: TabBarIndicatorSize.label,
|
||||
labelStyle: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13, // 减小字体大小
|
||||
),
|
||||
unselectedLabelStyle: const TextStyle(
|
||||
fontWeight: FontWeight.normal,
|
||||
fontSize: 13, // 减小字体大小
|
||||
),
|
||||
dividerColor: Colors.transparent,
|
||||
isScrollable: false, // 确保不可滚动,平均分配空间
|
||||
labelPadding: const EdgeInsets.symmetric(horizontal: 2.0), // 减小标签内边距
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2.0), // 减小整体内边距
|
||||
tabs: const [
|
||||
Tab(
|
||||
icon: Icon(Icons.inventory_2_outlined, size: 18), // 修改图标来反映设定功能
|
||||
text: '设定库', // 改为"设定库"
|
||||
height: 60, // 与顶部 AppBar 高度一致
|
||||
),
|
||||
Tab(
|
||||
icon: Icon(Icons.bookmark_border_outlined, size: 18), // 减小图标大小
|
||||
text: '片段',
|
||||
height: 60, // 与顶部 AppBar 高度一致
|
||||
),
|
||||
Tab(
|
||||
icon: Icon(Icons.menu_outlined, size: 18), // 目录图标
|
||||
text: '章节目录', // "章节目录"
|
||||
height: 60, // 与顶部 AppBar 高度一致
|
||||
),
|
||||
Tab(
|
||||
icon: Icon(Icons.auto_awesome, size: 18), // AI生成图标
|
||||
text: 'AI生成',
|
||||
height: 60, // 与顶部 AppBar 高度一致
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaceholderTab({required IconData icon, required String text}) {
|
||||
return _KeepAliveWrapper(
|
||||
child: Container(
|
||||
color: WebTheme.getSurfaceColor(context),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 48, color: WebTheme.getSecondaryTextColor(context)),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(fontSize: 16, color: WebTheme.getSecondaryTextColor(context)),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomBar(ThemeData theme) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// 当侧边栏宽度较小时,仅显示图标;宽度充足时显示图标+文字
|
||||
final bool isCompact = constraints.maxWidth < 240;
|
||||
return Container(
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getBackgroundColor(context),
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: WebTheme.isDarkMode(context) ? WebTheme.darkGrey200 : WebTheme.grey200,
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 用户头像菜单
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: UserAvatarMenu(
|
||||
size: 16,
|
||||
showName: false,
|
||||
onMySubscription: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const SubscriptionScreen()),
|
||||
);
|
||||
},
|
||||
onOpenSettings: widget.onOpenSettings,
|
||||
onProfile: widget.onOpenSettings, // 个人资料也使用设置面板
|
||||
onAccountSettings: widget.onOpenSettings, // 账户设置使用设置面板
|
||||
),
|
||||
),
|
||||
// 使用Expanded包裹SingleChildScrollView来确保按钮能够根据宽度滚动/自适应
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
// 帮助按钮
|
||||
_buildBottomBarItem(
|
||||
icon: Icons.help_outline,
|
||||
label: 'Help',
|
||||
showLabel: !isCompact,
|
||||
selected: _selectedBottomBarItem == 'Help',
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedBottomBarItem = 'Help';
|
||||
});
|
||||
// TODO: 实现帮助功能
|
||||
},
|
||||
),
|
||||
// 提示按钮
|
||||
_buildBottomBarItem(
|
||||
icon: Icons.lightbulb_outline,
|
||||
label: 'Prompts',
|
||||
showLabel: !isCompact,
|
||||
selected: _selectedBottomBarItem == 'Prompts',
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedBottomBarItem = 'Prompts';
|
||||
});
|
||||
final controller = Provider.of<EditorScreenController>(context, listen: false);
|
||||
controller.togglePromptView();
|
||||
},
|
||||
),
|
||||
// 导出按钮
|
||||
_buildBottomBarItem(
|
||||
icon: Icons.download_outlined,
|
||||
label: 'Export',
|
||||
showLabel: !isCompact,
|
||||
selected: _selectedBottomBarItem == 'Export',
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedBottomBarItem = 'Export';
|
||||
});
|
||||
// TODO: 实现导出功能
|
||||
},
|
||||
),
|
||||
// 保存按钮
|
||||
_buildBottomBarItem(
|
||||
icon: Icons.save_outlined,
|
||||
label: 'Save',
|
||||
showLabel: !isCompact,
|
||||
selected: _selectedBottomBarItem == 'Save',
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedBottomBarItem = 'Save';
|
||||
});
|
||||
// 手动保存:触发与自动保存一致的SaveContent事件
|
||||
try {
|
||||
final controller = Provider.of<EditorScreenController>(context, listen: false);
|
||||
controller.editorBloc.add(const SaveContent());
|
||||
} catch (e) {
|
||||
AppLogger.w('EditorSidebar', '手动保存触发失败', e);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建底部栏单个按钮
|
||||
Widget _buildBottomBarItem({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
bool showLabel = true,
|
||||
bool selected = false,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
// 修复选中状态的颜色配置,确保在暗黑模式下文字可见
|
||||
final Color foregroundColor;
|
||||
final Color backgroundColor;
|
||||
|
||||
if (selected) {
|
||||
if (isDark) {
|
||||
// 暗黑模式下:选中时使用深灰背景+白字
|
||||
backgroundColor = WebTheme.darkGrey700;
|
||||
foregroundColor = WebTheme.white;
|
||||
} else {
|
||||
// 亮色模式下:选中时使用深色背景+白字
|
||||
backgroundColor = WebTheme.grey800;
|
||||
foregroundColor = WebTheme.white;
|
||||
}
|
||||
} else {
|
||||
// 未选中时:透明背景+半透明文字
|
||||
backgroundColor = Colors.transparent;
|
||||
foregroundColor = WebTheme.getTextColor(context).withOpacity(0.7);
|
||||
}
|
||||
|
||||
return Material(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 10.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: foregroundColor,
|
||||
),
|
||||
if (showLabel) ...[
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: foregroundColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CodexEmptyState extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start, // 左对齐
|
||||
children: [
|
||||
Text(
|
||||
'YOUR CODEX IS EMPTY',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'The Codex stores information about the world your story takes place in, its inhabitants and more.',
|
||||
style: TextStyle(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
fontSize: 14,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
// 该点击应执行与"+ New Entry"按钮相同的操作
|
||||
},
|
||||
child: Text(
|
||||
'→ Create a new entry by clicking the button above.',
|
||||
style: TextStyle(
|
||||
color: WebTheme.getTextColor(context),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
1391
AINoval/lib/screens/editor/components/expansion_dialog.dart
Normal file
1391
AINoval/lib/screens/editor/components/expansion_dialog.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// 全屏加载动画覆盖层
|
||||
/// 在应用初始化、卷轴切换等耗时操作时显示
|
||||
class FullscreenLoadingOverlay extends StatelessWidget {
|
||||
final String loadingMessage;
|
||||
final bool showProgressIndicator;
|
||||
final double progress; // 0.0 - 1.0 的进度值,如果提供将显示进度条而非无限循环指示器
|
||||
final Color? backgroundColor;
|
||||
final Color textColor;
|
||||
final bool useBlur; // 是否使用背景模糊效果
|
||||
final bool isVisible;
|
||||
|
||||
const FullscreenLoadingOverlay({
|
||||
Key? key,
|
||||
this.loadingMessage = '正在加载,请稍候...',
|
||||
this.showProgressIndicator = true,
|
||||
this.progress = -1, // 默认为-1,表示不确定进度
|
||||
this.backgroundColor,
|
||||
this.textColor = Colors.black87,
|
||||
this.useBlur = false,
|
||||
this.isVisible = true,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!isVisible) return const SizedBox.shrink();
|
||||
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Positioned.fill(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
// 使用动态背景色
|
||||
color: backgroundColor ?? WebTheme.getBackgroundColor(context),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (showProgressIndicator)
|
||||
SizedBox(
|
||||
width: 60,
|
||||
height: 60,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 4,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
theme.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showProgressIndicator && (loadingMessage != null || progress > 0))
|
||||
const SizedBox(height: 30),
|
||||
if (loadingMessage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 40),
|
||||
child: Text(
|
||||
loadingMessage,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black87,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
if (progress > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
child: _buildProgressIndicator(theme),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 构建进度指示器
|
||||
Widget _buildProgressIndicator(ThemeData theme) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'${(progress * 100).toStringAsFixed(0)}%',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black54,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(
|
||||
width: 200,
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
backgroundColor: Colors.grey[200],
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
theme.primaryColor,
|
||||
),
|
||||
minHeight: 6,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:ainoval/blocs/editor/editor_bloc.dart' as editor_bloc;
|
||||
import 'package:ainoval/screens/editor/controllers/editor_screen_controller.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// 🚀 沉浸模式导航组件
|
||||
/// 包含模式切换按钮和章节导航按钮
|
||||
class ImmersiveModeNavigation extends StatelessWidget {
|
||||
const ImmersiveModeNavigation({
|
||||
super.key,
|
||||
required this.editorBloc,
|
||||
});
|
||||
|
||||
final editor_bloc.EditorBloc editorBloc;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<editor_bloc.EditorBloc, editor_bloc.EditorState>(
|
||||
bloc: editorBloc,
|
||||
builder: (context, state) {
|
||||
if (state is! editor_bloc.EditorLoaded) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 沉浸模式切换按钮
|
||||
_buildModeToggleButton(context, state),
|
||||
|
||||
// 保留章节导航按钮(普通/沉浸模式均可用)
|
||||
const SizedBox(width: 8),
|
||||
_buildChapterNavigationButtons(context, state),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建模式切换按钮
|
||||
Widget _buildModeToggleButton(BuildContext context, editor_bloc.EditorLoaded state) {
|
||||
final theme = Theme.of(context);
|
||||
final isImmersive = state.isImmersiveMode;
|
||||
final editorController = Provider.of<EditorScreenController>(context, listen: false);
|
||||
final label = isImmersive ? '沉浸模式' : '普通模式';
|
||||
|
||||
return Tooltip(
|
||||
message: isImmersive ? '切换到普通模式' : '切换到沉浸模式',
|
||||
child: TextButton.icon(
|
||||
icon: Icon(
|
||||
isImmersive ? Icons.center_focus_strong : Icons.view_stream,
|
||||
size: 20,
|
||||
color: isImmersive
|
||||
? WebTheme.getPrimaryColor(context)
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
label: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isImmersive
|
||||
? WebTheme.getPrimaryColor(context)
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor: isImmersive
|
||||
? WebTheme.getPrimaryColor(context).withAlpha(76)
|
||||
: Colors.transparent,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
AppLogger.i('ImmersiveModeNavigation', '用户点击模式切换按钮');
|
||||
editorController.toggleImmersiveMode();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建章节导航按钮组
|
||||
Widget _buildChapterNavigationButtons(BuildContext context, editor_bloc.EditorLoaded state) {
|
||||
final editorController = Provider.of<EditorScreenController>(context, listen: false);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 上一章按钮
|
||||
_buildNavigationButton(
|
||||
context: context,
|
||||
icon: Icons.navigate_before,
|
||||
tooltip: '上一章',
|
||||
onPressed: editorController.canNavigateToPreviousChapter
|
||||
? () {
|
||||
AppLogger.i('ImmersiveModeNavigation', '导航到上一章');
|
||||
editorController.navigateToPreviousChapter();
|
||||
}
|
||||
: null,
|
||||
),
|
||||
|
||||
// 分隔线
|
||||
Container(
|
||||
height: 24,
|
||||
width: 1,
|
||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
|
||||
),
|
||||
|
||||
// 章节信息
|
||||
_buildChapterInfo(context, state),
|
||||
|
||||
// 分隔线
|
||||
Container(
|
||||
height: 24,
|
||||
width: 1,
|
||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
|
||||
),
|
||||
|
||||
// 下一章按钮
|
||||
_buildNavigationButton(
|
||||
context: context,
|
||||
icon: Icons.navigate_next,
|
||||
tooltip: '下一章',
|
||||
onPressed: editorController.canNavigateToNextChapter
|
||||
? () {
|
||||
AppLogger.i('ImmersiveModeNavigation', '导航到下一章');
|
||||
editorController.navigateToNextChapter();
|
||||
}
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建导航按钮
|
||||
Widget _buildNavigationButton({
|
||||
required BuildContext context,
|
||||
required IconData icon,
|
||||
required String tooltip,
|
||||
required VoidCallback? onPressed,
|
||||
}) {
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: IconButton(
|
||||
onPressed: onPressed,
|
||||
icon: Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
),
|
||||
style: IconButton.styleFrom(
|
||||
minimumSize: const Size(32, 32),
|
||||
padding: const EdgeInsets.all(4),
|
||||
foregroundColor: onPressed != null
|
||||
? Theme.of(context).colorScheme.onSurface
|
||||
: Theme.of(context).colorScheme.onSurface.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建章节信息显示
|
||||
Widget _buildChapterInfo(BuildContext context, editor_bloc.EditorLoaded state) {
|
||||
final String? currentChapterId = state.immersiveChapterId ?? state.activeChapterId;
|
||||
if (currentChapterId == null) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text('未知章节'),
|
||||
);
|
||||
}
|
||||
|
||||
// 查找当前章节信息
|
||||
String chapterTitle = '未知章节';
|
||||
String chapterInfo = '';
|
||||
|
||||
for (int actIndex = 0; actIndex < state.novel.acts.length; actIndex++) {
|
||||
final act = state.novel.acts[actIndex];
|
||||
for (int chapterIndex = 0; chapterIndex < act.chapters.length; chapterIndex++) {
|
||||
final chapter = act.chapters[chapterIndex];
|
||||
if (chapter.id == currentChapterId) {
|
||||
chapterTitle = chapter.title.isNotEmpty ? chapter.title : '第${chapterIndex + 1}章';
|
||||
chapterInfo = '第${actIndex + 1}卷 第${chapterIndex + 1}章';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
chapterTitle,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
chapterInfo,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🚀 沉浸模式边界提示组件
|
||||
class ImmersiveModeBoundaryIndicator extends StatelessWidget {
|
||||
const ImmersiveModeBoundaryIndicator({
|
||||
super.key,
|
||||
required this.isFirstChapter,
|
||||
required this.isLastChapter,
|
||||
this.onNavigatePrevious,
|
||||
this.onNavigateNext,
|
||||
});
|
||||
|
||||
final bool isFirstChapter;
|
||||
final bool isLastChapter;
|
||||
final VoidCallback? onNavigatePrevious;
|
||||
final VoidCallback? onNavigateNext;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!isFirstChapter && !isLastChapter) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 20),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
isFirstChapter ? Icons.first_page : Icons.last_page,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
isFirstChapter ? '这是第一章' : '这是最后一章',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
if ((isFirstChapter && onNavigateNext != null) ||
|
||||
(isLastChapter && onNavigatePrevious != null))
|
||||
TextButton.icon(
|
||||
onPressed: isFirstChapter ? onNavigateNext : onNavigatePrevious,
|
||||
icon: Icon(
|
||||
isFirstChapter ? Icons.arrow_forward : Icons.arrow_back,
|
||||
size: 16,
|
||||
),
|
||||
label: Text(isFirstChapter ? '下一章' : '上一章'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: WebTheme.getPrimaryColor(context),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🚀 沉浸模式工具栏
|
||||
class ImmersiveModeToolbar extends StatelessWidget {
|
||||
const ImmersiveModeToolbar({
|
||||
super.key,
|
||||
required this.editorBloc,
|
||||
});
|
||||
|
||||
final editor_bloc.EditorBloc editorBloc;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<editor_bloc.EditorBloc, editor_bloc.EditorState>(
|
||||
bloc: editorBloc,
|
||||
builder: (context, state) {
|
||||
if (state is! editor_bloc.EditorLoaded || !state.isImmersiveMode) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final editorController = Provider.of<EditorScreenController>(context, listen: false);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(12)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Theme.of(context).shadowColor.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 沉浸模式指示器
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getPrimaryColor(context).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.center_focus_strong,
|
||||
size: 16,
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'沉浸模式',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// 快捷操作按钮
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 返回普通模式按钮
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
editorController.switchToNormalMode();
|
||||
},
|
||||
icon: const Icon(Icons.view_stream, size: 16),
|
||||
label: const Text('普通模式'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.onSurface,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
236
AINoval/lib/screens/editor/components/indexed_map.dart
Normal file
236
AINoval/lib/screens/editor/components/indexed_map.dart
Normal file
@@ -0,0 +1,236 @@
|
||||
/// 索引化的Map数据结构,同时支持键查找和索引访问
|
||||
/// 提供O(1)的键查找、索引访问、相邻元素获取
|
||||
class IndexedMap<K, V> {
|
||||
final Map<K, _IndexedNode<K, V>> _keyToNode = <K, _IndexedNode<K, V>>{};
|
||||
final List<_IndexedNode<K, V>> _orderedNodes = <_IndexedNode<K, V>>[];
|
||||
|
||||
/// 获取元素数量
|
||||
int get length => _orderedNodes.length;
|
||||
|
||||
/// 是否为空
|
||||
bool get isEmpty => _orderedNodes.isEmpty;
|
||||
|
||||
/// 是否非空
|
||||
bool get isNotEmpty => _orderedNodes.isNotEmpty;
|
||||
|
||||
/// 获取所有键
|
||||
Iterable<K> get keys => _keyToNode.keys;
|
||||
|
||||
/// 获取所有值
|
||||
Iterable<V> get values => _orderedNodes.map((node) => node.value);
|
||||
|
||||
/// 添加或更新元素到末尾
|
||||
void add(K key, V value) {
|
||||
if (_keyToNode.containsKey(key)) {
|
||||
// 更新现有元素
|
||||
_keyToNode[key]!.value = value;
|
||||
return;
|
||||
}
|
||||
|
||||
final node = _IndexedNode<K, V>(
|
||||
key: key,
|
||||
value: value,
|
||||
index: _orderedNodes.length,
|
||||
);
|
||||
|
||||
_keyToNode[key] = node;
|
||||
_orderedNodes.add(node);
|
||||
}
|
||||
|
||||
/// 在指定位置插入元素
|
||||
void insertAt(int index, K key, V value) {
|
||||
if (_keyToNode.containsKey(key)) {
|
||||
throw ArgumentError('Key $key already exists');
|
||||
}
|
||||
|
||||
if (index < 0 || index > _orderedNodes.length) {
|
||||
throw RangeError('Index $index out of range');
|
||||
}
|
||||
|
||||
final node = _IndexedNode<K, V>(
|
||||
key: key,
|
||||
value: value,
|
||||
index: index,
|
||||
);
|
||||
|
||||
_keyToNode[key] = node;
|
||||
_orderedNodes.insert(index, node);
|
||||
|
||||
// 更新后续节点的索引
|
||||
_updateIndicesFrom(index);
|
||||
}
|
||||
|
||||
/// 根据键删除元素
|
||||
bool removeKey(K key) {
|
||||
final node = _keyToNode[key];
|
||||
if (node == null) return false;
|
||||
|
||||
final index = node.index;
|
||||
_keyToNode.remove(key);
|
||||
_orderedNodes.removeAt(index);
|
||||
|
||||
// 更新后续节点的索引
|
||||
_updateIndicesFrom(index);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 根据索引删除元素
|
||||
V? removeAt(int index) {
|
||||
if (index < 0 || index >= _orderedNodes.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final node = _orderedNodes.removeAt(index);
|
||||
_keyToNode.remove(node.key);
|
||||
|
||||
// 更新后续节点的索引
|
||||
_updateIndicesFrom(index);
|
||||
|
||||
return node.value;
|
||||
}
|
||||
|
||||
/// 根据键获取值 - O(1)
|
||||
V? operator [](K key) {
|
||||
return _keyToNode[key]?.value;
|
||||
}
|
||||
|
||||
/// 根据索引获取值 - O(1)
|
||||
V? getAt(int index) {
|
||||
if (index < 0 || index >= _orderedNodes.length) {
|
||||
return null;
|
||||
}
|
||||
return _orderedNodes[index].value;
|
||||
}
|
||||
|
||||
/// 根据索引获取键 - O(1)
|
||||
K? getKeyAt(int index) {
|
||||
if (index < 0 || index >= _orderedNodes.length) {
|
||||
return null;
|
||||
}
|
||||
return _orderedNodes[index].key;
|
||||
}
|
||||
|
||||
/// 根据键获取索引 - O(1)
|
||||
int? getIndex(K key) {
|
||||
return _keyToNode[key]?.index;
|
||||
}
|
||||
|
||||
/// 检查是否包含键 - O(1)
|
||||
bool containsKey(K key) {
|
||||
return _keyToNode.containsKey(key);
|
||||
}
|
||||
|
||||
/// 获取前k个元素 - O(k),但通常k很小所以近似O(1)
|
||||
List<V> getPrevious(K key, int count) {
|
||||
final node = _keyToNode[key];
|
||||
if (node == null) return [];
|
||||
|
||||
final startIndex = (node.index - count).clamp(0, _orderedNodes.length);
|
||||
final endIndex = node.index;
|
||||
|
||||
return _orderedNodes
|
||||
.getRange(startIndex, endIndex)
|
||||
.map((n) => n.value)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 获取后k个元素 - O(k),但通常k很小所以近似O(1)
|
||||
List<V> getNext(K key, int count) {
|
||||
final node = _keyToNode[key];
|
||||
if (node == null) return [];
|
||||
|
||||
final startIndex = node.index + 1;
|
||||
final endIndex = (startIndex + count).clamp(0, _orderedNodes.length);
|
||||
|
||||
return _orderedNodes
|
||||
.getRange(startIndex, endIndex)
|
||||
.map((n) => n.value)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 获取前后k个元素 - O(k)
|
||||
List<V> getSurrounding(K key, int count) {
|
||||
final node = _keyToNode[key];
|
||||
if (node == null) return [];
|
||||
|
||||
final startIndex = (node.index - count).clamp(0, _orderedNodes.length);
|
||||
final endIndex = (node.index + count + 1).clamp(0, _orderedNodes.length);
|
||||
|
||||
return _orderedNodes
|
||||
.getRange(startIndex, endIndex)
|
||||
.map((n) => n.value)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 获取指定范围的元素 - O(range)
|
||||
List<V> getRange(int start, int end) {
|
||||
if (start < 0) start = 0;
|
||||
if (end > _orderedNodes.length) end = _orderedNodes.length;
|
||||
if (start >= end) return [];
|
||||
|
||||
return _orderedNodes
|
||||
.getRange(start, end)
|
||||
.map((n) => n.value)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 清空所有元素
|
||||
void clear() {
|
||||
_keyToNode.clear();
|
||||
_orderedNodes.clear();
|
||||
}
|
||||
|
||||
/// 更新指定位置之后的所有节点索引
|
||||
void _updateIndicesFrom(int startIndex) {
|
||||
for (int i = startIndex; i < _orderedNodes.length; i++) {
|
||||
_orderedNodes[i].index = i;
|
||||
}
|
||||
}
|
||||
|
||||
/// 转换为List
|
||||
List<V> toList() {
|
||||
return _orderedNodes.map((node) => node.value).toList();
|
||||
}
|
||||
|
||||
/// 转换为Map
|
||||
Map<K, V> toMap() {
|
||||
return Map.fromEntries(
|
||||
_orderedNodes.map((node) => MapEntry(node.key, node.value)),
|
||||
);
|
||||
}
|
||||
|
||||
/// 遍历所有元素
|
||||
void forEach(void Function(K key, V value, int index) action) {
|
||||
for (int i = 0; i < _orderedNodes.length; i++) {
|
||||
final node = _orderedNodes[i];
|
||||
action(node.key, node.value, i);
|
||||
}
|
||||
}
|
||||
|
||||
/// 查找符合条件的元素索引
|
||||
int indexWhere(bool Function(V value) test) {
|
||||
for (int i = 0; i < _orderedNodes.length; i++) {
|
||||
if (test(_orderedNodes[i].value)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/// 内部节点类
|
||||
class _IndexedNode<K, V> {
|
||||
final K key;
|
||||
V value;
|
||||
int index;
|
||||
|
||||
_IndexedNode({
|
||||
required this.key,
|
||||
required this.value,
|
||||
required this.index,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => 'Node($key: $value @ $index)';
|
||||
}
|
||||
89
AINoval/lib/screens/editor/components/loading_overlay.dart
Normal file
89
AINoval/lib/screens/editor/components/loading_overlay.dart
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 加载覆盖层组件
|
||||
*
|
||||
* 在内容加载过程中显示的半透明渐变覆盖层,提供直观的加载状态反馈。
|
||||
* 显示在屏幕底部,包含加载指示器和自定义加载消息。
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 加载覆盖层组件
|
||||
///
|
||||
/// 用于在编辑器中显示内容加载状态。
|
||||
/// 设计为一个半透明的覆盖层,显示在主界面底部,
|
||||
/// 具有渐变背景和居中的指示器加消息。
|
||||
class LoadingOverlay extends StatelessWidget {
|
||||
/// 要显示的加载消息文本
|
||||
final String loadingMessage;
|
||||
|
||||
/// 创建一个加载覆盖层
|
||||
///
|
||||
/// [loadingMessage] 要显示的加载消息
|
||||
const LoadingOverlay({
|
||||
Key? key,
|
||||
required this.loadingMessage,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
height: 80,
|
||||
// 渐变背景从透明到白色
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.white.withOpacity(0.0),
|
||||
Colors.white.withOpacity(0.8),
|
||||
Colors.white,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24),
|
||||
// 消息容器的样式,圆角白色卡片带阴影
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 加载指示器
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.blue.shade700),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 加载消息文本
|
||||
Text(
|
||||
loadingMessage,
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade800,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
492
AINoval/lib/screens/editor/components/multi_ai_panel_view.dart
Normal file
492
AINoval/lib/screens/editor/components/multi_ai_panel_view.dart
Normal file
@@ -0,0 +1,492 @@
|
||||
import 'package:ainoval/screens/chat/widgets/ai_chat_sidebar.dart';
|
||||
import 'package:ainoval/screens/editor/components/draggable_divider.dart';
|
||||
import 'package:ainoval/screens/editor/managers/editor_layout_manager.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/ai_generation_panel.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/ai_setting_generation_panel.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/ai_summary_panel.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/continue_writing_form.dart';
|
||||
import 'package:ainoval/services/api_service/repositories/user_ai_model_config_repository.dart';
|
||||
import 'package:ainoval/services/api_service/repositories/editor_repository.dart';
|
||||
import 'package:ainoval/services/api_service/repositories/novel_ai_repository.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// 多AI面板视图组件
|
||||
/// 支持以卡片形式并排显示多个AI辅助面板,可拖拽调整大小和顺序
|
||||
class MultiAIPanelView extends StatefulWidget {
|
||||
const MultiAIPanelView({
|
||||
Key? key,
|
||||
required this.novelId,
|
||||
required this.chapterId,
|
||||
required this.layoutManager,
|
||||
required this.userId,
|
||||
required this.userAiModelConfigRepository,
|
||||
required this.onContinueWritingSubmit,
|
||||
required this.editorRepository,
|
||||
required this.novelAIRepository,
|
||||
}) : super(key: key);
|
||||
|
||||
final String novelId;
|
||||
final String? chapterId;
|
||||
final EditorLayoutManager layoutManager;
|
||||
final String? userId;
|
||||
final UserAIModelConfigRepository userAiModelConfigRepository;
|
||||
final Function(Map<String, dynamic> parameters) onContinueWritingSubmit;
|
||||
final EditorRepository editorRepository;
|
||||
final NovelAIRepository novelAIRepository;
|
||||
|
||||
@override
|
||||
State<MultiAIPanelView> createState() => _MultiAIPanelViewState();
|
||||
}
|
||||
|
||||
class _MultiAIPanelViewState extends State<MultiAIPanelView> {
|
||||
// 拖拽重排序相关状态
|
||||
String? _draggedPanelId;
|
||||
double _draggedPanelOffset = 0.0;
|
||||
bool _isDragging = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final visiblePanels = widget.layoutManager.visiblePanels;
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final bool isNarrow = screenWidth < 1280;
|
||||
final bool isVeryNarrow = screenWidth < 900;
|
||||
|
||||
// 小屏策略:仅允许显示一个面板,其余通过顺序切换(保留顺序,限制数量)
|
||||
final List<String> effectivePanels = isNarrow && visiblePanels.isNotEmpty
|
||||
? [visiblePanels.last] // 取最近一个打开的
|
||||
: visiblePanels;
|
||||
|
||||
if (effectivePanels.isEmpty) {
|
||||
return _buildToggleAllPanelsButton();
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
// 添加面板之间的拖拽分隔线和面板内容
|
||||
for (int i = 0; i < effectivePanels.length; i++) ...[
|
||||
if (i > 0 && !isNarrow) _buildDraggableDivider(effectivePanels[i]),
|
||||
_buildPanelContent(effectivePanels[i], i, isNarrow: isNarrow, isVeryNarrow: isVeryNarrow),
|
||||
],
|
||||
|
||||
// 全局隐藏/显示控制按钮
|
||||
_buildToggleAllPanelsButton(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建全局隐藏/显示所有面板的控制按钮
|
||||
Widget _buildToggleAllPanelsButton() {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final hasVisiblePanels = widget.layoutManager.visiblePanels.isNotEmpty;
|
||||
|
||||
return SizedBox(
|
||||
width: 32,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(left: 8, right: 4),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
if (hasVisiblePanels) {
|
||||
_hideAllPanels();
|
||||
} else {
|
||||
_showAllPanels();
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Tooltip(
|
||||
message: hasVisiblePanels ? '隐藏所有面板' : '显示面板',
|
||||
child: Container(
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.7),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: colorScheme.outline.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
hasVisiblePanels
|
||||
? Icons.keyboard_arrow_right
|
||||
: Icons.keyboard_arrow_left,
|
||||
size: 18,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Container(
|
||||
width: 12,
|
||||
height: 2,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 隐藏所有面板
|
||||
void _hideAllPanels() {
|
||||
widget.layoutManager.hideAllAIPanels();
|
||||
}
|
||||
|
||||
/// 恢复所有面板(显示之前保存的面板配置)
|
||||
void _showAllPanels() {
|
||||
widget.layoutManager.restoreHiddenAIPanels();
|
||||
}
|
||||
|
||||
Widget _buildDraggableDivider(String panelId) {
|
||||
return DraggableDivider(
|
||||
onDragUpdate: (details) {
|
||||
final delta = details.delta.dx;
|
||||
widget.layoutManager.updatePanelWidth(panelId, delta);
|
||||
},
|
||||
onDragEnd: (_) {
|
||||
widget.layoutManager.savePanelWidths();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPanelContent(String panelId, int index, {required bool isNarrow, required bool isVeryNarrow}) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
// 在小屏上将面板宽度限定为屏宽的35%,其余按原逻辑
|
||||
final double maxResponsiveWidth = (screenWidth * 0.35).clamp(
|
||||
EditorLayoutManager.minPanelWidth,
|
||||
EditorLayoutManager.maxPanelWidth,
|
||||
);
|
||||
double width = widget.layoutManager.panelWidths[panelId] ?? EditorLayoutManager.minPanelWidth;
|
||||
if (isNarrow) {
|
||||
width = width.clamp(EditorLayoutManager.minPanelWidth, maxResponsiveWidth);
|
||||
}
|
||||
|
||||
// 计算拖拽时的偏移量
|
||||
double xOffset = 0.0;
|
||||
if (_isDragging && _draggedPanelId == panelId) {
|
||||
xOffset = _draggedPanelOffset.clamp(-50.0, 50.0); // 限制偏移量,避免布局问题
|
||||
}
|
||||
|
||||
// 使用Material和Card为面板添加卡片风格
|
||||
return SizedBox(
|
||||
width: width,
|
||||
child: Transform.translate(
|
||||
offset: Offset(xOffset, 0),
|
||||
child: Card(
|
||||
elevation: _isDragging && _draggedPanelId == panelId ? 8 : 1,
|
||||
margin: EdgeInsets.zero, // 紧贴边缘
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.zero, // 取消圆角
|
||||
side: BorderSide(
|
||||
color: _isDragging && _draggedPanelId == panelId
|
||||
? WebTheme.getPrimaryColor(context).withValues(alpha: 0.5)
|
||||
: Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
width: _isDragging && _draggedPanelId == panelId ? 2 : 0.5,
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 可拖动的顶部把手(小屏禁用重排序,改为显示标题行)
|
||||
_buildDragHandle(panelId, index, isNarrow: isNarrow),
|
||||
|
||||
// 面板内容
|
||||
Expanded(
|
||||
child: _buildPanel(panelId),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDragHandle(String panelId, int index, {required bool isNarrow}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
// 面板类型标题映射
|
||||
final panelTitles = {
|
||||
EditorLayoutManager.aiChatPanel: 'AI聊天',
|
||||
EditorLayoutManager.aiSummaryPanel: 'AI摘要',
|
||||
EditorLayoutManager.aiScenePanel: 'AI场景生成',
|
||||
EditorLayoutManager.aiContinueWritingPanel: '自动续写',
|
||||
EditorLayoutManager.aiSettingGenerationPanel: 'AI生成设定',
|
||||
};
|
||||
|
||||
final panelTitle = panelTitles[panelId] ?? '未知面板 ($panelId)';
|
||||
|
||||
return GestureDetector(
|
||||
// 实现拖拽重排序(小屏禁用)
|
||||
onPanStart: (!isNarrow && widget.layoutManager.visiblePanels.length > 1) ? (details) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_draggedPanelId = panelId;
|
||||
_isDragging = true;
|
||||
_draggedPanelOffset = 0.0;
|
||||
});
|
||||
}
|
||||
} : null,
|
||||
onPanUpdate: (!isNarrow && widget.layoutManager.visiblePanels.length > 1) ? (details) {
|
||||
if (_isDragging && _draggedPanelId == panelId && mounted) {
|
||||
setState(() {
|
||||
_draggedPanelOffset += details.delta.dx;
|
||||
});
|
||||
|
||||
// 计算当前应该插入的位置
|
||||
_updatePanelOrder(details.globalPosition.dx);
|
||||
}
|
||||
} : null,
|
||||
onPanEnd: (!isNarrow && widget.layoutManager.visiblePanels.length > 1) ? (details) {
|
||||
if (_isDragging && _draggedPanelId == panelId && mounted) {
|
||||
setState(() {
|
||||
_isDragging = false;
|
||||
_draggedPanelId = null;
|
||||
_draggedPanelOffset = 0.0;
|
||||
});
|
||||
}
|
||||
} : null,
|
||||
child: Container(
|
||||
height: 24,
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
margin: EdgeInsets.zero,
|
||||
decoration: BoxDecoration(
|
||||
color: _isDragging && _draggedPanelId == panelId
|
||||
? WebTheme.getPrimaryColor(context).withOpacity(0.15)
|
||||
: colorScheme.secondaryContainer.withValues(alpha: 0.7),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: colorScheme.outlineVariant,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// Panel icon and title
|
||||
Flexible(
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_getPanelIcon(panelId),
|
||||
size: 14,
|
||||
color: _isDragging && _draggedPanelId == panelId
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSecondaryContainer,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
panelTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: _isDragging && _draggedPanelId == panelId
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSecondaryContainer,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Drag and close buttons
|
||||
Row(
|
||||
children: [
|
||||
// Drag handle icon
|
||||
if (!isNarrow && widget.layoutManager.visiblePanels.length > 1)
|
||||
Tooltip(
|
||||
message: '拖动调整顺序',
|
||||
child: Icon(
|
||||
Icons.drag_handle,
|
||||
size: 14,
|
||||
color: _isDragging && _draggedPanelId == panelId
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
|
||||
// Close button
|
||||
MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
_closePanel(panelId);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: Icon(
|
||||
Icons.close,
|
||||
size: 14,
|
||||
color: _isDragging && _draggedPanelId == panelId
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 根据拖拽位置更新面板顺序
|
||||
void _updatePanelOrder(double globalX) {
|
||||
if (_draggedPanelId == null || !mounted) return;
|
||||
|
||||
final currentIndex = widget.layoutManager.visiblePanels.indexOf(_draggedPanelId!);
|
||||
if (currentIndex == -1) return;
|
||||
|
||||
// 简化的位置计算:基于偏移量估算新位置
|
||||
int newIndex = currentIndex;
|
||||
final offset = _draggedPanelOffset;
|
||||
|
||||
// 使用较大的阈值避免频繁重排序
|
||||
const threshold = 100.0;
|
||||
|
||||
if (offset > threshold && currentIndex < widget.layoutManager.visiblePanels.length - 1) {
|
||||
newIndex = currentIndex + 1;
|
||||
} else if (offset < -threshold && currentIndex > 0) {
|
||||
newIndex = currentIndex - 1;
|
||||
}
|
||||
|
||||
// 如果位置发生了变化,更新面板顺序
|
||||
if (newIndex != currentIndex && mounted) {
|
||||
widget.layoutManager.reorderPanels(currentIndex, newIndex);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_draggedPanelOffset = 0.0; // 重置偏移量
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 根据面板类型获取对应图标
|
||||
IconData _getPanelIcon(String panelId) {
|
||||
switch (panelId) {
|
||||
case EditorLayoutManager.aiChatPanel:
|
||||
return Icons.chat_outlined;
|
||||
case EditorLayoutManager.aiSummaryPanel:
|
||||
return Icons.summarize_outlined;
|
||||
case EditorLayoutManager.aiScenePanel:
|
||||
return Icons.auto_awesome_outlined;
|
||||
case EditorLayoutManager.aiContinueWritingPanel:
|
||||
return Icons.auto_stories_outlined;
|
||||
case EditorLayoutManager.aiSettingGenerationPanel:
|
||||
return Icons.auto_fix_high_outlined;
|
||||
default:
|
||||
return Icons.dashboard_outlined;
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭指定面板
|
||||
void _closePanel(String panelId) {
|
||||
switch (panelId) {
|
||||
case EditorLayoutManager.aiChatPanel:
|
||||
widget.layoutManager.toggleAIChatSidebar();
|
||||
break;
|
||||
case EditorLayoutManager.aiSummaryPanel:
|
||||
widget.layoutManager.toggleAISummaryPanel();
|
||||
break;
|
||||
case EditorLayoutManager.aiScenePanel:
|
||||
widget.layoutManager.toggleAISceneGenerationPanel();
|
||||
break;
|
||||
case EditorLayoutManager.aiContinueWritingPanel:
|
||||
widget.layoutManager.toggleAIContinueWritingPanel();
|
||||
break;
|
||||
case EditorLayoutManager.aiSettingGenerationPanel:
|
||||
widget.layoutManager.toggleAISettingGenerationPanel();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildPanel(String panelId) {
|
||||
switch (panelId) {
|
||||
case EditorLayoutManager.aiChatPanel:
|
||||
return _buildAIChatPanel();
|
||||
case EditorLayoutManager.aiSummaryPanel:
|
||||
return _buildAISummaryPanel();
|
||||
case EditorLayoutManager.aiScenePanel:
|
||||
return _buildAISceneGenerationPanel();
|
||||
case EditorLayoutManager.aiContinueWritingPanel:
|
||||
return _buildAIContinueWritingPanel();
|
||||
case EditorLayoutManager.aiSettingGenerationPanel:
|
||||
return _buildAISettingGenerationPanel();
|
||||
default:
|
||||
return Center(child: Text('未知面板类型: $panelId'));
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildAIChatPanel() {
|
||||
return AIChatSidebar(
|
||||
novelId: widget.novelId,
|
||||
chapterId: widget.chapterId,
|
||||
onClose: widget.layoutManager.toggleAIChatSidebar,
|
||||
isCardMode: true,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAISummaryPanel() {
|
||||
return AISummaryPanel(
|
||||
novelId: widget.novelId,
|
||||
onClose: widget.layoutManager.toggleAISummaryPanel,
|
||||
isCardMode: true,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAISceneGenerationPanel() {
|
||||
return AIGenerationPanel(
|
||||
novelId: widget.novelId,
|
||||
onClose: widget.layoutManager.toggleAISceneGenerationPanel,
|
||||
isCardMode: true,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAIContinueWritingPanel() {
|
||||
if (widget.userId == null) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
'请先登录以使用自动续写功能。',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ContinueWritingForm(
|
||||
novelId: widget.novelId,
|
||||
userId: widget.userId!,
|
||||
userAiModelConfigRepository: widget.userAiModelConfigRepository,
|
||||
onCancel: widget.layoutManager.toggleAIContinueWritingPanel,
|
||||
onSubmit: widget.onContinueWritingSubmit,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAISettingGenerationPanel() {
|
||||
return AISettingGenerationPanel(
|
||||
novelId: widget.novelId,
|
||||
onClose: widget.layoutManager.toggleAISettingGenerationPanel,
|
||||
isCardMode: true,
|
||||
editorRepository: widget.editorRepository,
|
||||
novelAIRepository: widget.novelAIRepository,
|
||||
);
|
||||
}
|
||||
}
|
||||
1527
AINoval/lib/screens/editor/components/plan_view.dart
Normal file
1527
AINoval/lib/screens/editor/components/plan_view.dart
Normal file
File diff suppressed because it is too large
Load Diff
1029
AINoval/lib/screens/editor/components/refactor_dialog.dart
Normal file
1029
AINoval/lib/screens/editor/components/refactor_dialog.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,832 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:ainoval/blocs/ai_config/ai_config_bloc.dart';
|
||||
import 'package:ainoval/blocs/universal_ai/universal_ai_bloc.dart';
|
||||
import 'package:ainoval/blocs/universal_ai/universal_ai_state.dart';
|
||||
import 'package:ainoval/blocs/universal_ai/universal_ai_event.dart';
|
||||
// import 'package:ainoval/blocs/public_models/public_models_bloc.dart';
|
||||
import 'package:ainoval/blocs/prompt_new/prompt_new_bloc.dart';
|
||||
import 'package:ainoval/models/user_ai_model_config_model.dart';
|
||||
import 'package:ainoval/models/context_selection_models.dart';
|
||||
import 'package:ainoval/models/novel_structure.dart';
|
||||
import 'package:ainoval/models/novel_setting_item.dart';
|
||||
import 'package:ainoval/models/setting_group.dart';
|
||||
import 'package:ainoval/models/novel_snippet.dart';
|
||||
import 'package:ainoval/models/scene_beat_data.dart';
|
||||
import 'package:ainoval/models/ai_request_models.dart';
|
||||
import 'package:ainoval/models/unified_ai_model.dart';
|
||||
import 'package:ainoval/models/preset_models.dart';
|
||||
import 'package:ainoval/widgets/common/index.dart';
|
||||
import 'package:ainoval/widgets/common/top_toast.dart';
|
||||
import 'package:ainoval/widgets/common/unified_ai_model_dropdown.dart';
|
||||
import 'package:ainoval/widgets/common/prompt_preview_widget.dart';
|
||||
import 'package:ainoval/screens/editor/components/ai_dialog_common_logic.dart';
|
||||
// 移除未使用的仓库相关导入
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:ainoval/config/app_config.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:ainoval/blocs/public_models/public_models_bloc.dart';
|
||||
|
||||
/// 场景节拍编辑对话框
|
||||
/// 完全按照SummaryDialog的样式和结构设计
|
||||
class SceneBeatEditDialog extends StatefulWidget {
|
||||
const SceneBeatEditDialog({
|
||||
super.key,
|
||||
required this.data,
|
||||
this.novel,
|
||||
this.settings = const [],
|
||||
this.settingGroups = const [],
|
||||
this.snippets = const [],
|
||||
this.selectedUnifiedModel,
|
||||
this.onDataChanged,
|
||||
this.onGenerate,
|
||||
});
|
||||
|
||||
final SceneBeatData data;
|
||||
final Novel? novel;
|
||||
final List<NovelSettingItem> settings;
|
||||
final List<SettingGroup> settingGroups;
|
||||
final List<NovelSnippet> snippets;
|
||||
final UnifiedAIModel? selectedUnifiedModel;
|
||||
final ValueChanged<SceneBeatData>? onDataChanged;
|
||||
final Function(UniversalAIRequest, UnifiedAIModel)? onGenerate;
|
||||
|
||||
@override
|
||||
State<SceneBeatEditDialog> createState() => _SceneBeatEditDialogState();
|
||||
}
|
||||
|
||||
class _SceneBeatEditDialogState extends State<SceneBeatEditDialog> with AIDialogCommonLogic {
|
||||
// 控制器
|
||||
late TextEditingController _promptController;
|
||||
late TextEditingController _instructionsController;
|
||||
late TextEditingController _lengthController;
|
||||
|
||||
// 状态变量
|
||||
UnifiedAIModel? _selectedUnifiedModel;
|
||||
String? _selectedLength;
|
||||
bool _enableSmartContext = true;
|
||||
AIPromptPreset? _currentPreset;
|
||||
String? _selectedPromptTemplateId;
|
||||
// 临时自定义提示词
|
||||
String? _customSystemPrompt;
|
||||
String? _customUserPrompt;
|
||||
double _temperature = 0.7;
|
||||
double _topP = 0.9;
|
||||
late ContextSelectionData _contextSelectionData;
|
||||
|
||||
// 模型选择器key(用于FormDialogTemplate)
|
||||
final GlobalKey _modelSelectorKey = GlobalKey();
|
||||
OverlayEntry? _tempOverlay;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// 初始化控制器
|
||||
final parsedRequest = widget.data.parsedRequest;
|
||||
_promptController = TextEditingController(text: parsedRequest?.prompt ?? '续写故事。');
|
||||
_instructionsController = TextEditingController(text: parsedRequest?.instructions ?? '一个关键时刻,重要的事情发生改变,推动故事发展。');
|
||||
_lengthController = TextEditingController();
|
||||
|
||||
// 初始化状态
|
||||
_selectedUnifiedModel = widget.selectedUnifiedModel;
|
||||
_selectedLength = widget.data.selectedLength;
|
||||
// 同步初始长度到输入框:若为自定义长度,则填入文本框并清空单选
|
||||
if (_selectedLength != null && !['200', '400', '600'].contains(_selectedLength)) {
|
||||
_lengthController.text = _selectedLength!;
|
||||
_selectedLength = null;
|
||||
}
|
||||
_temperature = widget.data.temperature;
|
||||
_topP = widget.data.topP;
|
||||
_enableSmartContext = widget.data.enableSmartContext;
|
||||
_selectedPromptTemplateId = widget.data.selectedPromptTemplateId;
|
||||
|
||||
// 初始化上下文选择数据
|
||||
if (widget.data.parsedContextSelections != null) {
|
||||
// 如果已有保存的上下文选择,则在完整上下文树的基础上回显已选中项
|
||||
final baseData = _createDefaultContextSelectionData();
|
||||
_contextSelectionData = _mergeContextSelections(
|
||||
baseData,
|
||||
widget.data.parsedContextSelections!,
|
||||
);
|
||||
} else {
|
||||
_contextSelectionData = _createDefaultContextSelectionData();
|
||||
}
|
||||
|
||||
debugPrint('SceneBeatEditDialog 初始化上下文选择数据');
|
||||
debugPrint('SceneBeatEditDialog Novel: ${widget.novel?.title}');
|
||||
debugPrint('SceneBeatEditDialog Settings: ${widget.settings.length}');
|
||||
debugPrint('SceneBeatEditDialog Setting Groups: ${widget.settingGroups.length}');
|
||||
debugPrint('SceneBeatEditDialog Snippets: ${widget.snippets.length}');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_promptController.dispose();
|
||||
_instructionsController.dispose();
|
||||
_lengthController.dispose();
|
||||
_tempOverlay?.remove();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
ContextSelectionData _createDefaultContextSelectionData() {
|
||||
if (widget.novel != null) {
|
||||
return ContextSelectionDataBuilder.fromNovelWithContext(
|
||||
widget.novel!,
|
||||
settings: widget.settings,
|
||||
settingGroups: widget.settingGroups,
|
||||
snippets: widget.snippets,
|
||||
);
|
||||
} else {
|
||||
return ContextSelectionData(
|
||||
novelId: 'scene_beat',
|
||||
availableItems: const [],
|
||||
flatItems: const {},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// (已移除未使用的演示方法与扁平化构建方法)
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// (已移除未使用的 Repository 初始化代码)
|
||||
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
// 使用全局的 UniversalAIBloc 而不是创建新的
|
||||
BlocProvider.value(value: context.read<UniversalAIBloc>()),
|
||||
// 🚀 为FormDialogTemplate提供必要的Bloc
|
||||
BlocProvider.value(value: context.read<PromptNewBloc>()),
|
||||
],
|
||||
child: FormDialogTemplate(
|
||||
title: '场景节拍配置',
|
||||
tabs: const [
|
||||
TabItem(
|
||||
id: 'tweak',
|
||||
label: '调整',
|
||||
icon: Icons.edit,
|
||||
),
|
||||
TabItem(
|
||||
id: 'preview',
|
||||
label: '预览',
|
||||
icon: Icons.preview,
|
||||
),
|
||||
],
|
||||
tabContents: [
|
||||
_buildTweakTab(),
|
||||
_buildPreviewTab(),
|
||||
],
|
||||
onTabChanged: _onTabChanged,
|
||||
showPresets: true,
|
||||
usePresetDropdown: true,
|
||||
presetFeatureType: 'SCENE_BEAT_GENERATION',
|
||||
currentPreset: _currentPreset,
|
||||
onPresetSelected: _handlePresetSelected,
|
||||
onCreatePreset: _showCreatePresetDialog,
|
||||
onManagePresets: _showManagePresetsPage,
|
||||
novelId: widget.novel?.id,
|
||||
showModelSelector: true,
|
||||
modelSelectorData: _selectedUnifiedModel != null
|
||||
? ModelSelectorData(
|
||||
modelName: _selectedUnifiedModel!.displayName,
|
||||
maxOutput: '~12000 words',
|
||||
isModerated: true,
|
||||
)
|
||||
: const ModelSelectorData(
|
||||
modelName: '选择模型',
|
||||
),
|
||||
onModelSelectorTap: _showModelSelectorDropdown,
|
||||
modelSelectorKey: _modelSelectorKey,
|
||||
primaryActionLabel: '保存配置',
|
||||
onPrimaryAction: _handleSave,
|
||||
onClose: _handleClose,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建调整选项卡
|
||||
Widget _buildTweakTab() {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 指令字段
|
||||
FormFieldFactory.createInstructionsField(
|
||||
controller: _instructionsController,
|
||||
title: '指令',
|
||||
description: '为AI提供的额外指令和角色设定',
|
||||
placeholder: 'e.g. 一个关键时刻,重要的事情发生改变',
|
||||
onReset: () => setState(() => _instructionsController.clear()),
|
||||
onExpand: () {}, // TODO: 实现展开编辑器
|
||||
onCopy: () {}, // TODO: 实现复制功能
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 长度字段
|
||||
FormFieldFactory.createLengthField<String>(
|
||||
options: const [
|
||||
RadioOption(value: '200', label: '200字'),
|
||||
RadioOption(value: '400', label: '400字'),
|
||||
RadioOption(value: '600', label: '600字'),
|
||||
],
|
||||
value: _selectedLength,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedLength = value;
|
||||
_lengthController.clear();
|
||||
});
|
||||
if (value != null) {
|
||||
final updated = widget.data.copyWith(
|
||||
selectedLength: value,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
widget.onDataChanged?.call(updated);
|
||||
}
|
||||
},
|
||||
title: '长度',
|
||||
description: '生成内容的目标长度',
|
||||
onReset: () => setState(() {
|
||||
_selectedLength = null;
|
||||
_lengthController.clear();
|
||||
}),
|
||||
alternativeInput: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 40),
|
||||
child: TextField(
|
||||
controller: _lengthController,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
decoration: InputDecoration(
|
||||
hintText: 'e.g. 300字',
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).brightness == Brightness.dark
|
||||
? WebTheme.darkGrey300
|
||||
: WebTheme.grey300,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).brightness == Brightness.dark
|
||||
? WebTheme.darkGrey300
|
||||
: WebTheme.grey300,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
fillColor: Theme.of(context).brightness == Brightness.dark
|
||||
? WebTheme.darkGrey100
|
||||
: WebTheme.white,
|
||||
filled: true,
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedLength = null;
|
||||
});
|
||||
final trimmed = value.trim();
|
||||
final parsed = int.tryParse(trimmed);
|
||||
if (parsed != null) {
|
||||
final clamped = parsed.clamp(50, 5000).toString();
|
||||
final updated = widget.data.copyWith(
|
||||
selectedLength: clamped,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
widget.onDataChanged?.call(updated);
|
||||
}
|
||||
},
|
||||
onSubmitted: (value) {
|
||||
final parsed = int.tryParse(value.trim());
|
||||
if (parsed != null) {
|
||||
final clamped = parsed.clamp(50, 5000).toString();
|
||||
if (_lengthController.text != clamped) {
|
||||
_lengthController.text = clamped;
|
||||
}
|
||||
final updated = widget.data.copyWith(
|
||||
selectedLength: clamped,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
widget.onDataChanged?.call(updated);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 附加上下文字段
|
||||
FormFieldFactory.createContextSelectionField(
|
||||
contextData: _contextSelectionData,
|
||||
onSelectionChanged: (newData) => setState(() => _contextSelectionData = newData),
|
||||
title: '附加上下文',
|
||||
description: '为AI提供的任何额外信息',
|
||||
onReset: () => setState(() => _contextSelectionData = _createDefaultContextSelectionData()),
|
||||
dropdownWidth: 400,
|
||||
initialChapterId: null,
|
||||
initialSceneId: null,
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 智能上下文勾选组件
|
||||
SmartContextToggle(
|
||||
value: _enableSmartContext,
|
||||
onChanged: (value) => setState(() => _enableSmartContext = value),
|
||||
title: '智能上下文',
|
||||
description: '使用AI自动检索相关背景信息,提升生成质量',
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 关联提示词模板选择字段
|
||||
FormFieldFactory.createPromptTemplateSelectionField(
|
||||
selectedTemplateId: _selectedPromptTemplateId,
|
||||
onTemplateSelected: (templateId) => setState(() => _selectedPromptTemplateId = templateId),
|
||||
aiFeatureType: 'SCENE_BEAT_GENERATION',
|
||||
title: '关联提示词模板',
|
||||
description: '选择要关联的提示词模板(可选)',
|
||||
onReset: () => setState(() => _selectedPromptTemplateId = null),
|
||||
onTemporaryPromptsSaved: (sys, user) {
|
||||
setState(() {
|
||||
_customSystemPrompt = sys.trim().isEmpty ? null : sys.trim();
|
||||
_customUserPrompt = user.trim().isEmpty ? null : user.trim();
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 温度滑动组件
|
||||
FormFieldFactory.createTemperatureSliderField(
|
||||
context: context,
|
||||
value: _temperature,
|
||||
onChanged: (value) => setState(() => _temperature = value),
|
||||
onReset: () => setState(() => _temperature = 0.7),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Top-P滑动组件
|
||||
FormFieldFactory.createTopPSliderField(
|
||||
context: context,
|
||||
value: _topP,
|
||||
onChanged: (value) => setState(() => _topP = value),
|
||||
onReset: () => setState(() => _topP = 0.9),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建预览选项卡
|
||||
Widget _buildPreviewTab() {
|
||||
return BlocBuilder<UniversalAIBloc, UniversalAIState>(
|
||||
builder: (context, state) {
|
||||
if (state is UniversalAILoading) {
|
||||
return const PromptPreviewLoadingWidget();
|
||||
} else if (state is UniversalAIPreviewSuccess) {
|
||||
return PromptPreviewWidget(
|
||||
previewResponse: state.previewResponse,
|
||||
showActions: true,
|
||||
);
|
||||
} else if (state is UniversalAIError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'预览失败',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
state.message,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _triggerPreview,
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.preview_outlined,
|
||||
size: 48,
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'点击预览选项卡查看提示词',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _triggerPreview,
|
||||
child: const Text('生成预览'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Tab切换监听器
|
||||
void _onTabChanged(String tabId) {
|
||||
if (tabId == 'preview') {
|
||||
_triggerPreview();
|
||||
}
|
||||
}
|
||||
|
||||
/// 触发预览请求
|
||||
void _triggerPreview() {
|
||||
if (_selectedUnifiedModel == null) {
|
||||
TopToast.warning(context, '请先选择AI模型');
|
||||
return;
|
||||
}
|
||||
|
||||
// 根据模型类型获取配置
|
||||
late UserAIModelConfigModel modelConfig;
|
||||
if (_selectedUnifiedModel!.isPublic) {
|
||||
final publicModel = (_selectedUnifiedModel as PublicAIModel).publicConfig;
|
||||
modelConfig = UserAIModelConfigModel.fromJson({
|
||||
'id': publicModel.id,
|
||||
'userId': AppConfig.userId ?? 'unknown',
|
||||
'name': publicModel.displayName,
|
||||
'alias': publicModel.displayName,
|
||||
'modelName': publicModel.modelId,
|
||||
'provider': publicModel.provider,
|
||||
'apiEndpoint': '',
|
||||
'isDefault': false,
|
||||
'isValidated': true,
|
||||
'createdAt': DateTime.now().toIso8601String(),
|
||||
'updatedAt': DateTime.now().toIso8601String(),
|
||||
'isPublic': true,
|
||||
'creditMultiplier': publicModel.creditRateMultiplier ?? 1.0,
|
||||
});
|
||||
} else {
|
||||
modelConfig = (_selectedUnifiedModel as PrivateAIModel).userConfig;
|
||||
}
|
||||
|
||||
final request = UniversalAIRequest(
|
||||
requestType: AIRequestType.sceneBeat,
|
||||
userId: AppConfig.userId ?? 'unknown',
|
||||
novelId: widget.novel?.id,
|
||||
modelConfig: modelConfig,
|
||||
prompt: _promptController.text.trim(),
|
||||
instructions: _instructionsController.text.trim(),
|
||||
contextSelections: _contextSelectionData,
|
||||
enableSmartContext: _enableSmartContext,
|
||||
parameters: {
|
||||
'length': _selectedLength ?? _lengthController.text.trim(),
|
||||
'temperature': _temperature,
|
||||
'topP': _topP,
|
||||
'maxTokens': 4000,
|
||||
'modelName': _selectedUnifiedModel!.modelId,
|
||||
'enableSmartContext': _enableSmartContext,
|
||||
'promptTemplateId': _selectedPromptTemplateId,
|
||||
if (_customSystemPrompt != null) 'customSystemPrompt': _customSystemPrompt,
|
||||
if (_customUserPrompt != null) 'customUserPrompt': _customUserPrompt,
|
||||
},
|
||||
metadata: {
|
||||
'action': 'scene_beat',
|
||||
'source': 'preview',
|
||||
'contextCount': _contextSelectionData.selectedCount,
|
||||
'modelName': _selectedUnifiedModel!.modelId,
|
||||
'modelProvider': _selectedUnifiedModel!.provider,
|
||||
'modelConfigId': _selectedUnifiedModel!.id,
|
||||
'enableSmartContext': _enableSmartContext,
|
||||
},
|
||||
);
|
||||
|
||||
// 发送预览请求
|
||||
context.read<UniversalAIBloc>().add(PreviewAIRequestEvent(request));
|
||||
|
||||
// 无需返回值
|
||||
}
|
||||
|
||||
/// 显示模型选择器下拉菜单
|
||||
void _showModelSelectorDropdown() {
|
||||
// 确保公共模型已加载,避免无私人模型时无法选择
|
||||
try {
|
||||
final publicBloc = context.read<PublicModelsBloc>();
|
||||
final st = publicBloc.state;
|
||||
if (st is PublicModelsInitial || st is PublicModelsError) {
|
||||
publicBloc.add(const LoadPublicModels());
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
final renderBox = _modelSelectorKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (renderBox == null) return;
|
||||
|
||||
final position = renderBox.localToGlobal(Offset.zero);
|
||||
final size = renderBox.size;
|
||||
final anchorRect = Rect.fromLTWH(position.dx, position.dy, size.width, size.height);
|
||||
|
||||
_tempOverlay?.remove();
|
||||
|
||||
_tempOverlay = UnifiedAIModelDropdown.show(
|
||||
context: context,
|
||||
anchorRect: anchorRect,
|
||||
selectedModel: _selectedUnifiedModel,
|
||||
onModelSelected: (unifiedModel) {
|
||||
setState(() {
|
||||
_selectedUnifiedModel = unifiedModel;
|
||||
});
|
||||
},
|
||||
showSettingsButton: true,
|
||||
novel: widget.novel,
|
||||
settings: widget.settings,
|
||||
settingGroups: widget.settingGroups,
|
||||
snippets: widget.snippets,
|
||||
onClose: () {
|
||||
_tempOverlay = null;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建当前请求对象(用于保存预设)
|
||||
UniversalAIRequest? _buildCurrentRequest() {
|
||||
// 情况 1:已选择新的统一模型,直接构建最新请求
|
||||
if (_selectedUnifiedModel != null) {
|
||||
final modelConfig = createModelConfig(_selectedUnifiedModel!);
|
||||
|
||||
final metadata = createModelMetadata(_selectedUnifiedModel!, {
|
||||
'action': 'scene_beat',
|
||||
'source': 'scene_beat_edit_dialog',
|
||||
'contextCount': _contextSelectionData.selectedCount,
|
||||
'enableSmartContext': _enableSmartContext,
|
||||
});
|
||||
|
||||
return UniversalAIRequest(
|
||||
requestType: AIRequestType.sceneBeat,
|
||||
userId: AppConfig.userId ?? 'unknown',
|
||||
novelId: widget.novel?.id,
|
||||
modelConfig: modelConfig,
|
||||
prompt: _promptController.text.trim(),
|
||||
instructions: _instructionsController.text.trim(),
|
||||
contextSelections: _contextSelectionData,
|
||||
enableSmartContext: _enableSmartContext,
|
||||
parameters: {
|
||||
'length': _selectedLength ?? _lengthController.text.trim(),
|
||||
'temperature': _temperature,
|
||||
'topP': _topP,
|
||||
'maxTokens': 4000,
|
||||
'modelName': _selectedUnifiedModel!.modelId,
|
||||
'enableSmartContext': _enableSmartContext,
|
||||
'promptTemplateId': _selectedPromptTemplateId,
|
||||
if (_customSystemPrompt != null) 'customSystemPrompt': _customSystemPrompt,
|
||||
if (_customUserPrompt != null) 'customUserPrompt': _customUserPrompt,
|
||||
},
|
||||
metadata: metadata,
|
||||
);
|
||||
}
|
||||
|
||||
// 情况 2:未选择模型,但之前已有请求快照,基于旧请求更新可编辑字段
|
||||
final prevRequest = widget.data.parsedRequest;
|
||||
if (prevRequest == null) return null;
|
||||
|
||||
final updatedParameters = Map<String, dynamic>.from(prevRequest.parameters);
|
||||
updatedParameters['length'] = _selectedLength ?? _lengthController.text.trim();
|
||||
updatedParameters['temperature'] = _temperature;
|
||||
updatedParameters['topP'] = _topP;
|
||||
updatedParameters['enableSmartContext'] = _enableSmartContext;
|
||||
updatedParameters['promptTemplateId'] = _selectedPromptTemplateId;
|
||||
if (_customSystemPrompt != null) {
|
||||
updatedParameters['customSystemPrompt'] = _customSystemPrompt;
|
||||
}
|
||||
if (_customUserPrompt != null) {
|
||||
updatedParameters['customUserPrompt'] = _customUserPrompt;
|
||||
}
|
||||
|
||||
return UniversalAIRequest(
|
||||
requestType: prevRequest.requestType,
|
||||
userId: prevRequest.userId,
|
||||
novelId: prevRequest.novelId,
|
||||
modelConfig: prevRequest.modelConfig,
|
||||
prompt: prevRequest.prompt,
|
||||
instructions: _instructionsController.text.trim(),
|
||||
contextSelections: _contextSelectionData,
|
||||
enableSmartContext: _enableSmartContext,
|
||||
parameters: updatedParameters,
|
||||
metadata: prevRequest.metadata,
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示创建预设对话框
|
||||
void _showCreatePresetDialog() {
|
||||
final currentRequest = _buildCurrentRequest();
|
||||
if (currentRequest == null) {
|
||||
TopToast.warning(context, '无法创建预设:缺少表单数据');
|
||||
return;
|
||||
}
|
||||
showPresetNameDialog(currentRequest, onPresetCreated: _handlePresetCreated);
|
||||
}
|
||||
|
||||
/// 显示预设管理页面
|
||||
void _showManagePresetsPage() {
|
||||
// TODO: 实现预设管理页面
|
||||
TopToast.info(context, '预设管理功能开发中...');
|
||||
}
|
||||
|
||||
/// 处理预设选择
|
||||
void _handlePresetSelected(AIPromptPreset preset) {
|
||||
try {
|
||||
// 设置当前预设
|
||||
setState(() {
|
||||
_currentPreset = preset;
|
||||
});
|
||||
|
||||
// 🚀 使用公共方法应用预设配置
|
||||
applyPresetToForm(
|
||||
preset,
|
||||
instructionsController: _instructionsController,
|
||||
onLengthChanged: (length) {
|
||||
setState(() {
|
||||
if (length != null && ['200', '400', '600'].contains(length)) {
|
||||
_selectedLength = length;
|
||||
_lengthController.clear();
|
||||
} else if (length != null) {
|
||||
_selectedLength = null;
|
||||
_lengthController.text = length;
|
||||
}
|
||||
});
|
||||
},
|
||||
onSmartContextChanged: (value) {
|
||||
setState(() {
|
||||
_enableSmartContext = value;
|
||||
});
|
||||
},
|
||||
onPromptTemplateChanged: (templateId) {
|
||||
setState(() {
|
||||
_selectedPromptTemplateId = templateId;
|
||||
});
|
||||
},
|
||||
onTemperatureChanged: (temperature) {
|
||||
setState(() {
|
||||
_temperature = temperature;
|
||||
});
|
||||
},
|
||||
onTopPChanged: (topP) {
|
||||
setState(() {
|
||||
_topP = topP;
|
||||
});
|
||||
},
|
||||
onContextSelectionChanged: (contextData) {
|
||||
setState(() {
|
||||
_contextSelectionData = contextData;
|
||||
});
|
||||
},
|
||||
onModelChanged: (unifiedModel) {
|
||||
setState(() {
|
||||
_selectedUnifiedModel = unifiedModel;
|
||||
});
|
||||
},
|
||||
currentContextData: _contextSelectionData,
|
||||
);
|
||||
} catch (e) {
|
||||
AppLogger.e('SceneBeatEditDialog', '应用预设失败', e);
|
||||
TopToast.error(context, '应用预设失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理预设创建
|
||||
void _handlePresetCreated(AIPromptPreset preset) {
|
||||
// 设置当前预设为新创建的预设
|
||||
setState(() {
|
||||
_currentPreset = preset;
|
||||
});
|
||||
|
||||
TopToast.success(context, '预设 "${preset.presetName}" 创建成功');
|
||||
AppLogger.i('SceneBeatEditDialog', '预设创建成功: ${preset.presetName}');
|
||||
}
|
||||
|
||||
void _handleSave() {
|
||||
// 构建更新的AI请求
|
||||
final request = _buildCurrentRequest();
|
||||
|
||||
// 更新SceneBeatData
|
||||
final updatedData = widget.data.copyWith(
|
||||
requestData: request != null ? jsonEncode(request.toApiJson()) : widget.data.requestData,
|
||||
selectedUnifiedModelId: _selectedUnifiedModel?.id,
|
||||
selectedLength: _selectedLength ?? _lengthController.text.trim(),
|
||||
temperature: _temperature,
|
||||
topP: _topP,
|
||||
enableSmartContext: _enableSmartContext,
|
||||
selectedPromptTemplateId: _selectedPromptTemplateId,
|
||||
contextSelectionsData: _contextSelectionData.selectedCount > 0
|
||||
? jsonEncode({
|
||||
'novelId': _contextSelectionData.novelId,
|
||||
'selectedItems': _contextSelectionData.selectedItems.values.map((item) => {
|
||||
'id': item.id,
|
||||
'title': item.title,
|
||||
'type': item.type.value, // 🚀 修复:使用API值
|
||||
'metadata': item.metadata,
|
||||
}).toList(),
|
||||
})
|
||||
: null,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
widget.onDataChanged?.call(updatedData);
|
||||
Navigator.of(context).pop();
|
||||
TopToast.success(context, '场景节拍配置已保存');
|
||||
}
|
||||
|
||||
void _handleClose() {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
/// 将已保存的上下文选择合并到新的完整上下文树中
|
||||
ContextSelectionData _mergeContextSelections(
|
||||
ContextSelectionData baseData,
|
||||
ContextSelectionData savedSelections,
|
||||
) {
|
||||
var mergedData = baseData;
|
||||
|
||||
// 遍历已保存的选项,将其在新的树中设为选中
|
||||
for (final itemId in savedSelections.selectedItems.keys) {
|
||||
if (mergedData.flatItems.containsKey(itemId)) {
|
||||
mergedData = mergedData.selectItem(itemId);
|
||||
} else {
|
||||
// 如果新树中没有该项,则将其追加到已选映射,避免数据丢失
|
||||
final savedItem = savedSelections.selectedItems[itemId]!;
|
||||
mergedData = mergedData.copyWith(
|
||||
selectedItems: {
|
||||
...mergedData.selectedItems,
|
||||
savedItem.id: savedItem,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return mergedData;
|
||||
}
|
||||
}
|
||||
|
||||
/// 显示场景节拍编辑对话框的便捷函数
|
||||
void showSceneBeatEditDialog(
|
||||
BuildContext context, {
|
||||
required SceneBeatData data,
|
||||
Novel? novel,
|
||||
List<NovelSettingItem> settings = const [],
|
||||
List<SettingGroup> settingGroups = const [],
|
||||
List<NovelSnippet> snippets = const [],
|
||||
UnifiedAIModel? selectedUnifiedModel,
|
||||
ValueChanged<SceneBeatData>? onDataChanged,
|
||||
Function(UniversalAIRequest, UnifiedAIModel)? onGenerate,
|
||||
}) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (dialogContext) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: context.read<AiConfigBloc>()),
|
||||
BlocProvider.value(value: context.read<PromptNewBloc>()),
|
||||
],
|
||||
child: SceneBeatEditDialog(
|
||||
data: data,
|
||||
novel: novel,
|
||||
settings: settings,
|
||||
settingGroups: settingGroups,
|
||||
snippets: snippets,
|
||||
selectedUnifiedModel: selectedUnifiedModel,
|
||||
onDataChanged: onDataChanged,
|
||||
onGenerate: onGenerate,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
3391
AINoval/lib/screens/editor/components/scene_editor.dart
Normal file
3391
AINoval/lib/screens/editor/components/scene_editor.dart
Normal file
File diff suppressed because it is too large
Load Diff
1047
AINoval/lib/screens/editor/components/summary_dialog.dart
Normal file
1047
AINoval/lib/screens/editor/components/summary_dialog.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
// 文本生成对话框统一导出文件
|
||||
// 集中导出扩写、重构、缩写三个对话框组件
|
||||
|
||||
export 'expansion_dialog.dart';
|
||||
export 'refactor_dialog.dart';
|
||||
export 'summary_dialog.dart';
|
||||
@@ -0,0 +1,221 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/blocs/editor/editor_bloc.dart' as editor_bloc;
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
|
||||
/// 卷轴导航按钮组件
|
||||
/// 显示上一卷/下一卷/添加新卷按钮
|
||||
class VolumeNavigationButtons extends StatelessWidget {
|
||||
// 位置控制
|
||||
final bool isTop;
|
||||
|
||||
// 卷状态控制
|
||||
final bool isFirstAct;
|
||||
final bool isLastAct;
|
||||
final String? previousActTitle;
|
||||
final String? nextActTitle;
|
||||
|
||||
// 滚动状态
|
||||
final bool hasReachedStart;
|
||||
final bool hasReachedEnd;
|
||||
|
||||
// 加载状态
|
||||
final bool isLoadingMore;
|
||||
|
||||
// 回调
|
||||
final VoidCallback? onPreviousAct;
|
||||
final VoidCallback? onNextAct;
|
||||
final VoidCallback? onAddNewAct;
|
||||
|
||||
const VolumeNavigationButtons({
|
||||
Key? key,
|
||||
required this.isTop,
|
||||
required this.isFirstAct,
|
||||
required this.isLastAct,
|
||||
this.previousActTitle,
|
||||
this.nextActTitle,
|
||||
required this.hasReachedStart,
|
||||
required this.hasReachedEnd,
|
||||
this.isLoadingMore = false,
|
||||
this.onPreviousAct,
|
||||
this.onNextAct,
|
||||
this.onAddNewAct,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 始终记录底部按钮显示条件,方便调试
|
||||
if (!isTop) {
|
||||
AppLogger.i('VolumeNavigationButtons', '底部按钮条件: isLastAct=$isLastAct, hasReachedEnd=$hasReachedEnd');
|
||||
}
|
||||
|
||||
// 上方按钮显示条件:
|
||||
// 1. 是顶部按钮位置 (isTop)
|
||||
// 2. 不能是第一卷 (isFirstAct == false)
|
||||
final bool shouldShowTopButton = isTop && !isFirstAct;
|
||||
|
||||
// 下方按钮显示条件:
|
||||
// 1. 是底部按钮位置
|
||||
final bool shouldShowBottomButton = !isTop;
|
||||
|
||||
// 确定按钮类型
|
||||
// 顶部按钮永远是"上一卷"
|
||||
// 底部按钮在最后一卷时是"添加新卷",否则是"下一卷"
|
||||
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
transitionBuilder: (Widget child, Animation<double> animation) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: Offset(0, isTop ? -0.5 : 0.5),
|
||||
end: Offset.zero,
|
||||
).animate(animation),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: (shouldShowTopButton || shouldShowBottomButton)
|
||||
? _buildButton(
|
||||
context,
|
||||
isTop: isTop,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildButton(
|
||||
BuildContext context, {
|
||||
required bool isTop,
|
||||
}) {
|
||||
final themeData = Theme.of(context);
|
||||
|
||||
// 安全地获取前一个和下一个卷的信息
|
||||
final String? prevVolumeName = isFirstAct ? null : previousActTitle;
|
||||
final String? nextVolumeName = this.isLastAct ? null : this.nextActTitle;
|
||||
|
||||
// 按钮文本
|
||||
late final String buttonText;
|
||||
late final IconData buttonIcon;
|
||||
late final VoidCallback? onPressed;
|
||||
|
||||
if (isTop) {
|
||||
// 顶部按钮:上一卷
|
||||
if (prevVolumeName == null) {
|
||||
buttonText = '返回首卷';
|
||||
} else {
|
||||
String displayName = prevVolumeName;
|
||||
if (displayName.length > 10) {
|
||||
displayName = displayName.substring(0, 10) + '...';
|
||||
}
|
||||
buttonText = '上一卷:$displayName';
|
||||
}
|
||||
buttonIcon = Icons.arrow_upward_rounded;
|
||||
onPressed = onPreviousAct;
|
||||
} else {
|
||||
if (this.isLastAct) {
|
||||
// 底部按钮:如果是最后一卷,则为"添加新卷"
|
||||
buttonText = '添加新卷';
|
||||
buttonIcon = Icons.add_rounded;
|
||||
onPressed = onAddNewAct;
|
||||
} else {
|
||||
// 底部按钮:如果不是最后一卷,则为"下一卷"
|
||||
if (nextVolumeName == null) {
|
||||
buttonText = '下一卷';
|
||||
} else {
|
||||
String displayName = nextVolumeName;
|
||||
if (displayName.length > 10) {
|
||||
displayName = displayName.substring(0, 10) + '...';
|
||||
}
|
||||
buttonText = '下一卷:$displayName';
|
||||
}
|
||||
buttonIcon = Icons.arrow_downward_rounded;
|
||||
onPressed = onNextAct;
|
||||
}
|
||||
}
|
||||
|
||||
// 构建按钮
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: isTop ? 16.0 : 0.0,
|
||||
bottom: isTop ? 0.0 : 16.0,
|
||||
),
|
||||
child: Container(
|
||||
height: 60,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
decoration: BoxDecoration(
|
||||
color: themeData.cardColor,
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
onTap: isLoadingMore ? null : onPressed,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
isLoadingMore
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
buttonIcon,
|
||||
color: themeData.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
isLoadingMore ? '加载中...' : buttonText,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: themeData.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 构建加载指示器
|
||||
Widget _buildLoadingIndicator(ThemeData theme, String loadingText) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(theme.colorScheme.primary),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
loadingText,
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
2022
AINoval/lib/screens/editor/controllers/editor_screen_controller.dart
Normal file
2022
AINoval/lib/screens/editor/controllers/editor_screen_controller.dart
Normal file
File diff suppressed because it is too large
Load Diff
164
AINoval/lib/screens/editor/editor_screen.dart
Normal file
164
AINoval/lib/screens/editor/editor_screen.dart
Normal file
@@ -0,0 +1,164 @@
|
||||
import 'package:ainoval/blocs/auth/auth_bloc.dart';
|
||||
import 'package:ainoval/blocs/sidebar/sidebar_bloc.dart';
|
||||
// import 'package:ainoval/config/app_config.dart';
|
||||
// import 'package:ainoval/models/novel_structure.dart' as novel_models;
|
||||
import 'package:ainoval/models/novel_summary.dart';
|
||||
import 'package:ainoval/screens/editor/components/editor_layout.dart';
|
||||
import 'package:ainoval/screens/editor/controllers/editor_screen_controller.dart';
|
||||
import 'package:ainoval/screens/editor/managers/editor_layout_manager.dart';
|
||||
import 'package:ainoval/screens/editor/managers/editor_state_manager.dart';
|
||||
// import 'package:ainoval/screens/editor/widgets/continue_writing_form.dart';
|
||||
// import 'package:ainoval/services/api_service/repositories/editor_repository.dart';
|
||||
// import 'package:ainoval/services/api_service/repositories/user_ai_model_config_repository.dart';
|
||||
import 'package:ainoval/services/api_service/base/api_client.dart';
|
||||
// import 'package:ainoval/utils/logger.dart';
|
||||
// import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
// import 'package:ainoval/blocs/setting/setting_bloc.dart';
|
||||
import 'package:ainoval/services/api_service/repositories/novel_setting_repository.dart';
|
||||
import 'package:ainoval/services/api_service/repositories/impl/novel_setting_repository_impl.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:ainoval/blocs/prompt_new/prompt_new_bloc.dart';
|
||||
// import 'package:ainoval/services/api_service/repositories/prompt_repository.dart';
|
||||
// import 'package:ainoval/services/api_service/repositories/impl/prompt_repository_impl.dart';
|
||||
// import 'package:ainoval/screens/prompt/prompt_screen.dart';
|
||||
|
||||
/// 编辑器屏幕
|
||||
/// 使用设计模式重构后的编辑器屏幕,将功能拆分为多个组件
|
||||
class EditorScreen extends StatefulWidget {
|
||||
const EditorScreen({
|
||||
super.key,
|
||||
required this.novel,
|
||||
});
|
||||
final NovelSummary novel;
|
||||
|
||||
@override
|
||||
State<EditorScreen> createState() => _EditorScreenState();
|
||||
}
|
||||
|
||||
class _EditorScreenState extends State<EditorScreen> with SingleTickerProviderStateMixin {
|
||||
late final EditorScreenController _controller;
|
||||
late final EditorLayoutManager _layoutManager;
|
||||
late final EditorStateManager _stateManager;
|
||||
late final PromptNewBloc _promptNewBloc;
|
||||
|
||||
|
||||
|
||||
late final SidebarBloc _sidebarBloc;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = EditorScreenController(
|
||||
novel: widget.novel,
|
||||
vsync: this,
|
||||
);
|
||||
_layoutManager = EditorLayoutManager();
|
||||
_stateManager = EditorStateManager();
|
||||
|
||||
// 初始化 SidebarBloc
|
||||
_sidebarBloc = SidebarBloc(
|
||||
editorRepository: _controller.editorRepository,
|
||||
);
|
||||
|
||||
// 初始化 PromptNewBloc
|
||||
_promptNewBloc = PromptNewBloc(
|
||||
promptRepository: _controller.promptRepository,
|
||||
);
|
||||
|
||||
// 加载小说结构数据
|
||||
_sidebarBloc.add(LoadNovelStructure(widget.novel.id));
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 自动续写对话框显示控制
|
||||
void _showAutoContinueWritingDialog() {
|
||||
// 暂时留空,功能待实现
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// 关闭SidebarBloc
|
||||
_sidebarBloc.close();
|
||||
|
||||
// 关闭PromptNewBloc
|
||||
_promptNewBloc.close();
|
||||
|
||||
// 尝试同步当前小说数据
|
||||
_controller.syncCurrentNovel();
|
||||
|
||||
// 通知小说列表页面刷新数据
|
||||
_controller.notifyNovelListRefresh(context);
|
||||
|
||||
// 释放控制器资源
|
||||
_controller.dispose();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
return BlocListener<AuthBloc, AuthState>(
|
||||
listenWhen: (prev, curr) => curr is AuthUnauthenticated,
|
||||
listener: (context, state) {
|
||||
// 监听认证状态变化,当用户未认证时导航回登录页面
|
||||
if (state is AuthUnauthenticated) {
|
||||
// 确保在widget仍然挂载时执行导航
|
||||
if (mounted) {
|
||||
// 使用pushAndRemoveUntil清除导航栈并导航到登录页面
|
||||
// Navigator.of(context).pushAndRemoveUntil(
|
||||
// MaterialPageRoute(builder: (context) => const LoginScreen()),
|
||||
// (route) => false, // 清除所有现有路由
|
||||
// );
|
||||
}
|
||||
}
|
||||
},
|
||||
child: MultiRepositoryProvider(
|
||||
providers: [
|
||||
RepositoryProvider<NovelSettingRepository>(
|
||||
create: (context) => NovelSettingRepositoryImpl(
|
||||
apiClient: ApiClient(),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: MultiBlocProvider(
|
||||
providers: [
|
||||
// 确保AuthBloc在编辑器中可用
|
||||
BlocProvider.value(value: context.read<AuthBloc>()),
|
||||
BlocProvider.value(value: _controller.editorBloc),
|
||||
BlocProvider.value(value: _sidebarBloc),
|
||||
BlocProvider.value(value: _promptNewBloc),
|
||||
ChangeNotifierProvider.value(value: _controller),
|
||||
ChangeNotifierProvider.value(value: _layoutManager),
|
||||
BlocProvider.value(value: _controller.settingBlocInstance),
|
||||
],
|
||||
child: ValueListenableBuilder<String>(
|
||||
valueListenable: WebTheme.variantListenable,
|
||||
builder: (context, variant, _) {
|
||||
// 通过监听变体,确保本地Theme随全局主题变更而重建
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
// 使用全局主题的颜色,随变体变更
|
||||
scaffoldBackgroundColor: Theme.of(context).scaffoldBackgroundColor, // 使用正确的背景色
|
||||
cardColor: Theme.of(context).colorScheme.surface, // 使用动态卡片背景色
|
||||
),
|
||||
child: EditorLayout(
|
||||
controller: _controller,
|
||||
layoutManager: _layoutManager,
|
||||
stateManager: _stateManager,
|
||||
onAutoContinueWritingPressed: _showAutoContinueWritingDialog,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
150
AINoval/lib/screens/editor/managers/editor_dialog_manager.dart
Normal file
150
AINoval/lib/screens/editor/managers/editor_dialog_manager.dart
Normal file
@@ -0,0 +1,150 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 编辑器对话框管理器
|
||||
/// 负责管理编辑器中的各种对话框
|
||||
class EditorDialogManager {
|
||||
// 显示编辑器侧边栏宽度调整对话框
|
||||
static void showEditorSidebarWidthDialog(
|
||||
BuildContext context,
|
||||
double currentWidth,
|
||||
double minWidth,
|
||||
double maxWidth,
|
||||
ValueChanged<double> onWidthChanged,
|
||||
VoidCallback onSave,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return _buildWidthAdjustmentDialog(
|
||||
context,
|
||||
'调整侧边栏宽度',
|
||||
currentWidth,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
onWidthChanged,
|
||||
onSave,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 显示聊天侧边栏宽度调整对话框
|
||||
static void showChatSidebarWidthDialog(
|
||||
BuildContext context,
|
||||
double currentWidth,
|
||||
double minWidth,
|
||||
double maxWidth,
|
||||
ValueChanged<double> onWidthChanged,
|
||||
VoidCallback onSave,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return _buildWidthAdjustmentDialog(
|
||||
context,
|
||||
'调整聊天侧边栏宽度',
|
||||
currentWidth,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
onWidthChanged,
|
||||
onSave,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 构建宽度调整对话框
|
||||
static Widget _buildWidthAdjustmentDialog(
|
||||
BuildContext context,
|
||||
String title,
|
||||
double currentWidth,
|
||||
double minWidth,
|
||||
double maxWidth,
|
||||
ValueChanged<double> onWidthChanged,
|
||||
VoidCallback onSave,
|
||||
) {
|
||||
return AlertDialog(
|
||||
title: Text(title),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('当前宽度: ${currentWidth.toInt()} 像素'),
|
||||
const SizedBox(height: 16),
|
||||
StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return Slider(
|
||||
value: currentWidth,
|
||||
min: minWidth,
|
||||
max: maxWidth,
|
||||
divisions: 8,
|
||||
label: currentWidth.toInt().toString(),
|
||||
onChanged: (value) {
|
||||
onWidthChanged(value);
|
||||
setState(() {});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
onSave();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 显示登录提示对话框
|
||||
static Widget buildLoginRequiredPanel(BuildContext context, VoidCallback onClose) {
|
||||
return Material(
|
||||
elevation: 4.0,
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
child: Container(
|
||||
width: 400, // Smaller width for message
|
||||
height: 200, // Smaller height for message
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.lock_outline,
|
||||
size: 40, color: Theme.of(context).colorScheme.error),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'需要登录', // TODO: Localize
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'请先登录以访问和管理 AI 配置。', // TODO: Localize
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
// TODO: Implement navigation to login screen
|
||||
onClose(); // Close panel for now
|
||||
},
|
||||
child: const Text('前往登录'), // TODO: Localize
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
551
AINoval/lib/screens/editor/managers/editor_layout_manager.dart
Normal file
551
AINoval/lib/screens/editor/managers/editor_layout_manager.dart
Normal file
@@ -0,0 +1,551 @@
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:collection/collection.dart'; // For firstWhereOrNull
|
||||
|
||||
/// 编辑器布局管理器
|
||||
/// 负责管理编辑器的布局和尺寸
|
||||
class EditorLayoutManager extends ChangeNotifier {
|
||||
EditorLayoutManager() {
|
||||
_loadSavedDimensions();
|
||||
}
|
||||
|
||||
// 对象dispose状态跟踪
|
||||
bool _isDisposed = false;
|
||||
|
||||
// 侧边栏可见性状态
|
||||
bool isEditorSidebarVisible = true;
|
||||
bool isAIChatSidebarVisible = false;
|
||||
bool isSettingsPanelVisible = false;
|
||||
bool isNovelSettingsVisible = false;
|
||||
bool isAISummaryPanelVisible = false;
|
||||
bool isAISceneGenerationPanelVisible = false;
|
||||
bool isAIContinueWritingPanelVisible = false;
|
||||
bool isAISettingGenerationPanelVisible = false;
|
||||
bool isPromptViewVisible = false;
|
||||
|
||||
// 多面板显示时的顺序和位置
|
||||
final List<String> visiblePanels = [];
|
||||
static const String aiChatPanel = 'aiChatPanel';
|
||||
static const String aiSummaryPanel = 'aiSummaryPanel';
|
||||
static const String aiScenePanel = 'aiScenePanel';
|
||||
static const String aiContinueWritingPanel = 'aiContinueWritingPanel';
|
||||
static const String aiSettingGenerationPanel = 'aiSettingGenerationPanel';
|
||||
|
||||
// 侧边栏宽度
|
||||
double editorSidebarWidth = 400;
|
||||
double chatSidebarWidth = 380;
|
||||
|
||||
// 多面板模式下的单个面板宽度
|
||||
Map<String, double> panelWidths = {
|
||||
aiChatPanel: 600, // 聊天侧边栏默认最大宽度打开
|
||||
aiSummaryPanel: 350, // 其他侧边栏保持当前宽度
|
||||
aiScenePanel: 350,
|
||||
aiContinueWritingPanel: 350,
|
||||
aiSettingGenerationPanel: 350,
|
||||
};
|
||||
|
||||
// 侧边栏宽度限制
|
||||
static const double minEditorSidebarWidth = 220;
|
||||
static const double maxEditorSidebarWidth = 400;
|
||||
static const double minChatSidebarWidth = 280;
|
||||
static const double maxChatSidebarWidth = 500;
|
||||
static const double minPanelWidth = 280;
|
||||
static const double maxPanelWidth = 600; // 提升二分之一:400 * 1.5 = 600
|
||||
|
||||
// 持久化键
|
||||
static const String editorSidebarWidthPrefKey = 'editor_sidebar_width';
|
||||
static const String chatSidebarWidthPrefKey = 'chat_sidebar_width';
|
||||
static const String panelWidthsPrefKey = 'multi_panel_widths';
|
||||
static const String visiblePanelsPrefKey = 'visible_panels';
|
||||
static const String lastHiddenPanelsPrefKey = 'last_hidden_panels';
|
||||
|
||||
// 保存隐藏前的面板配置
|
||||
List<String> _lastHiddenPanelsConfig = [];
|
||||
|
||||
// 布局变化标志 - 用于标识当前变化是否为纯布局变化
|
||||
bool _isLayoutOnlyChange = false;
|
||||
|
||||
// 操作节流控制
|
||||
DateTime? _lastLayoutChangeTime;
|
||||
static const Duration _layoutChangeThrottle = Duration(milliseconds: 200);
|
||||
|
||||
// 获取是否为纯布局变化
|
||||
bool get isLayoutOnlyChange => _isLayoutOnlyChange;
|
||||
|
||||
// 重置布局变化标志
|
||||
void resetLayoutChangeFlag() {
|
||||
_isLayoutOnlyChange = false;
|
||||
}
|
||||
|
||||
// 🔧 优化:更严格的节流通知机制,避免在关键操作期间触发不必要的布局变化
|
||||
void _notifyLayoutChange() {
|
||||
if (_isDisposed) return; // 防止在dispose后调用
|
||||
|
||||
final now = DateTime.now();
|
||||
|
||||
// 🔧 修复:更严格的节流控制,避免过于频繁的布局变化通知
|
||||
if (_lastLayoutChangeTime != null &&
|
||||
now.difference(_lastLayoutChangeTime!) < _layoutChangeThrottle) {
|
||||
// 在节流期间,仍然设置布局变化标志,但不触发通知
|
||||
_isLayoutOnlyChange = true;
|
||||
AppLogger.d('EditorLayoutManager', '节流: 跳过布局变化通知');
|
||||
return;
|
||||
}
|
||||
|
||||
_lastLayoutChangeTime = now;
|
||||
_isLayoutOnlyChange = true;
|
||||
|
||||
AppLogger.d('EditorLayoutManager', '触发布局变化通知');
|
||||
|
||||
// 立即通知监听器
|
||||
notifyListeners();
|
||||
|
||||
// 🔧 修复:延长标志重置时间,确保下游组件有足够时间处理布局变化
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (!_isDisposed) { // 检查对象是否仍然有效
|
||||
_isLayoutOnlyChange = false;
|
||||
AppLogger.d('EditorLayoutManager', '重置布局变化标志');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 加载保存的尺寸
|
||||
Future<void> _loadSavedDimensions() async {
|
||||
await _loadSavedEditorSidebarWidth();
|
||||
await _loadSavedChatSidebarWidth();
|
||||
await _loadSavedPanelWidths();
|
||||
await _loadSavedVisiblePanels();
|
||||
await _loadLastHiddenPanelsConfig();
|
||||
}
|
||||
|
||||
// 加载保存的编辑器侧边栏宽度
|
||||
Future<void> _loadSavedEditorSidebarWidth() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedWidth = prefs.getDouble(editorSidebarWidthPrefKey);
|
||||
if (savedWidth != null) {
|
||||
if (savedWidth >= minEditorSidebarWidth &&
|
||||
savedWidth <= maxEditorSidebarWidth) {
|
||||
editorSidebarWidth = savedWidth;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.e('EditorLayoutManager', '加载编辑器侧边栏宽度失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存编辑器侧边栏宽度
|
||||
Future<void> saveEditorSidebarWidth() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setDouble(editorSidebarWidthPrefKey, editorSidebarWidth);
|
||||
} catch (e) {
|
||||
AppLogger.e('EditorLayoutManager', '保存编辑器侧边栏宽度失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载保存的聊天侧边栏宽度
|
||||
Future<void> _loadSavedChatSidebarWidth() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedWidth = prefs.getDouble(chatSidebarWidthPrefKey);
|
||||
if (savedWidth != null) {
|
||||
if (savedWidth >= minChatSidebarWidth &&
|
||||
savedWidth <= maxChatSidebarWidth) {
|
||||
chatSidebarWidth = savedWidth;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.e('EditorLayoutManager', '加载侧边栏宽度失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载保存的面板宽度
|
||||
Future<void> _loadSavedPanelWidths() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedWidthsString = prefs.getString(panelWidthsPrefKey);
|
||||
if (savedWidthsString != null) {
|
||||
final savedWidthsList = savedWidthsString.split(',');
|
||||
if (savedWidthsList.isNotEmpty) {
|
||||
// 聊天面板保持新的默认值(600),其他面板加载保存的值
|
||||
if (savedWidthsList.isNotEmpty && savedWidthsList[0].isNotEmpty) {
|
||||
final savedChatWidth = double.tryParse(savedWidthsList.elementAtOrNull(0) ?? '');
|
||||
if (savedChatWidth != null) {
|
||||
panelWidths[aiChatPanel] = savedChatWidth.clamp(minPanelWidth, maxPanelWidth);
|
||||
}
|
||||
}
|
||||
panelWidths[aiSummaryPanel] = double.tryParse(savedWidthsList.elementAtOrNull(1) ?? panelWidths[aiSummaryPanel].toString())!.clamp(minPanelWidth, maxPanelWidth);
|
||||
panelWidths[aiScenePanel] = double.tryParse(savedWidthsList.elementAtOrNull(2) ?? panelWidths[aiScenePanel].toString())!.clamp(minPanelWidth, maxPanelWidth);
|
||||
if (savedWidthsList.length > 3) {
|
||||
panelWidths[aiContinueWritingPanel] = double.tryParse(savedWidthsList.elementAtOrNull(3) ?? panelWidths[aiContinueWritingPanel].toString())!.clamp(minPanelWidth, maxPanelWidth);
|
||||
}
|
||||
if (savedWidthsList.length > 4) {
|
||||
panelWidths[aiSettingGenerationPanel] = double.tryParse(savedWidthsList.elementAtOrNull(4) ?? panelWidths[aiSettingGenerationPanel].toString())!.clamp(minPanelWidth, maxPanelWidth);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.e('EditorLayoutManager', '加载面板宽度失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载保存的可见面板
|
||||
Future<void> _loadSavedVisiblePanels() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedPanels = prefs.getStringList(visiblePanelsPrefKey);
|
||||
if (savedPanels != null) {
|
||||
visiblePanels.clear();
|
||||
visiblePanels.addAll(savedPanels);
|
||||
|
||||
// 更新各面板的可见性状态
|
||||
isAIChatSidebarVisible = visiblePanels.contains(aiChatPanel);
|
||||
isAISummaryPanelVisible = visiblePanels.contains(aiSummaryPanel);
|
||||
isAISceneGenerationPanelVisible = visiblePanels.contains(aiScenePanel);
|
||||
isAIContinueWritingPanelVisible = visiblePanels.contains(aiContinueWritingPanel);
|
||||
isAISettingGenerationPanelVisible = visiblePanels.contains(aiSettingGenerationPanel);
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.e('EditorLayoutManager', '加载可见面板失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存聊天侧边栏宽度
|
||||
Future<void> saveChatSidebarWidth() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setDouble(chatSidebarWidthPrefKey, chatSidebarWidth);
|
||||
} catch (e) {
|
||||
AppLogger.e('EditorLayoutManager', '保存侧边栏宽度失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存面板宽度
|
||||
Future<void> savePanelWidths() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final widthsString = [
|
||||
panelWidths[aiChatPanel],
|
||||
panelWidths[aiSummaryPanel],
|
||||
panelWidths[aiScenePanel],
|
||||
panelWidths[aiContinueWritingPanel],
|
||||
panelWidths[aiSettingGenerationPanel]
|
||||
].join(',');
|
||||
await prefs.setString(panelWidthsPrefKey, widthsString);
|
||||
} catch (e) {
|
||||
AppLogger.e('EditorLayoutManager', '保存面板宽度失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存可见面板
|
||||
Future<void> saveVisiblePanels() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(visiblePanelsPrefKey, visiblePanels);
|
||||
} catch (e) {
|
||||
AppLogger.e('EditorLayoutManager', '保存可见面板失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载隐藏前的面板配置
|
||||
Future<void> _loadLastHiddenPanelsConfig() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedConfig = prefs.getStringList(lastHiddenPanelsPrefKey);
|
||||
if (savedConfig != null) {
|
||||
_lastHiddenPanelsConfig = savedConfig;
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.e('EditorLayoutManager', '加载隐藏面板配置失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存隐藏前的面板配置
|
||||
Future<void> _saveLastHiddenPanelsConfig() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(lastHiddenPanelsPrefKey, _lastHiddenPanelsConfig);
|
||||
} catch (e) {
|
||||
AppLogger.e('EditorLayoutManager', '保存隐藏面板配置失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新编辑器侧边栏宽度
|
||||
void updateEditorSidebarWidth(double delta) {
|
||||
editorSidebarWidth = (editorSidebarWidth + delta).clamp(
|
||||
minEditorSidebarWidth,
|
||||
maxEditorSidebarWidth,
|
||||
);
|
||||
_notifyLayoutChange(); // 使用布局专用的通知方法
|
||||
}
|
||||
|
||||
// 更新聊天侧边栏宽度
|
||||
void updateChatSidebarWidth(double delta) {
|
||||
chatSidebarWidth = (chatSidebarWidth - delta).clamp(
|
||||
minChatSidebarWidth,
|
||||
maxChatSidebarWidth,
|
||||
);
|
||||
_notifyLayoutChange(); // 修复:添加missing的notifyListeners调用
|
||||
}
|
||||
|
||||
// 更新指定面板宽度
|
||||
void updatePanelWidth(String panelId, double delta) {
|
||||
if (panelWidths.containsKey(panelId)) {
|
||||
panelWidths[panelId] = (panelWidths[panelId]! - delta).clamp(
|
||||
minPanelWidth,
|
||||
maxPanelWidth,
|
||||
);
|
||||
_notifyLayoutChange(); // 使用布局专用的通知方法
|
||||
}
|
||||
}
|
||||
|
||||
// 切换编辑器侧边栏可见性
|
||||
void toggleEditorSidebar() {
|
||||
isEditorSidebarVisible = !isEditorSidebarVisible;
|
||||
_notifyLayoutChange(); // 使用布局专用的通知方法
|
||||
}
|
||||
|
||||
// 抽屉模式切换:当宽度小于阈值时展开到最大,当宽度大于等于阈值时收起到抽屉阈值
|
||||
void toggleEditorSidebarCompactMode() {
|
||||
const double drawerThreshold = 260.0;
|
||||
if (editorSidebarWidth < drawerThreshold) {
|
||||
expandEditorSidebarToMax();
|
||||
} else {
|
||||
collapseEditorSidebarToDrawer();
|
||||
}
|
||||
}
|
||||
|
||||
// 收起到抽屉(通过设置较小宽度触发精简抽屉UI)
|
||||
void collapseEditorSidebarToDrawer() {
|
||||
editorSidebarWidth = minEditorSidebarWidth; // e.g. 220,会触发 < 260 的精简抽屉
|
||||
_notifyLayoutChange();
|
||||
saveEditorSidebarWidth();
|
||||
}
|
||||
|
||||
// 展开到最大宽度
|
||||
void expandEditorSidebarToMax() {
|
||||
editorSidebarWidth = maxEditorSidebarWidth; // e.g. 400
|
||||
_notifyLayoutChange();
|
||||
saveEditorSidebarWidth();
|
||||
}
|
||||
|
||||
// 显示编辑器侧边栏(幂等)
|
||||
void showEditorSidebar() {
|
||||
if (!isEditorSidebarVisible) {
|
||||
isEditorSidebarVisible = true;
|
||||
_notifyLayoutChange();
|
||||
}
|
||||
}
|
||||
|
||||
// 隐藏编辑器侧边栏(幂等)
|
||||
void hideEditorSidebar() {
|
||||
if (isEditorSidebarVisible) {
|
||||
isEditorSidebarVisible = false;
|
||||
_notifyLayoutChange();
|
||||
}
|
||||
}
|
||||
|
||||
// 切换AI聊天侧边栏可见性
|
||||
void toggleAIChatSidebar() {
|
||||
// 在多面板模式下
|
||||
if (visiblePanels.contains(aiChatPanel)) {
|
||||
// 如果已经可见,则移除
|
||||
visiblePanels.remove(aiChatPanel);
|
||||
isAIChatSidebarVisible = false;
|
||||
} else {
|
||||
// 如果不可见,则添加
|
||||
visiblePanels.add(aiChatPanel);
|
||||
isAIChatSidebarVisible = true;
|
||||
}
|
||||
saveVisiblePanels();
|
||||
_notifyLayoutChange(); // 使用布局专用的通知方法
|
||||
}
|
||||
|
||||
// 切换AI场景生成面板可见性
|
||||
void toggleAISceneGenerationPanel() {
|
||||
// 在多面板模式下
|
||||
if (visiblePanels.contains(aiScenePanel)) {
|
||||
// 如果已经可见,则移除
|
||||
visiblePanels.remove(aiScenePanel);
|
||||
isAISceneGenerationPanelVisible = false;
|
||||
} else {
|
||||
// 如果不可见,则添加
|
||||
visiblePanels.add(aiScenePanel);
|
||||
isAISceneGenerationPanelVisible = true;
|
||||
}
|
||||
saveVisiblePanels();
|
||||
_notifyLayoutChange(); // 使用布局专用的通知方法
|
||||
}
|
||||
|
||||
// 切换AI摘要面板可见性
|
||||
void toggleAISummaryPanel() {
|
||||
// 在多面板模式下
|
||||
if (visiblePanels.contains(aiSummaryPanel)) {
|
||||
// 如果已经可见,则移除
|
||||
visiblePanels.remove(aiSummaryPanel);
|
||||
isAISummaryPanelVisible = false;
|
||||
} else {
|
||||
// 如果不可见,则添加
|
||||
visiblePanels.add(aiSummaryPanel);
|
||||
isAISummaryPanelVisible = true;
|
||||
}
|
||||
saveVisiblePanels();
|
||||
_notifyLayoutChange(); // 使用布局专用的通知方法
|
||||
}
|
||||
|
||||
// 新增:切换AI自动续写面板可见性
|
||||
void toggleAIContinueWritingPanel() {
|
||||
if (visiblePanels.contains(aiContinueWritingPanel)) {
|
||||
visiblePanels.remove(aiContinueWritingPanel);
|
||||
isAIContinueWritingPanelVisible = false;
|
||||
} else {
|
||||
visiblePanels.add(aiContinueWritingPanel);
|
||||
isAIContinueWritingPanelVisible = true;
|
||||
}
|
||||
saveVisiblePanels();
|
||||
_notifyLayoutChange(); // 使用布局专用的通知方法
|
||||
}
|
||||
|
||||
// 切换设置面板可见性
|
||||
void toggleSettingsPanel() {
|
||||
isSettingsPanelVisible = !isSettingsPanelVisible;
|
||||
if (isSettingsPanelVisible) {
|
||||
// 设置面板是全屏遮罩,不影响其他面板的显示
|
||||
}
|
||||
_notifyLayoutChange(); // 使用布局专用的通知方法
|
||||
}
|
||||
|
||||
// 切换小说设置视图可见性
|
||||
void toggleNovelSettings() {
|
||||
isNovelSettingsVisible = !isNovelSettingsVisible;
|
||||
if (isNovelSettingsVisible) {
|
||||
// 小说设置视图会替换主编辑区域,不影响侧边面板
|
||||
}
|
||||
_notifyLayoutChange(); // 使用布局专用的通知方法
|
||||
}
|
||||
|
||||
// 获取面板是否为最后一个
|
||||
bool isLastPanel(String panelId) {
|
||||
return visiblePanels.length == 1 && visiblePanels.contains(panelId);
|
||||
}
|
||||
|
||||
// 重新排序面板
|
||||
void reorderPanels(int oldIndex, int newIndex) {
|
||||
if (oldIndex < newIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
final item = visiblePanels.removeAt(oldIndex);
|
||||
visiblePanels.insert(newIndex, item);
|
||||
saveVisiblePanels();
|
||||
_notifyLayoutChange(); // 使用布局专用的通知方法
|
||||
}
|
||||
|
||||
void toggleAISettingGenerationPanel() {
|
||||
if (visiblePanels.contains(aiSettingGenerationPanel)) {
|
||||
visiblePanels.remove(aiSettingGenerationPanel);
|
||||
isAISettingGenerationPanelVisible = false;
|
||||
} else {
|
||||
visiblePanels.add(aiSettingGenerationPanel);
|
||||
isAISettingGenerationPanelVisible = true;
|
||||
}
|
||||
saveVisiblePanels();
|
||||
_notifyLayoutChange(); // 使用布局专用的通知方法
|
||||
}
|
||||
|
||||
// 切换提示词视图可见性
|
||||
void togglePromptView() {
|
||||
isPromptViewVisible = !isPromptViewVisible;
|
||||
if (isPromptViewVisible) {
|
||||
// 提示词视图是全屏替换,不影响其他面板的显示
|
||||
}
|
||||
_notifyLayoutChange(); // 使用布局专用的通知方法
|
||||
}
|
||||
|
||||
// 🚀 新增:沉浸模式状态管理
|
||||
bool isImmersiveModeEnabled = false;
|
||||
|
||||
// 🚀 新增:切换沉浸模式
|
||||
void toggleImmersiveMode() {
|
||||
isImmersiveModeEnabled = !isImmersiveModeEnabled;
|
||||
AppLogger.i('EditorLayoutManager', '切换沉浸模式: $isImmersiveModeEnabled');
|
||||
_notifyLayoutChange();
|
||||
}
|
||||
|
||||
// 🚀 新增:启用沉浸模式
|
||||
void enableImmersiveMode() {
|
||||
if (!isImmersiveModeEnabled) {
|
||||
isImmersiveModeEnabled = true;
|
||||
AppLogger.i('EditorLayoutManager', '启用沉浸模式');
|
||||
_notifyLayoutChange();
|
||||
}
|
||||
}
|
||||
|
||||
// 🚀 新增:禁用沉浸模式
|
||||
void disableImmersiveMode() {
|
||||
if (isImmersiveModeEnabled) {
|
||||
isImmersiveModeEnabled = false;
|
||||
AppLogger.i('EditorLayoutManager', '禁用沉浸模式');
|
||||
_notifyLayoutChange();
|
||||
}
|
||||
}
|
||||
|
||||
/// 隐藏所有AI面板
|
||||
void hideAllAIPanels() {
|
||||
if (visiblePanels.isNotEmpty) {
|
||||
// 保存当前配置
|
||||
_lastHiddenPanelsConfig = List<String>.from(visiblePanels);
|
||||
_saveLastHiddenPanelsConfig();
|
||||
|
||||
// 隐藏所有面板
|
||||
visiblePanels.clear();
|
||||
isAIChatSidebarVisible = false;
|
||||
isAISummaryPanelVisible = false;
|
||||
isAISceneGenerationPanelVisible = false;
|
||||
isAIContinueWritingPanelVisible = false;
|
||||
isAISettingGenerationPanelVisible = false;
|
||||
|
||||
saveVisiblePanels();
|
||||
_notifyLayoutChange();
|
||||
}
|
||||
}
|
||||
|
||||
/// 恢复隐藏前的AI面板配置
|
||||
void restoreHiddenAIPanels() {
|
||||
if (_lastHiddenPanelsConfig.isNotEmpty) {
|
||||
// 恢复面板配置
|
||||
visiblePanels.clear();
|
||||
visiblePanels.addAll(_lastHiddenPanelsConfig);
|
||||
|
||||
// 更新各面板的可见性状态
|
||||
isAIChatSidebarVisible = visiblePanels.contains(aiChatPanel);
|
||||
isAISummaryPanelVisible = visiblePanels.contains(aiSummaryPanel);
|
||||
isAISceneGenerationPanelVisible = visiblePanels.contains(aiScenePanel);
|
||||
isAIContinueWritingPanelVisible = visiblePanels.contains(aiContinueWritingPanel);
|
||||
isAISettingGenerationPanelVisible = visiblePanels.contains(aiSettingGenerationPanel);
|
||||
|
||||
saveVisiblePanels();
|
||||
_notifyLayoutChange();
|
||||
} else {
|
||||
// 如果没有保存的配置,显示默认的AI聊天面板
|
||||
toggleAIChatSidebar();
|
||||
}
|
||||
}
|
||||
|
||||
// 显示AI摘要面板
|
||||
void showAISummaryPanel() {
|
||||
if (!visiblePanels.contains(aiSummaryPanel)) {
|
||||
visiblePanels.add(aiSummaryPanel);
|
||||
isAISummaryPanelVisible = true;
|
||||
saveVisiblePanels();
|
||||
_notifyLayoutChange();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_isDisposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
319
AINoval/lib/screens/editor/managers/editor_state_manager.dart
Normal file
319
AINoval/lib/screens/editor/managers/editor_state_manager.dart
Normal file
@@ -0,0 +1,319 @@
|
||||
import 'package:ainoval/blocs/editor/editor_bloc.dart' as editor_bloc;
|
||||
import 'package:ainoval/models/novel_structure.dart' as novel_models;
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 编辑器状态管理器
|
||||
/// 负责管理编辑器的状态,如字数统计、控制器检查等
|
||||
class EditorStateManager {
|
||||
EditorStateManager();
|
||||
|
||||
// 控制器检查节流相关变量
|
||||
DateTime? _lastControllerCheckTime;
|
||||
static const Duration _controllerCheckInterval = Duration(milliseconds: 500);
|
||||
static const Duration _controllerLongCheckInterval = Duration(seconds: 5);
|
||||
editor_bloc.EditorLoaded? _lastEditorState;
|
||||
|
||||
// 字数统计缓存
|
||||
int _cachedWordCount = 0;
|
||||
String? _wordCountCacheKey;
|
||||
final Map<String, int> _memoryWordCountCache = {};
|
||||
|
||||
// 🔧 新增:模型验证状态跟踪,防止模型操作影响编辑器状态
|
||||
bool _isModelOperationInProgress = false;
|
||||
DateTime? _lastModelOperationTime;
|
||||
static const Duration _modelOperationCooldown = Duration(seconds: 5);
|
||||
|
||||
// 🔧 新增:设置模型操作状态
|
||||
void setModelOperationInProgress(bool inProgress) {
|
||||
_isModelOperationInProgress = inProgress;
|
||||
if (inProgress) {
|
||||
_lastModelOperationTime = DateTime.now();
|
||||
AppLogger.i('EditorStateManager', '模型操作开始,暂停控制器检查');
|
||||
} else {
|
||||
AppLogger.i('EditorStateManager', '模型操作结束');
|
||||
}
|
||||
}
|
||||
|
||||
// 🔧 新增:检查是否在模型操作冷却期
|
||||
bool get _isInModelOperationCooldown {
|
||||
if (_lastModelOperationTime == null) return false;
|
||||
final now = DateTime.now();
|
||||
final inCooldown = now.difference(_lastModelOperationTime!) < _modelOperationCooldown;
|
||||
if (inCooldown) {
|
||||
AppLogger.d('EditorStateManager', '模型操作冷却期中,跳过控制器检查');
|
||||
}
|
||||
return inCooldown;
|
||||
}
|
||||
|
||||
// 清除内存缓存
|
||||
void clearMemoryCache() {
|
||||
_memoryWordCountCache.clear();
|
||||
}
|
||||
|
||||
// 计算总字数
|
||||
int calculateTotalWordCount(novel_models.Novel novel) {
|
||||
// 生成缓存键:使用更新时间和场景总数作为缓存键
|
||||
final totalSceneCount = novel.acts.fold(0, (sum, act) =>
|
||||
sum + act.chapters.fold(0, (sum, chapter) =>
|
||||
sum + chapter.scenes.length));
|
||||
|
||||
final updatedAtMs = novel.updatedAt.millisecondsSinceEpoch ?? 0;
|
||||
final cacheKey = '${novel.id}_${updatedAtMs}_$totalSceneCount';
|
||||
|
||||
// 首先检查内存缓存,这是最快的检查方式
|
||||
if (_memoryWordCountCache.containsKey(cacheKey)) {
|
||||
// 完全跳过日志记录以提高性能
|
||||
return _memoryWordCountCache[cacheKey]!;
|
||||
}
|
||||
|
||||
// 如果持久化缓存有效,直接返回缓存的字数
|
||||
if (cacheKey == _wordCountCacheKey && _cachedWordCount > 0) {
|
||||
// 同时更新内存缓存
|
||||
_memoryWordCountCache[cacheKey] = _cachedWordCount;
|
||||
return _cachedWordCount;
|
||||
}
|
||||
|
||||
// 检查是否在滚动过程中 - 如果在滚动,使用旧缓存或返回0而不是计算
|
||||
final now = DateTime.now();
|
||||
if (_lastScrollHandleTime != null &&
|
||||
now.difference(_lastScrollHandleTime!) < const Duration(seconds: 2)) {
|
||||
// 在滚动过程中,如果有缓存直接用,没有就返回0避免计算
|
||||
if (_cachedWordCount > 0) {
|
||||
AppLogger.d('EditorStateManager', '滚动中使用缓存字数: $_cachedWordCount');
|
||||
// 同时更新内存缓存
|
||||
_memoryWordCountCache[cacheKey] = _cachedWordCount;
|
||||
return _cachedWordCount;
|
||||
} else {
|
||||
AppLogger.d('EditorStateManager', '滚动中跳过字数计算');
|
||||
return 0; // 返回0避免计算
|
||||
}
|
||||
}
|
||||
|
||||
// 正常情况下,记录字数计算原因
|
||||
AppLogger.i('EditorStateManager', '字数统计缓存无效,重新计算。新缓存键: $cacheKey,旧缓存键: ${_wordCountCacheKey ?? "无"}');
|
||||
|
||||
// 计算总字数(不再重复计算每个场景的字数)
|
||||
int totalWordCount = 0;
|
||||
for (final act in novel.acts) {
|
||||
for (final chapter in act.chapters) {
|
||||
for (final scene in chapter.scenes) {
|
||||
// 直接使用存储的字数,不重新计算
|
||||
totalWordCount += scene.wordCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新缓存,并减少日志输出
|
||||
_wordCountCacheKey = cacheKey;
|
||||
_cachedWordCount = totalWordCount;
|
||||
|
||||
// 同时更新内存缓存
|
||||
_memoryWordCountCache[cacheKey] = totalWordCount;
|
||||
|
||||
AppLogger.i('EditorStateManager', '小说总字数计算结果: $totalWordCount (Acts: ${novel.acts.length}, 更新缓存键: $cacheKey)');
|
||||
return totalWordCount;
|
||||
}
|
||||
|
||||
// 滚动处理节流
|
||||
DateTime? _lastScrollHandleTime;
|
||||
|
||||
// 检查是否应该重建Quill控制器
|
||||
bool shouldCheckControllers(editor_bloc.EditorLoaded state, {bool isLayoutOnlyChange = false}) {
|
||||
if (_isModelOperationInProgress || _isInModelOperationCooldown) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果是纯布局变化,跳过控制器检查
|
||||
if (isLayoutOnlyChange) {
|
||||
if (kDebugMode) {
|
||||
AppLogger.d('EditorStateManager', '跳过控制器检查 - 原因: 纯布局变化');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (state.lastUpdateSilent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果状态对象引用变化,表示小说数据结构可能发生变化,需要检查
|
||||
final bool stateChanged = _lastEditorState != state;
|
||||
final now = DateTime.now();
|
||||
|
||||
// 检查是否刚完成加载且内容有变化 (最重要的条件)
|
||||
bool justFinishedLoadingWithChanges = false;
|
||||
bool contentChanged = false; // Calculate contentChanged regardless of other checks
|
||||
|
||||
if (stateChanged && _lastEditorState != null) {
|
||||
// 检查小说结构是否有实质变化,主要比较acts和scenes的数量
|
||||
final oldNovel = _lastEditorState!.novel;
|
||||
final newNovel = state.novel;
|
||||
|
||||
// 🔧 修复:更严格的内容变化检查,避免将非内容变化误认为内容变化
|
||||
// 只有在小说结构本身发生变化时才认为是内容变化
|
||||
|
||||
// 首先检查小说基本信息是否变化(排除时间戳)
|
||||
if (oldNovel.id != newNovel.id ||
|
||||
oldNovel.title != newNovel.title) {
|
||||
contentChanged = true;
|
||||
AppLogger.i('EditorStateManager', '检测到小说基本信息变化');
|
||||
}
|
||||
|
||||
// 检查act数量是否变化
|
||||
else if (oldNovel.acts.length != newNovel.acts.length) {
|
||||
contentChanged = true;
|
||||
AppLogger.i('EditorStateManager', '检测到Act数量变化: ${oldNovel.acts.length} -> ${newNovel.acts.length}');
|
||||
}
|
||||
else {
|
||||
// 检查章节和场景数量是否变化
|
||||
bool structureChanged = false;
|
||||
|
||||
for (int i = 0; i < oldNovel.acts.length && i < newNovel.acts.length; i++) {
|
||||
final oldAct = oldNovel.acts[i];
|
||||
final newAct = newNovel.acts[i];
|
||||
|
||||
// 检查Act基本信息
|
||||
if (oldAct.id != newAct.id || oldAct.title != newAct.title) {
|
||||
structureChanged = true;
|
||||
AppLogger.i('EditorStateManager', '检测到Act[$i]基本信息变化');
|
||||
break;
|
||||
}
|
||||
|
||||
// 检查章节数量
|
||||
if (oldAct.chapters.length != newAct.chapters.length) {
|
||||
structureChanged = true;
|
||||
AppLogger.i('EditorStateManager', '检测到Act[$i]章节数量变化: ${oldAct.chapters.length} -> ${newAct.chapters.length}');
|
||||
break;
|
||||
}
|
||||
|
||||
// 检查每个章节的场景数量
|
||||
for (int j = 0; j < oldAct.chapters.length && j < newAct.chapters.length; j++) {
|
||||
final oldChapter = oldAct.chapters[j];
|
||||
final newChapter = newAct.chapters[j];
|
||||
|
||||
// 检查Chapter基本信息
|
||||
if (oldChapter.id != newChapter.id || oldChapter.title != newChapter.title) {
|
||||
structureChanged = true;
|
||||
AppLogger.i('EditorStateManager', '检测到Chapter[$i][$j]基本信息变化');
|
||||
break;
|
||||
}
|
||||
|
||||
// 检查场景数量
|
||||
if (oldChapter.scenes.length != newChapter.scenes.length) {
|
||||
structureChanged = true;
|
||||
AppLogger.i('EditorStateManager', '检测到Chapter[$i][$j]场景数量变化: ${oldChapter.scenes.length} -> ${newChapter.scenes.length}');
|
||||
break;
|
||||
}
|
||||
|
||||
// 检查场景ID是否变化(新增/删除场景)
|
||||
final oldSceneIds = oldChapter.scenes.map((s) => s.id).toSet();
|
||||
final newSceneIds = newChapter.scenes.map((s) => s.id).toSet();
|
||||
if (oldSceneIds.length != newSceneIds.length ||
|
||||
!oldSceneIds.containsAll(newSceneIds) ||
|
||||
!newSceneIds.containsAll(oldSceneIds)) {
|
||||
structureChanged = true;
|
||||
AppLogger.i('EditorStateManager', '检测到Chapter[$i][$j]场景ID变化');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (structureChanged) break;
|
||||
}
|
||||
|
||||
contentChanged = structureChanged;
|
||||
}
|
||||
|
||||
// *** Check if loading just finished and content actually changed ***
|
||||
if (_lastEditorState!.isLoading && !state.isLoading && contentChanged) {
|
||||
justFinishedLoadingWithChanges = true;
|
||||
// 仅在调试模式下记录日志
|
||||
if (kDebugMode) {
|
||||
AppLogger.i('EditorStateManager', '检测到加载完成且内容有变化,强制检查控制器。');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// *** Bypass throttle if loading just finished with changes ***
|
||||
if (justFinishedLoadingWithChanges) {
|
||||
_lastControllerCheckTime = now;
|
||||
_lastEditorState = state; // Update state reference
|
||||
// 仅在调试模式下记录日志
|
||||
if (kDebugMode) {
|
||||
AppLogger.i('EditorStateManager', '触发控制器检查 - 原因: 加载完成');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 🔧 修复:增加节流时间到15秒,减少不必要的控制器检查
|
||||
// 极端节流:如果距离上次检查时间不足15秒,且不是刚加载完成,绝对不检查
|
||||
if (_lastControllerCheckTime != null &&
|
||||
now.difference(_lastControllerCheckTime!) < const Duration(seconds: 15)) {
|
||||
// 记录日志:禁止频繁检查 (仅在状态变化且调试模式下记录,避免日志刷屏)
|
||||
if (stateChanged && kDebugMode) {
|
||||
AppLogger.d('EditorStateManager', '节流: 禁止15秒内重复检查控制器');
|
||||
}
|
||||
// 更新状态引用,即使被节流也要更新,以便下次比较
|
||||
_lastEditorState = state;
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查活动元素是否变化
|
||||
bool activeElementsChanged = false;
|
||||
if (stateChanged && _lastEditorState != null) {
|
||||
activeElementsChanged =
|
||||
_lastEditorState!.activeActId != state.activeActId ||
|
||||
_lastEditorState!.activeChapterId != state.activeChapterId ||
|
||||
_lastEditorState!.activeSceneId != state.activeSceneId;
|
||||
}
|
||||
|
||||
// 🔧 修复:只有在以下严格条件下才重建控制器
|
||||
// 1. 首次加载(_lastControllerCheckTime为null)
|
||||
// 2. 确实的内容结构变化(添加/删除场景或章节)
|
||||
// 3. 活动元素变化
|
||||
// 4. 长时间间隔超时 (15秒)
|
||||
final bool timeIntervalExceeded = _lastControllerCheckTime == null ||
|
||||
now.difference(_lastControllerCheckTime!) > const Duration(seconds: 15);
|
||||
|
||||
final bool needsCheck = _lastControllerCheckTime == null ||
|
||||
contentChanged ||
|
||||
activeElementsChanged ||
|
||||
timeIntervalExceeded;
|
||||
|
||||
// 更新状态引用,用于下次比较
|
||||
_lastEditorState = state;
|
||||
|
||||
// 如果需要检查,更新最后检查时间
|
||||
if (needsCheck) {
|
||||
_lastControllerCheckTime = now;
|
||||
|
||||
// 仅在调试模式下记录日志
|
||||
if (kDebugMode) {
|
||||
String reason;
|
||||
if (contentChanged) {
|
||||
reason = '内容结构变化';
|
||||
} else if (activeElementsChanged) {
|
||||
reason = '活动元素变化';
|
||||
} else if (timeIntervalExceeded) {
|
||||
reason = '时间间隔超过(15秒)';
|
||||
} else {
|
||||
reason = '首次加载';
|
||||
}
|
||||
|
||||
AppLogger.i('EditorStateManager', '触发控制器检查 - 原因: $reason');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 内容更新通知器
|
||||
final ValueNotifier<String> contentUpdateNotifier = ValueNotifier<String>('');
|
||||
|
||||
// 通知内容更新
|
||||
void notifyContentUpdate(String reason) {
|
||||
AppLogger.i('EditorStateManager', '通知内容更新: $reason');
|
||||
contentUpdateNotifier.value = '${DateTime.now().millisecondsSinceEpoch}_$reason';
|
||||
}
|
||||
}
|
||||
784
AINoval/lib/screens/editor/utils/document_parser.dart
Normal file
784
AINoval/lib/screens/editor/utils/document_parser.dart
Normal file
@@ -0,0 +1,784 @@
|
||||
/**
|
||||
* 文档解析工具类
|
||||
*
|
||||
* 用于解析和处理文本内容,将其转换为可编辑的Quill文档格式。
|
||||
* 提供两种解析方法:安全解析(在UI线程使用)和隔离解析(在计算隔离中使用)。
|
||||
*/
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:isolate';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_quill/flutter_quill.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:ainoval/utils/quill_helper.dart';
|
||||
|
||||
/// 优化的文档解析器
|
||||
///
|
||||
/// 包含以下优化特性:
|
||||
/// 1. LRU缓存机制 - 避免重复解析
|
||||
/// 2. 解析队列和优先级控制 - 减少并发竞争
|
||||
/// 3. 批量解析 - 提高吞吐量
|
||||
/// 4. 智能预解析 - 提前准备常用内容
|
||||
/// 5. 解析结果压缩 - 减少内存占用
|
||||
class DocumentParser {
|
||||
static final DocumentParser _instance = DocumentParser._internal();
|
||||
factory DocumentParser() => _instance;
|
||||
DocumentParser._internal();
|
||||
|
||||
// LRU缓存配置
|
||||
static const int _maxCacheSize = 50; // 从50
|
||||
static const int _maxCacheMemoryMB = 200; // 从100MB增加到200MB
|
||||
|
||||
// 解析队列配置
|
||||
static const int _maxConcurrentParsing = 5; // 从3增加到5个并发解析
|
||||
static const Duration _parseTimeout = Duration(seconds: 8); // 从5秒增加到8秒
|
||||
|
||||
// 缓存存储
|
||||
final Map<String, _CachedDocument> _documentCache = {};
|
||||
final List<String> _cacheAccessOrder = []; // LRU访问顺序
|
||||
|
||||
// 解析队列
|
||||
final List<_ParseRequest> _parseQueue = [];
|
||||
int _currentParsingCount = 0;
|
||||
|
||||
// 统计信息
|
||||
int _cacheHits = 0;
|
||||
int _cacheMisses = 0;
|
||||
int _totalParseTime = 0;
|
||||
int _totalParseCount = 0;
|
||||
|
||||
/// 解析文档(带缓存和优先级)
|
||||
static Future<Document> parseDocumentOptimized(
|
||||
String content, {
|
||||
int priority = 5, // 优先级 1-10,10最高
|
||||
String? cacheKey,
|
||||
bool useCache = true,
|
||||
}) async {
|
||||
return DocumentParser()._parseWithCache(
|
||||
content,
|
||||
priority: priority,
|
||||
cacheKey: cacheKey,
|
||||
useCache: useCache,
|
||||
);
|
||||
}
|
||||
|
||||
/// 原始解析方法(保持兼容性)
|
||||
static Future<Document> parseDocumentInIsolate(String content) async {
|
||||
return DocumentParser()._parseWithCache(content, priority: 5);
|
||||
}
|
||||
|
||||
/// 安全解析文档(用于UI线程,兼容性方法)
|
||||
static Future<Document> parseDocumentSafely(String content) async {
|
||||
return DocumentParser()._parseWithCache(content, priority: 5, useCache: true);
|
||||
}
|
||||
|
||||
/// 同步解析文档(用于控制器初始化)
|
||||
///
|
||||
/// 这个方法用于需要立即返回Document的场景,如QuillController初始化
|
||||
/// 使用简化解析逻辑,避免异步操作
|
||||
static Document parseDocumentSync(String content) {
|
||||
return DocumentParser()._parseDocumentSimple(content);
|
||||
}
|
||||
|
||||
/// 批量解析文档
|
||||
static Future<List<Document>> parseBatchDocuments(
|
||||
List<String> contents, {
|
||||
int priority = 5,
|
||||
List<String>? cacheKeys,
|
||||
}) async {
|
||||
return DocumentParser()._parseBatch(contents, priority: priority, cacheKeys: cacheKeys);
|
||||
}
|
||||
|
||||
/// 预加载文档到缓存(增强版)
|
||||
static Future<void> preloadDocuments(
|
||||
List<String> contents, {
|
||||
List<String>? cacheKeys,
|
||||
int maxPreloadConcurrency = 2, // 限制预加载并发数,避免影响正常解析
|
||||
}) async {
|
||||
final parser = DocumentParser();
|
||||
final futures = <Future<void>>[];
|
||||
|
||||
for (int i = 0; i < contents.length; i++) {
|
||||
final content = contents[i];
|
||||
final cacheKey = cacheKeys != null && i < cacheKeys.length
|
||||
? cacheKeys[i]
|
||||
: parser._generateCacheKey(content);
|
||||
|
||||
// 检查是否已缓存
|
||||
if (!parser._documentCache.containsKey(cacheKey)) {
|
||||
// 创建预加载Future
|
||||
final preloadFuture = parser._parseWithCache(
|
||||
content,
|
||||
priority: 1, // 最低优先级后台解析
|
||||
cacheKey: cacheKey,
|
||||
useCache: true
|
||||
).then((_) {
|
||||
AppLogger.d('DocumentParser', '预加载完成: $cacheKey');
|
||||
}).catchError((e) {
|
||||
AppLogger.w('DocumentParser', '预加载失败: $cacheKey, $e');
|
||||
});
|
||||
|
||||
futures.add(preloadFuture);
|
||||
|
||||
// 控制并发数量,每批处理maxPreloadConcurrency个
|
||||
if (futures.length >= maxPreloadConcurrency) {
|
||||
await Future.wait(futures);
|
||||
futures.clear();
|
||||
// 短暂延迟,避免阻塞主线程
|
||||
await Future.delayed(const Duration(milliseconds: 10));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理剩余的预加载任务
|
||||
if (futures.isNotEmpty) {
|
||||
await Future.wait(futures);
|
||||
}
|
||||
|
||||
AppLogger.i('DocumentParser', '批量预加载完成,处理了${contents.length}个文档');
|
||||
}
|
||||
|
||||
/// 清理缓存
|
||||
static void clearCache() {
|
||||
final parser = DocumentParser();
|
||||
parser._documentCache.clear();
|
||||
parser._cacheAccessOrder.clear();
|
||||
parser._cacheHits = 0;
|
||||
parser._cacheMisses = 0;
|
||||
parser._totalParseTime = 0;
|
||||
parser._totalParseCount = 0;
|
||||
AppLogger.i('DocumentParser', '缓存已清理');
|
||||
}
|
||||
|
||||
/// 获取缓存统计信息
|
||||
static Map<String, dynamic> getCacheStats() {
|
||||
final parser = DocumentParser();
|
||||
final cacheSize = parser._documentCache.length;
|
||||
final memoryUsageMB = parser._calculateCacheMemoryUsage() / 1024 / 1024;
|
||||
final hitRate = parser._cacheHits + parser._cacheMisses > 0
|
||||
? (parser._cacheHits / (parser._cacheHits + parser._cacheMisses) * 100).toStringAsFixed(1) + '%'
|
||||
: '0.0%';
|
||||
final avgParseTimeMs = parser._totalParseCount > 0
|
||||
? (parser._totalParseTime / parser._totalParseCount).toStringAsFixed(1)
|
||||
: '0.0';
|
||||
|
||||
return {
|
||||
'cacheSize': cacheSize,
|
||||
'memoryUsageMB': memoryUsageMB.toStringAsFixed(2),
|
||||
'hitRate': hitRate,
|
||||
'avgParseTimeMs': avgParseTimeMs,
|
||||
'queueLength': parser._parseQueue.length,
|
||||
'currentParsing': parser._currentParsingCount,
|
||||
'totalHits': parser._cacheHits,
|
||||
'totalMisses': parser._cacheMisses,
|
||||
'totalParseCount': parser._totalParseCount,
|
||||
'maxCacheSize': _maxCacheSize,
|
||||
'maxMemoryMB': _maxCacheMemoryMB,
|
||||
};
|
||||
}
|
||||
|
||||
/// 核心解析方法(带缓存)
|
||||
Future<Document> _parseWithCache(
|
||||
String content, {
|
||||
int priority = 5,
|
||||
String? cacheKey,
|
||||
bool useCache = true,
|
||||
}) async {
|
||||
final key = cacheKey ?? _generateCacheKey(content);
|
||||
|
||||
// 🚀 快速路径:空内容直接返回
|
||||
if (content.isEmpty) {
|
||||
AppLogger.d('DocumentParser', '快速路径:空内容 $key');
|
||||
return Document.fromJson([{'insert': '\n'}]);
|
||||
}
|
||||
|
||||
// 尝试从缓存获取
|
||||
if (useCache && _documentCache.containsKey(key)) {
|
||||
_updateCacheAccess(key);
|
||||
_cacheHits++;
|
||||
AppLogger.d('DocumentParser', '缓存命中: $key');
|
||||
return _documentCache[key]!.document;
|
||||
}
|
||||
|
||||
_cacheMisses++;
|
||||
|
||||
// 🚀 快速路径:内容过大时使用简化解析
|
||||
if (content.length > 100000) { // 大于100KB使用简化解析
|
||||
AppLogger.w('DocumentParser', '内容过大($content.length字符),使用简化解析: $key');
|
||||
try {
|
||||
final simpleDocument = _parseDocumentSimple(content);
|
||||
if (useCache) {
|
||||
_storeInCache(key, simpleDocument, content.length);
|
||||
}
|
||||
return simpleDocument;
|
||||
} catch (e) {
|
||||
AppLogger.e('DocumentParser', '简化解析失败: $key', e);
|
||||
return Document.fromJson([{'insert': '内容过大,解析失败\n'}]);
|
||||
}
|
||||
}
|
||||
|
||||
// 🚀 快速路径:如果是纯文本且不太长,直接解析
|
||||
if (content.length < 1000 && !content.trim().startsWith('[') && !content.trim().startsWith('{')) {
|
||||
AppLogger.d('DocumentParser', '快速路径:纯文本解析 $key');
|
||||
final quickDocument = Document.fromJson([{'insert': '$content\n'}]);
|
||||
if (useCache) {
|
||||
_storeInCache(key, quickDocument, content.length);
|
||||
}
|
||||
return quickDocument;
|
||||
}
|
||||
|
||||
// 创建解析请求
|
||||
final completer = Completer<Document>();
|
||||
final request = _ParseRequest(
|
||||
content: content,
|
||||
cacheKey: key,
|
||||
priority: priority,
|
||||
completer: completer,
|
||||
useCache: useCache,
|
||||
);
|
||||
|
||||
_parseQueue.add(request);
|
||||
_parseQueue.sort((a, b) => b.priority.compareTo(a.priority)); // 按优先级排序
|
||||
|
||||
_processParseQueue();
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
/// 批量解析
|
||||
Future<List<Document>> _parseBatch(
|
||||
List<String> contents, {
|
||||
int priority = 5,
|
||||
List<String>? cacheKeys,
|
||||
}) async {
|
||||
final futures = <Future<Document>>[];
|
||||
|
||||
for (int i = 0; i < contents.length; i++) {
|
||||
final cacheKey = cacheKeys != null && i < cacheKeys.length ? cacheKeys[i] : null;
|
||||
futures.add(_parseWithCache(contents[i], priority: priority, cacheKey: cacheKey));
|
||||
}
|
||||
|
||||
return Future.wait(futures);
|
||||
}
|
||||
|
||||
/// 处理解析队列
|
||||
void _processParseQueue() {
|
||||
while (_parseQueue.isNotEmpty && _currentParsingCount < _maxConcurrentParsing) {
|
||||
final request = _parseQueue.removeAt(0);
|
||||
_currentParsingCount++;
|
||||
|
||||
_executeParseRequest(request);
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行解析请求
|
||||
void _executeParseRequest(_ParseRequest request) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
|
||||
try {
|
||||
// 🚀 预估解析时间,如果内容过大直接使用简化解析
|
||||
if (request.content.length > 50000) {
|
||||
AppLogger.w('DocumentParser', '内容较大(${request.content.length}字符),使用简化解析: ${request.cacheKey}');
|
||||
final document = _parseDocumentSimple(request.content);
|
||||
|
||||
stopwatch.stop();
|
||||
final parseTime = stopwatch.elapsedMilliseconds;
|
||||
_totalParseTime += parseTime;
|
||||
_totalParseCount++;
|
||||
|
||||
if (request.useCache) {
|
||||
_storeInCache(request.cacheKey, document, request.content.length);
|
||||
}
|
||||
|
||||
AppLogger.d('DocumentParser', '简化解析完成: ${request.cacheKey}, 耗时: ${parseTime}ms');
|
||||
request.completer.complete(document);
|
||||
return;
|
||||
}
|
||||
|
||||
// 正常解析流程
|
||||
final document = await _parseInIsolateWithTimeout(request.content);
|
||||
|
||||
stopwatch.stop();
|
||||
final parseTime = stopwatch.elapsedMilliseconds;
|
||||
_totalParseTime += parseTime;
|
||||
_totalParseCount++;
|
||||
|
||||
// 🚨 性能监控:如果解析时间过长,记录警告
|
||||
if (parseTime > 1000) {
|
||||
AppLogger.w('DocumentParser', '⚠️ 解析时间过长: ${request.cacheKey}, 耗时: ${parseTime}ms, 内容长度: ${request.content.length}');
|
||||
}
|
||||
|
||||
// 存储到缓存
|
||||
if (request.useCache) {
|
||||
_storeInCache(request.cacheKey, document, request.content.length);
|
||||
}
|
||||
|
||||
AppLogger.d('DocumentParser', '解析完成: ${request.cacheKey}, 耗时: ${parseTime}ms');
|
||||
request.completer.complete(document);
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
stopwatch.stop();
|
||||
AppLogger.e('DocumentParser', '解析失败: ${request.cacheKey}', e, stackTrace);
|
||||
|
||||
// 🚀 解析失败时使用简化解析作为备用方案
|
||||
try {
|
||||
AppLogger.i('DocumentParser', '尝试简化解析备用方案: ${request.cacheKey}');
|
||||
final fallbackDocument = _parseDocumentSimple(request.content);
|
||||
|
||||
if (request.useCache) {
|
||||
_storeInCache(request.cacheKey, fallbackDocument, request.content.length);
|
||||
}
|
||||
|
||||
request.completer.complete(fallbackDocument);
|
||||
AppLogger.i('DocumentParser', '简化解析备用方案成功: ${request.cacheKey}');
|
||||
} catch (fallbackError) {
|
||||
// 最后的备用方案:创建错误文档
|
||||
final errorDocument = Document.fromJson([
|
||||
{'insert': '⚠️ 文档解析失败\n内容加载出现问题,请刷新重试。\n\n原始内容预览:\n'},
|
||||
{'insert': request.content.length > 200 ? '${request.content.substring(0, 200)}...\n' : '${request.content}\n'},
|
||||
]);
|
||||
|
||||
request.completer.complete(errorDocument);
|
||||
AppLogger.e('DocumentParser', '所有解析方案都失败: ${request.cacheKey}', fallbackError);
|
||||
}
|
||||
} finally {
|
||||
_currentParsingCount--;
|
||||
_processParseQueue(); // 处理队列中的下一个请求
|
||||
}
|
||||
}
|
||||
|
||||
/// 在隔离中解析(带超时)
|
||||
Future<Document> _parseInIsolateWithTimeout(String content) async {
|
||||
// 🚀 根据内容大小动态调整超时时间
|
||||
Duration timeout;
|
||||
if (content.length < 1000) {
|
||||
timeout = const Duration(seconds: 2); // 小内容2秒超时
|
||||
} else if (content.length < 10000) {
|
||||
timeout = const Duration(seconds: 4); // 中等内容4秒超时
|
||||
} else {
|
||||
timeout = const Duration(seconds: 6); // 大内容6秒超时,不再使用8秒
|
||||
}
|
||||
|
||||
return compute(_isolateParseFunction, content).timeout(
|
||||
timeout,
|
||||
onTimeout: () {
|
||||
AppLogger.w('DocumentParser', '解析超时(${timeout.inSeconds}秒),使用简化解析,内容长度: ${content.length}');
|
||||
return _parseDocumentSimple(content);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 生成缓存键
|
||||
String _generateCacheKey(String content) {
|
||||
// 使用内容长度和特征字符生成更稳定的缓存键
|
||||
final length = content.length;
|
||||
if (length == 0) return 'doc_empty_0';
|
||||
|
||||
// 采样关键字符位置,避免完整内容哈希
|
||||
final sample1 = content.codeUnitAt(0);
|
||||
final sample2 = length > 10 ? content.codeUnitAt(length ~/ 4) : 0;
|
||||
final sample3 = length > 20 ? content.codeUnitAt(length ~/ 2) : 0;
|
||||
final sample4 = length > 30 ? content.codeUnitAt(length * 3 ~/ 4) : 0;
|
||||
final sample5 = content.codeUnitAt(length - 1);
|
||||
|
||||
// 使用字符码点和生成稳定哈希
|
||||
int stableHash = length;
|
||||
stableHash = (stableHash * 31 + sample1) & 0x7FFFFFFF;
|
||||
stableHash = (stableHash * 31 + sample2) & 0x7FFFFFFF;
|
||||
stableHash = (stableHash * 31 + sample3) & 0x7FFFFFFF;
|
||||
stableHash = (stableHash * 31 + sample4) & 0x7FFFFFFF;
|
||||
stableHash = (stableHash * 31 + sample5) & 0x7FFFFFFF;
|
||||
|
||||
return 'doc_${length}_${stableHash}';
|
||||
}
|
||||
|
||||
/// 存储到缓存
|
||||
void _storeInCache(String key, Document document, int contentSize) {
|
||||
// 检查缓存大小限制
|
||||
_enforceCacheLimits();
|
||||
|
||||
final cachedDoc = _CachedDocument(
|
||||
document: document,
|
||||
contentSize: contentSize,
|
||||
accessTime: DateTime.now(),
|
||||
);
|
||||
|
||||
_documentCache[key] = cachedDoc;
|
||||
_updateCacheAccess(key);
|
||||
}
|
||||
|
||||
/// 更新缓存访问顺序
|
||||
void _updateCacheAccess(String key) {
|
||||
_cacheAccessOrder.remove(key);
|
||||
_cacheAccessOrder.add(key); // 移到最后(最近访问)
|
||||
|
||||
if (_documentCache.containsKey(key)) {
|
||||
_documentCache[key]!.accessTime = DateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
/// 强制执行缓存限制
|
||||
void _enforceCacheLimits() {
|
||||
// 检查数量限制
|
||||
while (_documentCache.length >= _maxCacheSize && _cacheAccessOrder.isNotEmpty) {
|
||||
final oldestKey = _cacheAccessOrder.removeAt(0);
|
||||
_documentCache.remove(oldestKey);
|
||||
}
|
||||
|
||||
// 检查内存限制
|
||||
while (_calculateCacheMemoryUsage() > _maxCacheMemoryMB * 1024 * 1024 && _cacheAccessOrder.isNotEmpty) {
|
||||
final oldestKey = _cacheAccessOrder.removeAt(0);
|
||||
_documentCache.remove(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// 计算缓存内存使用量
|
||||
int _calculateCacheMemoryUsage() {
|
||||
return _documentCache.values.fold(0, (sum, doc) => sum + doc.contentSize);
|
||||
}
|
||||
|
||||
/// 简化解析方法 - 用于大内容或解析失败的备用方案
|
||||
Document _parseDocumentSimple(String content) {
|
||||
try {
|
||||
// 🚀 快速检查:如果是空内容
|
||||
if (content.trim().isEmpty) {
|
||||
return Document.fromJson([{'insert': '\n'}]);
|
||||
}
|
||||
|
||||
// 🚀 快速检查:如果明显是纯文本
|
||||
final trimmedContent = content.trim();
|
||||
if (!trimmedContent.startsWith('[') && !trimmedContent.startsWith('{')) {
|
||||
// 处理纯文本,保留换行
|
||||
final lines = content.split('\n');
|
||||
final ops = <Map<String, dynamic>>[];
|
||||
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
if (lines[i].isNotEmpty) {
|
||||
ops.add({'insert': lines[i]});
|
||||
}
|
||||
if (i < lines.length - 1 || content.endsWith('\n')) {
|
||||
ops.add({'insert': '\n'});
|
||||
}
|
||||
}
|
||||
|
||||
if (ops.isEmpty) {
|
||||
ops.add({'insert': '\n'});
|
||||
}
|
||||
|
||||
return Document.fromJson(ops);
|
||||
}
|
||||
|
||||
// 🚀 尝试快速JSON解析
|
||||
try {
|
||||
final jsonData = jsonDecode(content);
|
||||
|
||||
if (jsonData is List) {
|
||||
// 验证是否是有效的Quill操作数组
|
||||
bool isValidOps = true;
|
||||
bool hasStyleAttributes = false;
|
||||
|
||||
for (final op in jsonData) {
|
||||
if (op is! Map || !op.containsKey('insert')) {
|
||||
isValidOps = false;
|
||||
break;
|
||||
}
|
||||
// 检查是否有样式属性
|
||||
if (op is Map && op.containsKey('attributes')) {
|
||||
hasStyleAttributes = true;
|
||||
final attributes = op['attributes'] as Map<String, dynamic>?;
|
||||
if (attributes != null) {
|
||||
AppLogger.d('DocumentParser/_parseDocumentSimple',
|
||||
'🎨 发现样式属性: ${attributes.keys.join(', ')}');
|
||||
|
||||
if (attributes.containsKey('color')) {
|
||||
AppLogger.d('DocumentParser/_parseDocumentSimple',
|
||||
'🎨 文字颜色: ${attributes['color']}');
|
||||
}
|
||||
if (attributes.containsKey('background')) {
|
||||
AppLogger.d('DocumentParser/_parseDocumentSimple',
|
||||
'🎨 背景颜色: ${attributes['background']}');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasStyleAttributes) {
|
||||
AppLogger.i('DocumentParser/_parseDocumentSimple',
|
||||
'🎨 简化解析包含样式属性的内容,操作数量: ${jsonData.length}');
|
||||
}
|
||||
|
||||
if (isValidOps) {
|
||||
return Document.fromJson(jsonData);
|
||||
}
|
||||
} else if (jsonData is Map && jsonData.containsKey('ops')) {
|
||||
final ops = jsonData['ops'];
|
||||
if (ops is List) {
|
||||
// 检查ops中的样式属性
|
||||
bool hasStyleAttributes = false;
|
||||
for (final op in ops) {
|
||||
if (op is Map && op.containsKey('attributes')) {
|
||||
hasStyleAttributes = true;
|
||||
final attributes = op['attributes'] as Map<String, dynamic>?;
|
||||
if (attributes != null) {
|
||||
AppLogger.d('DocumentParser/_parseDocumentSimple',
|
||||
'🎨 ops中发现样式属性: ${attributes.keys.join(', ')}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasStyleAttributes) {
|
||||
AppLogger.i('DocumentParser/_parseDocumentSimple',
|
||||
'🎨 简化解析ops格式包含样式属性的内容,操作数量: ${ops.length}');
|
||||
}
|
||||
|
||||
return Document.fromJson(ops);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果JSON格式不正确,当作文本处理
|
||||
return Document.fromJson([
|
||||
{'insert': '⚠️ 内容格式异常,显示原始内容:\n'},
|
||||
{'insert': content.length > 1000 ? '${content.substring(0, 1000)}...\n' : '$content\n'}
|
||||
]);
|
||||
|
||||
} catch (jsonError) {
|
||||
// JSON解析失败,当作纯文本处理
|
||||
AppLogger.d('DocumentParser', '简化解析:JSON解析失败,当作纯文本处理');
|
||||
return Document.fromJson([
|
||||
{'insert': content.length > 10000 ? '${content.substring(0, 10000)}...\n' : '$content\n'}
|
||||
]);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
AppLogger.w('DocumentParser', '简化解析也失败,使用最基础的文档', e);
|
||||
return Document.fromJson([
|
||||
{'insert': '⚠️ 内容解析失败\n'},
|
||||
{'insert': '内容长度: ${content.length} 字符\n'},
|
||||
{'insert': '请联系技术支持\n'}
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// 优化缓存键生成 - 使用更稳定的hash算法
|
||||
String _generateCacheKeyOptimized(String content) {
|
||||
// 统一使用新的稳定缓存键生成方法
|
||||
return _generateCacheKey(content);
|
||||
}
|
||||
|
||||
/// 检查缓存健康状况
|
||||
static Map<String, dynamic> checkCacheHealth() {
|
||||
final parser = DocumentParser();
|
||||
final stats = getCacheStats();
|
||||
final issues = <String>[];
|
||||
|
||||
// 检查缓存命中率
|
||||
final hitRateNum = parser._cacheHits + parser._cacheMisses > 0
|
||||
? (parser._cacheHits / (parser._cacheHits + parser._cacheMisses) * 100)
|
||||
: 0.0;
|
||||
|
||||
if (hitRateNum < 30) {
|
||||
issues.add('缓存命中率过低 (${hitRateNum.toStringAsFixed(1)}%)');
|
||||
}
|
||||
|
||||
// 检查平均解析时间
|
||||
final avgParseTime = parser._totalParseCount > 0
|
||||
? (parser._totalParseTime / parser._totalParseCount)
|
||||
: 0.0;
|
||||
|
||||
if (avgParseTime > 500) {
|
||||
issues.add('平均解析时间过长 (${avgParseTime.toStringAsFixed(1)}ms)');
|
||||
}
|
||||
|
||||
// 检查队列长度
|
||||
if (parser._parseQueue.length > 10) {
|
||||
issues.add('解析队列过长 (${parser._parseQueue.length})');
|
||||
}
|
||||
|
||||
return {
|
||||
'isHealthy': issues.isEmpty,
|
||||
'issues': issues,
|
||||
'stats': stats,
|
||||
'recommendations': _generateRecommendations(issues),
|
||||
};
|
||||
}
|
||||
|
||||
/// 生成优化建议
|
||||
static List<String> _generateRecommendations(List<String> issues) {
|
||||
final recommendations = <String>[];
|
||||
|
||||
if (issues.any((issue) => issue.contains('缓存命中率'))) {
|
||||
recommendations.add('增加预加载范围');
|
||||
recommendations.add('检查缓存键生成逻辑');
|
||||
recommendations.add('考虑增加缓存大小');
|
||||
}
|
||||
|
||||
if (issues.any((issue) => issue.contains('解析时间'))) {
|
||||
recommendations.add('检查内容复杂度');
|
||||
recommendations.add('考虑内容预处理');
|
||||
recommendations.add('增加并发解析数量');
|
||||
}
|
||||
|
||||
if (issues.any((issue) => issue.contains('队列'))) {
|
||||
recommendations.add('减少同时触发的解析请求');
|
||||
recommendations.add('提高高优先级任务处理速度');
|
||||
recommendations.add('检查是否有解析死锁');
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
}
|
||||
|
||||
/// 智能缓存预热 - 新增功能
|
||||
static Future<void> warmupCache({
|
||||
List<String>? priorityContents,
|
||||
int warmupSize = 10,
|
||||
}) async {
|
||||
final parser = DocumentParser();
|
||||
|
||||
AppLogger.i('DocumentParser', '开始缓存预热...');
|
||||
|
||||
// 预热常见的文档格式
|
||||
final commonFormats = [
|
||||
'[{"insert":"\\n"}]', // 空文档
|
||||
'[{"insert":"测试文本\\n"}]', // 简单文本
|
||||
'[{"insert":"测试文本\\n","attributes":{"bold":true}}]', // 带格式文本
|
||||
'简单纯文本内容', // 纯文本
|
||||
'{"insert":"旧格式文档\\n"}', // 旧格式
|
||||
];
|
||||
|
||||
// 预热优先内容
|
||||
if (priorityContents != null) {
|
||||
await preloadDocuments(
|
||||
priorityContents.take(warmupSize).toList(),
|
||||
maxPreloadConcurrency: 3,
|
||||
);
|
||||
}
|
||||
|
||||
// 预热常见格式
|
||||
await preloadDocuments(
|
||||
commonFormats,
|
||||
cacheKeys: List.generate(commonFormats.length, (i) => 'warmup_format_$i'),
|
||||
maxPreloadConcurrency: 2,
|
||||
);
|
||||
|
||||
AppLogger.i('DocumentParser', '缓存预热完成');
|
||||
}
|
||||
}
|
||||
|
||||
/// 隔离中的解析函数
|
||||
Document _isolateParseFunction(String content) {
|
||||
try {
|
||||
if (content.isEmpty) {
|
||||
return Document.fromJson([{'insert': '\n'}]);
|
||||
}
|
||||
|
||||
// 优化的JSON解析
|
||||
if (content.trim().startsWith('[') || content.trim().startsWith('{')) {
|
||||
final jsonData = jsonDecode(content);
|
||||
List<Map<String, dynamic>> ops;
|
||||
|
||||
if (jsonData is List) {
|
||||
ops = jsonData.cast<Map<String, dynamic>>();
|
||||
} else if (jsonData is Map && jsonData.containsKey('ops')) {
|
||||
// 处理 {"ops": [...]} 格式
|
||||
ops = (jsonData['ops'] as List).cast<Map<String, dynamic>>();
|
||||
} else if (jsonData is Map) {
|
||||
ops = [jsonData.cast<String, dynamic>()];
|
||||
} else {
|
||||
// 转换为纯文本处理
|
||||
return Document.fromJson([{'insert': '$content\n'}]);
|
||||
}
|
||||
|
||||
// 🚀 新增:检查和记录样式属性
|
||||
bool hasStyleAttributes = false;
|
||||
for (final op in ops) {
|
||||
if (op.containsKey('attributes')) {
|
||||
hasStyleAttributes = true;
|
||||
final attributes = op['attributes'] as Map<String, dynamic>?;
|
||||
if (attributes != null) {
|
||||
// 记录发现的样式属性
|
||||
AppLogger.d('DocumentParser/_isolateParseFunction',
|
||||
'🎨 发现样式属性: ${attributes.keys.join(', ')}');
|
||||
|
||||
// 特别记录颜色属性
|
||||
if (attributes.containsKey('color')) {
|
||||
AppLogger.d('DocumentParser/_isolateParseFunction',
|
||||
'🎨 文字颜色: ${attributes['color']}');
|
||||
}
|
||||
if (attributes.containsKey('background')) {
|
||||
AppLogger.d('DocumentParser/_isolateParseFunction',
|
||||
'🎨 背景颜色: ${attributes['background']}');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasStyleAttributes) {
|
||||
AppLogger.i('DocumentParser/_isolateParseFunction',
|
||||
'🎨 解析包含样式属性的内容,操作数量: ${ops.length}');
|
||||
}
|
||||
|
||||
// 确保最后一个操作以换行符结尾
|
||||
if (ops.isNotEmpty) {
|
||||
final lastOp = ops.last;
|
||||
if (lastOp.containsKey('insert')) {
|
||||
final insertText = lastOp['insert'].toString();
|
||||
if (!insertText.endsWith('\n')) {
|
||||
// 如果最后一个insert不以换行符结尾,添加一个新的换行符操作
|
||||
ops.add({'insert': '\n'});
|
||||
}
|
||||
} else {
|
||||
// 如果最后一个操作不包含insert,添加换行符
|
||||
ops.add({'insert': '\n'});
|
||||
}
|
||||
} else {
|
||||
// 如果ops为空,添加一个换行符
|
||||
ops = [{'insert': '\n'}];
|
||||
}
|
||||
|
||||
return Document.fromJson(ops);
|
||||
}
|
||||
|
||||
// 处理普通文本
|
||||
return Document.fromJson([{'insert': '$content\n'}]);
|
||||
|
||||
} catch (e) {
|
||||
// 解析失败时的备用方案 - 增强错误信息
|
||||
AppLogger.e('DocumentParser/_isolateParseFunction',
|
||||
'解析失败,内容长度: ${content.length}, 错误: $e');
|
||||
|
||||
return Document.fromJson([
|
||||
{'insert': '解析错误: ${e.toString()}\n'},
|
||||
{'insert': content.length > 200 ? '${content.substring(0, 200)}...\n' : '$content\n'},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// 缓存的文档数据
|
||||
class _CachedDocument {
|
||||
final Document document;
|
||||
final int contentSize;
|
||||
DateTime accessTime;
|
||||
|
||||
_CachedDocument({
|
||||
required this.document,
|
||||
required this.contentSize,
|
||||
required this.accessTime,
|
||||
});
|
||||
}
|
||||
|
||||
/// 解析请求
|
||||
class _ParseRequest {
|
||||
final String content;
|
||||
final String cacheKey;
|
||||
final int priority;
|
||||
final Completer<Document> completer;
|
||||
final bool useCache;
|
||||
|
||||
_ParseRequest({
|
||||
required this.content,
|
||||
required this.cacheKey,
|
||||
required this.priority,
|
||||
required this.completer,
|
||||
required this.useCache,
|
||||
});
|
||||
}
|
||||
65
AINoval/lib/screens/editor/widgets/ai_chat_button.dart
Normal file
65
AINoval/lib/screens/editor/widgets/ai_chat_button.dart
Normal file
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../blocs/chat/chat_bloc.dart';
|
||||
import '../../../blocs/chat/chat_event.dart';
|
||||
import '../../../blocs/chat/chat_state.dart';
|
||||
|
||||
/// AI聊天按钮,用于在编辑器中打开AI聊天侧边栏
|
||||
class AIChatButton extends StatelessWidget {
|
||||
const AIChatButton({
|
||||
Key? key,
|
||||
required this.novelId,
|
||||
this.chapterId,
|
||||
required this.onPressed,
|
||||
this.isActive = false,
|
||||
}) : super(key: key);
|
||||
|
||||
final String novelId;
|
||||
final String? chapterId;
|
||||
final VoidCallback onPressed;
|
||||
final bool isActive;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<ChatBloc, ChatState>(
|
||||
builder: (context, state) {
|
||||
return IconButton(
|
||||
icon: Stack(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_outlined,
|
||||
color: isActive ? Colors.blue : Colors.black54,
|
||||
),
|
||||
if (state is ChatSessionActive && state.isGenerating)
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.green,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
tooltip: '打开AI聊天',
|
||||
onPressed: () {
|
||||
// 如果没有活动会话,创建一个新会话
|
||||
if (state is! ChatSessionActive) {
|
||||
context.read<ChatBloc>().add(CreateChatSession(
|
||||
title: 'New Chat',
|
||||
novelId: novelId,
|
||||
chapterId: chapterId,
|
||||
));
|
||||
}
|
||||
onPressed();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
1346
AINoval/lib/screens/editor/widgets/ai_generation_panel.dart
Normal file
1346
AINoval/lib/screens/editor/widgets/ai_generation_panel.dart
Normal file
File diff suppressed because it is too large
Load Diff
382
AINoval/lib/screens/editor/widgets/ai_generation_toolbar.dart
Normal file
382
AINoval/lib/screens/editor/widgets/ai_generation_toolbar.dart
Normal file
@@ -0,0 +1,382 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// AI生成工具栏
|
||||
/// 在流式输出文本时显示,提供Apply、Retry、Discard、Section等操作
|
||||
class AIGenerationToolbar extends StatefulWidget {
|
||||
const AIGenerationToolbar({
|
||||
super.key,
|
||||
required this.layerLink,
|
||||
required this.onApply,
|
||||
required this.onRetry,
|
||||
required this.onDiscard,
|
||||
required this.onSection,
|
||||
required this.wordCount,
|
||||
required this.modelName,
|
||||
this.isGenerating = false,
|
||||
this.onClosed,
|
||||
this.showAbove = false,
|
||||
this.onStop,
|
||||
this.offsetAbove = -60.0,
|
||||
this.offsetBelow = 30.0,
|
||||
});
|
||||
|
||||
/// 用于定位工具栏的层链接
|
||||
final LayerLink layerLink;
|
||||
|
||||
/// 应用生成的文本
|
||||
final VoidCallback onApply;
|
||||
|
||||
/// 重新生成
|
||||
final VoidCallback onRetry;
|
||||
|
||||
/// 丢弃生成的文本
|
||||
final VoidCallback onDiscard;
|
||||
|
||||
/// 分段功能
|
||||
final VoidCallback onSection;
|
||||
|
||||
/// 停止生成
|
||||
final VoidCallback? onStop;
|
||||
|
||||
/// 生成文本的字数
|
||||
final int wordCount;
|
||||
|
||||
/// 使用的模型名称
|
||||
final String modelName;
|
||||
|
||||
/// 是否正在生成中
|
||||
final bool isGenerating;
|
||||
|
||||
/// 工具栏关闭回调
|
||||
final VoidCallback? onClosed;
|
||||
|
||||
/// 是否显示在上方
|
||||
final bool showAbove;
|
||||
|
||||
/// 上方显示时的Y偏移量
|
||||
final double offsetAbove;
|
||||
|
||||
/// 下方显示时的Y偏移量
|
||||
final double offsetBelow;
|
||||
|
||||
@override
|
||||
State<AIGenerationToolbar> createState() => _AIGenerationToolbarState();
|
||||
}
|
||||
|
||||
class _AIGenerationToolbarState extends State<AIGenerationToolbar> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final isLight = !isDark;
|
||||
|
||||
return CompositedTransformFollower(
|
||||
link: widget.layerLink,
|
||||
offset: widget.showAbove ? Offset(0, widget.offsetAbove) : Offset(0, widget.offsetBelow),
|
||||
followerAnchor: Alignment.topCenter,
|
||||
targetAnchor: Alignment.topCenter,
|
||||
showWhenUnlinked: false,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
opaque: true,
|
||||
hitTestBehavior: HitTestBehavior.opaque,
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 600),
|
||||
child: _buildToolbarContainer(isLightTheme: isLight),
|
||||
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建工具栏容器
|
||||
Widget _buildToolbarContainer({required bool isLightTheme}) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
// 统一使用 WebTheme 色系
|
||||
color: isLightTheme ? WebTheme.black : WebTheme.getSurfaceColor(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: WebTheme.getShadowColor(context, opacity: isLightTheme ? 0.3 : 0.1),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
border: Border.all(
|
||||
color: WebTheme.getSecondaryBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// 估算内容总宽度
|
||||
final contentWidth = _estimateContentWidth();
|
||||
|
||||
// 如果空间不足,使用垂直布局
|
||||
if (contentWidth > constraints.maxWidth && constraints.maxWidth > 0) {
|
||||
return _buildVerticalLayout(isLightTheme);
|
||||
} else {
|
||||
return _buildHorizontalLayout(isLightTheme);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建水平布局
|
||||
Widget _buildHorizontalLayout(bool isLightTheme) {
|
||||
return IntrinsicWidth(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 操作按钮区域
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildActionButton(
|
||||
icon: Icons.check,
|
||||
label: 'Apply',
|
||||
tooltip: '应用生成的文本',
|
||||
onPressed: widget.isGenerating ? null : widget.onApply,
|
||||
),
|
||||
if (widget.isGenerating && widget.onStop != null)
|
||||
_buildActionButton(
|
||||
icon: Icons.stop,
|
||||
label: 'Stop',
|
||||
tooltip: '停止生成',
|
||||
onPressed: widget.onStop,
|
||||
)
|
||||
else
|
||||
_buildActionButton(
|
||||
icon: Icons.refresh,
|
||||
label: 'Retry',
|
||||
tooltip: '重新生成',
|
||||
onPressed: widget.isGenerating ? null : widget.onRetry,
|
||||
),
|
||||
_buildActionButton(
|
||||
icon: Icons.close,
|
||||
label: 'Discard',
|
||||
tooltip: widget.isGenerating ? '停止并丢弃生成的文本' : '丢弃生成的文本',
|
||||
onPressed: widget.onDiscard,
|
||||
),
|
||||
_buildActionButton(
|
||||
icon: Icons.crop_free,
|
||||
label: 'Section',
|
||||
tooltip: '分段处理',
|
||||
onPressed: widget.isGenerating ? null : widget.onSection,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 分隔线
|
||||
Container(
|
||||
width: 1,
|
||||
height: 32,
|
||||
color: WebTheme.getSecondaryBorderColor(context),
|
||||
),
|
||||
// 信息区域
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: _buildInfoContent(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建垂直布局(当空间不足时)
|
||||
Widget _buildVerticalLayout(bool isLightTheme) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 操作按钮区域
|
||||
Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildActionButton(
|
||||
icon: Icons.check,
|
||||
label: 'Apply',
|
||||
tooltip: '应用生成的文本',
|
||||
onPressed: widget.isGenerating ? null : widget.onApply,
|
||||
),
|
||||
if (widget.isGenerating && widget.onStop != null)
|
||||
_buildActionButton(
|
||||
icon: Icons.stop,
|
||||
label: 'Stop',
|
||||
tooltip: '停止生成',
|
||||
onPressed: widget.onStop,
|
||||
)
|
||||
else
|
||||
_buildActionButton(
|
||||
icon: Icons.refresh,
|
||||
label: 'Retry',
|
||||
tooltip: '重新生成',
|
||||
onPressed: widget.isGenerating ? null : widget.onRetry,
|
||||
),
|
||||
_buildActionButton(
|
||||
icon: Icons.close,
|
||||
label: 'Discard',
|
||||
tooltip: widget.isGenerating ? '停止并丢弃生成的文本' : '丢弃生成的文本',
|
||||
onPressed: widget.onDiscard,
|
||||
),
|
||||
_buildActionButton(
|
||||
icon: Icons.crop_free,
|
||||
label: 'Section',
|
||||
tooltip: '分段处理',
|
||||
onPressed: widget.isGenerating ? null : widget.onSection,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// 分隔线
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 1,
|
||||
color: WebTheme.getSecondaryBorderColor(context),
|
||||
),
|
||||
// 信息区域
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: _buildInfoContent(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建信息内容
|
||||
Widget _buildInfoContent() {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.isGenerating) ...[
|
||||
const SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1.5,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(WebTheme.white),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'生成中...',
|
||||
style: const TextStyle(
|
||||
color: WebTheme.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Flexible(
|
||||
child: Text(
|
||||
'${widget.wordCount} Words',
|
||||
style: const TextStyle(
|
||||
color: WebTheme.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
', ',
|
||||
style: TextStyle(
|
||||
color: WebTheme.white,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.modelName,
|
||||
style: const TextStyle(
|
||||
color: WebTheme.white,
|
||||
fontSize: 12,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 估算内容总宽度
|
||||
double _estimateContentWidth() {
|
||||
// 操作按钮: 4个按钮 * 80px ≈ 320px
|
||||
// 分隔线: 1px
|
||||
// 信息区域: 约150px
|
||||
// 内边距: 约30px
|
||||
return 320 + 1 + 150 + 30; // ≈ 501px
|
||||
}
|
||||
|
||||
/// 构建操作按钮
|
||||
Widget _buildActionButton({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required String tooltip,
|
||||
required VoidCallback? onPressed,
|
||||
}) {
|
||||
final isEnabled = onPressed != null;
|
||||
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: MouseRegion(
|
||||
cursor: isEnabled ? SystemMouseCursors.click : SystemMouseCursors.forbidden,
|
||||
opaque: true,
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 14,
|
||||
color: isEnabled
|
||||
? WebTheme.white
|
||||
: WebTheme.white,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isEnabled
|
||||
? WebTheme.white
|
||||
: WebTheme.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:ainoval/blocs/editor/editor_bloc.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
|
||||
/// AI场景生成侧边栏,用于显示从摘要生成的场景内容
|
||||
class AISceneGenerationSidePanel extends StatefulWidget {
|
||||
const AISceneGenerationSidePanel({
|
||||
Key? key,
|
||||
required this.onClose,
|
||||
required this.onInsert,
|
||||
}) : super(key: key);
|
||||
|
||||
/// 关闭面板时的回调
|
||||
final VoidCallback onClose;
|
||||
|
||||
/// 插入内容到编辑器的回调
|
||||
final Function(String content) onInsert;
|
||||
|
||||
@override
|
||||
State<AISceneGenerationSidePanel> createState() => _AISceneGenerationSidePanelState();
|
||||
}
|
||||
|
||||
class _AISceneGenerationSidePanelState extends State<AISceneGenerationSidePanel> {
|
||||
/// 编辑器控制器
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
|
||||
/// 滚动控制器
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
/// 是否已滚动到底部
|
||||
bool _isScrolledToBottom = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// 监听滚动事件,判断是否在底部
|
||||
_scrollController.addListener(_scrollListener);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_scrollController.removeListener(_scrollListener);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 滚动监听器,判断是否在底部
|
||||
void _scrollListener() {
|
||||
if (_scrollController.hasClients) {
|
||||
final isBottom = _scrollController.position.pixels >=
|
||||
_scrollController.position.maxScrollExtent - 50;
|
||||
if (isBottom != _isScrolledToBottom) {
|
||||
setState(() {
|
||||
_isScrolledToBottom = isBottom;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 复制内容到剪贴板
|
||||
void _copyToClipboard() {
|
||||
Clipboard.setData(ClipboardData(text: _controller.text)).then((_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('内容已复制到剪贴板')),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocConsumer<EditorBloc, EditorState>(
|
||||
listener: (context, state) {
|
||||
if (state is EditorLoaded && state.generatedSceneContent != null) {
|
||||
// 更新编辑器内容
|
||||
_controller.text = state.generatedSceneContent!;
|
||||
|
||||
// 如果用户滚动在底部,自动滚动到最新内容
|
||||
if (_isScrolledToBottom && _scrollController.hasClients) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state is! EditorLoaded) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final editorState = state as EditorLoaded;
|
||||
final isGenerating = editorState.aiSceneGenerationStatus == AIGenerationStatus.generating;
|
||||
final isCompleted = editorState.aiSceneGenerationStatus == AIGenerationStatus.completed;
|
||||
final isFailed = editorState.aiSceneGenerationStatus == AIGenerationStatus.failed;
|
||||
|
||||
return Container(
|
||||
width: 350, // 固定宽度
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(-2, 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题栏
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getPrimaryColor(context).withOpacity(0.1),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'AI 生成的场景',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const Spacer(),
|
||||
// 状态显示
|
||||
if (isGenerating)
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
WebTheme.getPrimaryColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'正在生成...',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
],
|
||||
)
|
||||
else if (isCompleted)
|
||||
const Text(
|
||||
'已完成',
|
||||
style: TextStyle(fontSize: 12, color: Colors.green),
|
||||
)
|
||||
else if (isFailed)
|
||||
const Text(
|
||||
'生成失败',
|
||||
style: TextStyle(fontSize: 12, color: Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 内容区域
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
// 文本编辑器
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
scrollController: _scrollController,
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
decoration: const InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText: '生成的内容将显示在这里...',
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 错误信息
|
||||
if (isFailed && editorState.aiGenerationError != null)
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.red.shade200),
|
||||
),
|
||||
child: Text(
|
||||
'错误: ${editorState.aiGenerationError}',
|
||||
style: TextStyle(
|
||||
color: Colors.red.shade800,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 操作栏
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
// 复制按钮
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy),
|
||||
tooltip: '复制内容',
|
||||
onPressed: _controller.text.isNotEmpty
|
||||
? _copyToClipboard
|
||||
: null,
|
||||
),
|
||||
// 插入原文按钮
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
tooltip: '插入到编辑器',
|
||||
onPressed: (isCompleted || !isGenerating) && _controller.text.isNotEmpty
|
||||
? () => widget.onInsert(_controller.text)
|
||||
: null,
|
||||
),
|
||||
// 停止生成按钮
|
||||
if (isGenerating)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.stop_circle_outlined),
|
||||
tooltip: '停止生成',
|
||||
onPressed: () {
|
||||
context.read<EditorBloc>().add(const StopSceneGeneration());
|
||||
},
|
||||
),
|
||||
// 关闭按钮
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: '关闭',
|
||||
onPressed: widget.onClose,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,787 @@
|
||||
// import 'dart:math'; // Added for min function
|
||||
import 'package:ainoval/screens/editor/widgets/floating_setting_dialogs.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:ainoval/models/novel_setting_item.dart';
|
||||
import 'package:ainoval/models/setting_type.dart'; // Your SettingType enum
|
||||
import 'package:ainoval/blocs/ai_setting_generation/ai_setting_generation_bloc.dart'; // Correct BLoC import
|
||||
import 'package:ainoval/models/novel_structure.dart'; // Import for Chapter model
|
||||
import 'package:ainoval/services/api_service/repositories/editor_repository.dart'; // Import EditorRepository
|
||||
import 'package:ainoval/services/api_service/repositories/novel_ai_repository.dart'; // Needed for BLoC creation
|
||||
import 'package:ainoval/blocs/setting/setting_bloc.dart';
|
||||
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
|
||||
// Removed placeholder BLoC, State, and Event definitions
|
||||
|
||||
class AISettingGenerationPanel extends StatelessWidget {
|
||||
final String novelId;
|
||||
final VoidCallback onClose;
|
||||
final bool isCardMode;
|
||||
final EditorRepository editorRepository; // Added
|
||||
final NovelAIRepository novelAIRepository; // Added
|
||||
|
||||
const AISettingGenerationPanel({
|
||||
Key? key,
|
||||
required this.novelId,
|
||||
required this.onClose,
|
||||
required this.editorRepository, // Added
|
||||
required this.novelAIRepository, // Added
|
||||
this.isCardMode = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<AISettingGenerationBloc>(
|
||||
create: (context) => AISettingGenerationBloc(
|
||||
editorRepository: editorRepository, // Changed from context.read
|
||||
novelAIRepository: novelAIRepository, // Changed from context.read
|
||||
)..add(LoadInitialDataForAISettingPanel(novelId)),
|
||||
child: AISettingGenerationView(novelId: novelId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AISettingGenerationView extends StatefulWidget {
|
||||
final String novelId;
|
||||
const AISettingGenerationView({Key? key, required this.novelId}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<AISettingGenerationView> createState() => _AISettingGenerationViewState();
|
||||
}
|
||||
|
||||
// 章节选择项数据模型
|
||||
class ChapterOption {
|
||||
final String id;
|
||||
final String title;
|
||||
final int order;
|
||||
final int globalOrder; // 全局排序序号
|
||||
final String actTitle;
|
||||
final int actOrder;
|
||||
|
||||
ChapterOption({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.order,
|
||||
required this.globalOrder,
|
||||
required this.actTitle,
|
||||
required this.actOrder,
|
||||
});
|
||||
|
||||
String get displayTitle {
|
||||
final chapterTitle = title.isNotEmpty ? title : '无标题章节';
|
||||
return '第${globalOrder}章 $chapterTitle';
|
||||
}
|
||||
|
||||
String get actDisplayTitle {
|
||||
return actTitle.isNotEmpty ? actTitle : '第${actOrder}卷';
|
||||
}
|
||||
}
|
||||
|
||||
class _AISettingGenerationViewState extends State<AISettingGenerationView> {
|
||||
String? _selectedStartChapterId;
|
||||
String? _selectedEndChapterId;
|
||||
final List<SettingTypeOption> _settingTypeOptions =
|
||||
SettingType.values.map((type) => SettingTypeOption(type)).toList();
|
||||
final _maxSettingsController = TextEditingController(text: '3');
|
||||
final _instructionsController = TextEditingController();
|
||||
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// 生成排序后的章节选项列表
|
||||
List<ChapterOption> _generateChapterOptions(List<Chapter> chapters, Novel? novel) {
|
||||
List<ChapterOption> options = [];
|
||||
int globalOrder = 1;
|
||||
|
||||
if (novel == null) {
|
||||
// 回退方案:没有Novel信息时,简单排序
|
||||
chapters.sort((a, b) => a.order.compareTo(b.order));
|
||||
for (final chapter in chapters) {
|
||||
options.add(ChapterOption(
|
||||
id: chapter.id,
|
||||
title: chapter.title,
|
||||
order: chapter.order,
|
||||
globalOrder: globalOrder++,
|
||||
actTitle: '',
|
||||
actOrder: 1,
|
||||
));
|
||||
}
|
||||
} else {
|
||||
// 有Novel信息时,按Act和章节顺序正确排序
|
||||
final sortedActs = novel.acts..sort((a, b) => a.order.compareTo(b.order));
|
||||
|
||||
for (final act in sortedActs) {
|
||||
final sortedChapters = act.chapters..sort((a, b) => a.order.compareTo(b.order));
|
||||
|
||||
for (final chapter in sortedChapters) {
|
||||
// 只处理在chapters列表中的章节(可能有过滤)
|
||||
if (chapters.any((c) => c.id == chapter.id)) {
|
||||
options.add(ChapterOption(
|
||||
id: chapter.id,
|
||||
title: chapter.title,
|
||||
order: chapter.order,
|
||||
globalOrder: globalOrder++,
|
||||
actTitle: act.title,
|
||||
actOrder: act.order,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_maxSettingsController.dispose();
|
||||
_instructionsController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 0), // Changed from 24, assuming MultiAIPanelView handles top padding for header
|
||||
child: Column(
|
||||
children: [
|
||||
_buildConfigurationArea(context, theme),
|
||||
const Divider(height: 1, thickness: 1),
|
||||
Expanded(child: _buildResultsArea(context, theme)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConfigurationArea(BuildContext context, ThemeData theme) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
BlocBuilder<AISettingGenerationBloc, AISettingGenerationState>(
|
||||
builder: (context, state) {
|
||||
List<Chapter> chapters = [];
|
||||
Novel? novel;
|
||||
bool isLoadingChapters = true;
|
||||
String? chapterLoadingError;
|
||||
|
||||
if (state is AISettingGenerationDataLoaded) {
|
||||
chapters = state.chapters;
|
||||
novel = state.novel;
|
||||
isLoadingChapters = false;
|
||||
} else if (state is AISettingGenerationSuccess) {
|
||||
chapters = state.chapters;
|
||||
novel = state.novel;
|
||||
isLoadingChapters = false;
|
||||
} else if (state is AISettingGenerationFailure) {
|
||||
chapters = state.chapters; // Might still have chapters from a previous successful load
|
||||
novel = state.novel;
|
||||
isLoadingChapters = false;
|
||||
if(chapters.isEmpty) chapterLoadingError = state.error; // Only show error if no chapters displayed
|
||||
} else if (state is AISettingGenerationLoadingChapters || state is AISettingGenerationInitial) {
|
||||
isLoadingChapters = true;
|
||||
} else {
|
||||
isLoadingChapters = false;
|
||||
}
|
||||
|
||||
if (isLoadingChapters) {
|
||||
return const Center(child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24.0),
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
));
|
||||
}
|
||||
if (chapterLoadingError != null) {
|
||||
return Center(child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: Text('加载章节失败: $chapterLoadingError', style: TextStyle(color: theme.colorScheme.error)),
|
||||
));
|
||||
}
|
||||
if (chapters.isEmpty) {
|
||||
return const Center(child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24.0),
|
||||
child: Text('没有可用的章节。'),
|
||||
));
|
||||
}
|
||||
|
||||
final chapterOptions = _generateChapterOptions(chapters, novel);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_buildChapterDropdown(
|
||||
context: context,
|
||||
theme: theme,
|
||||
label: '起始章节',
|
||||
value: _selectedStartChapterId,
|
||||
options: chapterOptions,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedStartChapterId = value;
|
||||
if (_selectedEndChapterId != null && _selectedStartChapterId != null) {
|
||||
final startOption = chapterOptions.firstWhere((opt) => opt.id == _selectedStartChapterId);
|
||||
final endOption = chapterOptions.firstWhere((opt) => opt.id == _selectedEndChapterId);
|
||||
if (endOption.globalOrder < startOption.globalOrder) {
|
||||
_selectedEndChapterId = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
validator: (value) => value == null ? '请选择起始章节' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildChapterDropdown(
|
||||
context: context,
|
||||
theme: theme,
|
||||
label: '结束章节 (可选)',
|
||||
value: _selectedEndChapterId,
|
||||
options: chapterOptions.where((option) {
|
||||
if (_selectedStartChapterId == null) return true;
|
||||
final startOption = chapterOptions.firstWhere((opt) => opt.id == _selectedStartChapterId);
|
||||
return option.globalOrder >= startOption.globalOrder;
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedEndChapterId = value;
|
||||
});
|
||||
},
|
||||
hasDefaultOption: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('希望生成的设定类型:', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8.0,
|
||||
runSpacing: 4.0,
|
||||
children: _settingTypeOptions.map((option) {
|
||||
return FilterChip(
|
||||
label: Text(option.type.displayName, style: const TextStyle(fontSize: 12)),
|
||||
selected: option.isSelected,
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
option.isSelected = selected;
|
||||
});
|
||||
},
|
||||
checkmarkColor: option.isSelected ? theme.colorScheme.onPrimary : null,
|
||||
selectedColor: WebTheme.getPrimaryColor(context),
|
||||
labelStyle: TextStyle(
|
||||
color: option.isSelected ? theme.colorScheme.onPrimary : theme.textTheme.bodySmall?.color,
|
||||
fontWeight: option.isSelected ? FontWeight.bold : FontWeight.normal),
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: option.isSelected ? WebTheme.getPrimaryColor(context) : theme.colorScheme.outline,
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _maxSettingsController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '每类生成数量 (1-5)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) return '请输入数量';
|
||||
final num = int.tryParse(value);
|
||||
if (num == null || num < 1 || num > 5) return '请输入1到5之间的数字';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _instructionsController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '其他说明或风格引导 (可选)',
|
||||
hintText: '例如:希望角色更神秘,或侧重描写地点的历史感',
|
||||
border: OutlineInputBorder(),
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
maxLines: 2,
|
||||
maxLength: 200,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Center(
|
||||
child: BlocBuilder<AISettingGenerationBloc, AISettingGenerationState>(
|
||||
builder: (context, state) {
|
||||
bool isLoading = state is AISettingGenerationInProgress;
|
||||
return ElevatedButton.icon(
|
||||
icon: isLoading
|
||||
? const SizedBox(width:16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||
: const Icon(Icons.auto_awesome_outlined, size: 18),
|
||||
label: Text(isLoading ? '生成中...' : '开始生成设定'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
textStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)
|
||||
),
|
||||
onPressed: isLoading ? null : () {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
final selectedTypes = _settingTypeOptions
|
||||
.where((opt) => opt.isSelected)
|
||||
.map((opt) => opt.type.value)
|
||||
.toList();
|
||||
if (selectedTypes.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('请至少选择一个设定类型'), backgroundColor: Colors.orange)
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (_selectedStartChapterId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('请选择起始章节'), backgroundColor: Colors.orange)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
context.read<AISettingGenerationBloc>().add(GenerateSettingsRequested(
|
||||
novelId: widget.novelId,
|
||||
startChapterId: _selectedStartChapterId!,
|
||||
endChapterId: _selectedEndChapterId,
|
||||
settingTypes: selectedTypes,
|
||||
maxSettingsPerType: int.parse(_maxSettingsController.text),
|
||||
additionalInstructions: _instructionsController.text,
|
||||
));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12), // Add some bottom padding
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChapterDropdown({
|
||||
required BuildContext context,
|
||||
required ThemeData theme,
|
||||
required String label,
|
||||
required String? value,
|
||||
required List<ChapterOption> options,
|
||||
required ValueChanged<String?> onChanged,
|
||||
String? Function(String?)? validator,
|
||||
bool hasDefaultOption = false,
|
||||
}) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: theme.dividerColor),
|
||||
),
|
||||
child: DropdownButtonFormField<String>(
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
labelStyle: TextStyle(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
value: value,
|
||||
isExpanded: true, // 确保下拉框内容完全显示
|
||||
icon: Icon(Icons.keyboard_arrow_down, color: theme.colorScheme.onSurfaceVariant),
|
||||
items: [
|
||||
if (hasDefaultOption)
|
||||
DropdownMenuItem<String>(
|
||||
value: null,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.auto_awesome, size: 18, color: WebTheme.getPrimaryColor(context)),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'到最新章节 (默认)',
|
||||
style: TextStyle(
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
...options.map((option) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: option.id,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
option.displayTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
if (option.actTitle.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
option.actDisplayTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
onChanged: onChanged,
|
||||
validator: validator,
|
||||
selectedItemBuilder: (BuildContext context) {
|
||||
return [
|
||||
if (hasDefaultOption)
|
||||
Text(
|
||||
'到最新章节 (默认)',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurface,
|
||||
fontSize: 14,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
...options.map((option) {
|
||||
return Text(
|
||||
option.displayTitle,
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurface,
|
||||
fontSize: 14,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
}).toList(),
|
||||
];
|
||||
},
|
||||
dropdownColor: theme.cardColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
elevation: 8,
|
||||
menuMaxHeight: 300, // 限制下拉菜单最大高度
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResultsArea(BuildContext context, ThemeData theme) {
|
||||
return BlocBuilder<AISettingGenerationBloc, AISettingGenerationState>(
|
||||
builder: (context, state) {
|
||||
if (state is AISettingGenerationInProgress) {
|
||||
return const Center(child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('正在分析章节并生成设定,请稍候...')
|
||||
],
|
||||
));
|
||||
}
|
||||
if (state is AISettingGenerationSuccess) {
|
||||
if (state.generatedSettings.isEmpty) {
|
||||
return const Center(child: Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Text('AI未能根据您的选择生成任何设定,请尝试调整选项或章节内容后再试。', textAlign: TextAlign.center,)
|
||||
));
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
itemCount: state.generatedSettings.length,
|
||||
itemBuilder: (context, index) {
|
||||
return NovelSettingItemCard(
|
||||
settingItem: state.generatedSettings[index],
|
||||
novelId: widget.novelId,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
if (state is AISettingGenerationFailure) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: theme.colorScheme.error, size: 48),
|
||||
const SizedBox(height:16),
|
||||
Text('生成设定时出错:', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height:8),
|
||||
Text(state.error, style: TextStyle(color: theme.colorScheme.error), textAlign: TextAlign.center,),
|
||||
const SizedBox(height:16),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
label: const Text('重试'),
|
||||
onPressed: (){
|
||||
if (_formKey.currentState!.validate()) {
|
||||
final selectedTypes = _settingTypeOptions
|
||||
.where((opt) => opt.isSelected)
|
||||
.map((opt) => opt.type.value)
|
||||
.toList();
|
||||
if (selectedTypes.isEmpty || _selectedStartChapterId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('请确保已选择起始章节和至少一个设定类型再重试。'), backgroundColor: Colors.orange)
|
||||
);
|
||||
return;
|
||||
}
|
||||
context.read<AISettingGenerationBloc>().add(GenerateSettingsRequested(
|
||||
novelId: widget.novelId,
|
||||
startChapterId: _selectedStartChapterId!,
|
||||
endChapterId: _selectedEndChapterId,
|
||||
settingTypes: selectedTypes,
|
||||
maxSettingsPerType: int.parse(_maxSettingsController.text),
|
||||
additionalInstructions: _instructionsController.text,
|
||||
));
|
||||
}
|
||||
}
|
||||
)
|
||||
],
|
||||
)
|
||||
),
|
||||
);
|
||||
}
|
||||
// Initial or other states
|
||||
return const Center(child: Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Text('请选择起始章节和希望生成的设定类型,然后点击"开始生成设定"按钮。', textAlign: TextAlign.center,)
|
||||
));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NovelSettingItemCard extends StatefulWidget {
|
||||
final NovelSettingItem settingItem;
|
||||
final String novelId;
|
||||
|
||||
const NovelSettingItemCard({
|
||||
Key? key,
|
||||
required this.settingItem,
|
||||
required this.novelId,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<NovelSettingItemCard> createState() => _NovelSettingItemCardState();
|
||||
}
|
||||
|
||||
class _NovelSettingItemCardState extends State<NovelSettingItemCard> {
|
||||
bool _isExpanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final typeEnum = SettingType.fromValue(widget.settingItem.type ?? 'OTHER');
|
||||
final itemAttributes = widget.settingItem.attributes; // Store in a local variable
|
||||
final itemTags = widget.settingItem.tags; // Store in a local variable
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 6.0),
|
||||
elevation: 1.5,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)), // Softer corners
|
||||
clipBehavior: Clip.antiAlias, // Ensures content respects border radius
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start, // Align items to the top
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.settingItem.name,
|
||||
style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold, fontSize: 15),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Chip(
|
||||
label: Text(typeEnum.displayName, style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w500)),
|
||||
backgroundColor: _getTypeColor(typeEnum).withOpacity(0.15),
|
||||
labelStyle: TextStyle(color: _getTypeColor(typeEnum)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: const VisualDensity(horizontal: 0.0, vertical: -2), // Compact chip
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
widget.settingItem.description ?? '无描述',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant, fontSize: 13, height: 1.4),
|
||||
maxLines: _isExpanded ? null : 3, // Show a bit more before expanding
|
||||
overflow: _isExpanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
),
|
||||
if ((widget.settingItem.description?.length ?? 0) > 120) // Show expand if description is somewhat long
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
style: TextButton.styleFrom(padding: EdgeInsets.zero, minimumSize: const Size(50,30), visualDensity: VisualDensity.compact),
|
||||
child: Text(_isExpanded ? '收起' : '展开', style: TextStyle(fontSize: 12, color: WebTheme.getPrimaryColor(context))),
|
||||
onPressed: () => setState(() => _isExpanded = !_isExpanded)),
|
||||
),
|
||||
|
||||
if ((itemAttributes?.isNotEmpty ?? false) || (itemTags?.isNotEmpty ?? false)) ...[
|
||||
const SizedBox(height: 6),
|
||||
Divider(thickness: 0.5, color: theme.dividerColor.withOpacity(0.5)),
|
||||
const SizedBox(height: 6),
|
||||
if (itemAttributes?.isNotEmpty ?? false)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: itemAttributes!.entries.map((e) => Chip(
|
||||
label: Text('${e.key}: ${e.value}', style: const TextStyle(fontSize: 10)),
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
backgroundColor: theme.colorScheme.surfaceVariant.withOpacity(0.7),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
||||
)).toList(),
|
||||
),
|
||||
),
|
||||
if (itemTags?.isNotEmpty ?? false)
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: itemTags!.map((tag) => Chip(
|
||||
label: Text(tag, style: const TextStyle(fontSize: 10)),
|
||||
backgroundColor: theme.colorScheme.secondaryContainer.withOpacity(0.6),
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
||||
)).toList(),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.add_circle_outline, size: 16),
|
||||
label: const Text('采纳到设定组', style: TextStyle(fontSize: 12)),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: WebTheme.getPrimaryColor(context),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
onPressed: () {
|
||||
_showAdoptDialog(context, widget.settingItem, widget.novelId);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getTypeColor(SettingType type) {
|
||||
switch (type) {
|
||||
case SettingType.character: return Colors.blue.shade600;
|
||||
case SettingType.location: return Colors.green.shade600;
|
||||
case SettingType.item: return Colors.orange.shade700;
|
||||
case SettingType.lore: return Colors.purple.shade600;
|
||||
case SettingType.event: return Colors.red.shade600;
|
||||
case SettingType.concept: return Colors.teal.shade600;
|
||||
case SettingType.faction: return Colors.indigo.shade600;
|
||||
case SettingType.creature: return Colors.brown.shade600;
|
||||
case SettingType.magicSystem: return Colors.cyan.shade600;
|
||||
case SettingType.technology: return Colors.blueGrey.shade600;
|
||||
case SettingType.culture: return Colors.deepOrange.shade600;
|
||||
case SettingType.history: return Colors.brown.shade600;
|
||||
case SettingType.organization: return Colors.indigo.shade600;
|
||||
case SettingType.worldview: return Colors.purple.shade600;
|
||||
case SettingType.pleasurePoint: return Colors.redAccent.shade200;
|
||||
case SettingType.anticipationHook: return Colors.teal.shade400;
|
||||
case SettingType.theme: return Colors.blueGrey.shade500;
|
||||
case SettingType.tone: return Colors.amber.shade700;
|
||||
case SettingType.style: return Colors.cyan.shade700;
|
||||
case SettingType.trope: return Colors.pink.shade400;
|
||||
case SettingType.plotDevice: return Colors.green.shade600;
|
||||
case SettingType.powerSystem: return Colors.orange.shade700;
|
||||
case SettingType.timeline: return Colors.blue.shade600;
|
||||
case SettingType.religion: return Colors.deepPurple.shade600;
|
||||
case SettingType.politics: return Colors.red.shade700;
|
||||
case SettingType.economy: return Colors.lightGreen.shade700;
|
||||
case SettingType.geography: return Colors.lightBlue.shade700;
|
||||
default: return Colors.grey.shade600;
|
||||
}
|
||||
}
|
||||
|
||||
void _showAdoptDialog(BuildContext context, NovelSettingItem itemToAdopt, String novelId) {
|
||||
final settingBloc = context.read<SettingBloc>();
|
||||
|
||||
AppLogger.i("AISettingGenerationPanel", "准备采纳设定: ${itemToAdopt.name}, 描述长度: ${itemToAdopt.description?.length ?? 0}, 标签数量: ${itemToAdopt.tags?.length ?? 0}, 属性数量: ${itemToAdopt.attributes?.length ?? 0}");
|
||||
|
||||
FloatingSettingDialogs.showSettingGroupSelection(
|
||||
context: context,
|
||||
novelId: novelId,
|
||||
onGroupSelected: (groupId, groupName) {
|
||||
// 显示操作提示
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('正在将 "${itemToAdopt.name}" 添加到 "$groupName"...'))
|
||||
);
|
||||
|
||||
// 确保类型值使用正确的枚举value值
|
||||
final typeValue = itemToAdopt.type;
|
||||
|
||||
// 准备创建的设定条目
|
||||
NovelSettingItem itemForCreation = itemToAdopt.copyWith(
|
||||
id: null,
|
||||
isAiSuggestion: false,
|
||||
status: 'ACTIVE',
|
||||
type: typeValue, // 确保使用原始的value值
|
||||
// 明确设置content和description,确保不会丢失
|
||||
content: "", // 不再使用content字段
|
||||
description: itemToAdopt.description, // 保留description作为主要描述字段
|
||||
attributes: itemToAdopt.attributes, // 确保属性被保留
|
||||
tags: itemToAdopt.tags, // 确保标签被保留
|
||||
generatedBy: "AI设定生成器" // 明确标记生成来源
|
||||
);
|
||||
|
||||
// 在安全的上下文环境中创建并添加到组
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
settingBloc.add(CreateSettingItemAndAddToGroup(
|
||||
novelId: novelId,
|
||||
item: itemForCreation,
|
||||
groupId: groupId,
|
||||
));
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,653 @@
|
||||
import 'package:ainoval/blocs/editor/editor_bloc.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'dart:async';
|
||||
|
||||
/// AI流式生成内容显示组件
|
||||
/// 在编辑器右侧面板中展示流式生成的内容,使用打字机效果
|
||||
class AIStreamGenerationDisplay extends StatefulWidget {
|
||||
const AIStreamGenerationDisplay({
|
||||
Key? key,
|
||||
required this.onClose,
|
||||
this.onOpenInEditor,
|
||||
}) : super(key: key);
|
||||
|
||||
/// 关闭面板的回调
|
||||
final VoidCallback onClose;
|
||||
|
||||
/// 在编辑器中打开内容的回调
|
||||
final Function(String content)? onOpenInEditor;
|
||||
|
||||
@override
|
||||
State<AIStreamGenerationDisplay> createState() => _AIStreamGenerationDisplayState();
|
||||
}
|
||||
|
||||
class _AIStreamGenerationDisplayState extends State<AIStreamGenerationDisplay> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
Timer? _autoScrollTimer;
|
||||
final TextEditingController _summaryController = TextEditingController();
|
||||
final TextEditingController _styleController = TextEditingController();
|
||||
bool _userScrolled = false;
|
||||
bool _showGeneratePanel = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// 初始化时检查是否有正在进行的生成,如有则自动滚动
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final state = context.read<EditorBloc>().state;
|
||||
if (state is EditorLoaded &&
|
||||
state.aiSceneGenerationStatus == AIGenerationStatus.generating &&
|
||||
state.generatedSceneContent != null &&
|
||||
state.generatedSceneContent!.isNotEmpty) {
|
||||
_scrollToBottom();
|
||||
AppLogger.i('AIStreamGenerationDisplay', '初始化时检测到生成内容,自动滚动到底部');
|
||||
}
|
||||
});
|
||||
|
||||
// 启动定期滚动更新
|
||||
_startAutoScrollTimer();
|
||||
|
||||
// 监听滚动事件,检测用户是否主动滚动
|
||||
_scrollController.addListener(_handleUserScroll);
|
||||
}
|
||||
|
||||
void _handleUserScroll() {
|
||||
if (_scrollController.hasClients) {
|
||||
// 如果用户向上滚动(滚动位置不在底部),标记为用户滚动
|
||||
if (_scrollController.position.pixels <
|
||||
_scrollController.position.maxScrollExtent - 50) {
|
||||
_userScrolled = true;
|
||||
}
|
||||
|
||||
// 如果用户滚动到底部,重置标记
|
||||
if (_scrollController.position.pixels >=
|
||||
_scrollController.position.maxScrollExtent - 10) {
|
||||
_userScrolled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _startAutoScrollTimer() {
|
||||
// 每500毫秒检查一次是否需要滚动
|
||||
_autoScrollTimer = Timer.periodic(const Duration(milliseconds: 500), (_) {
|
||||
final state = context.read<EditorBloc>().state;
|
||||
if (state is EditorLoaded &&
|
||||
state.isStreamingGeneration &&
|
||||
state.aiSceneGenerationStatus == AIGenerationStatus.generating &&
|
||||
!_userScrolled) { // 只有在用户没有主动滚动时自动滚动
|
||||
_scrollToBottom();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_autoScrollTimer?.cancel();
|
||||
_scrollController.removeListener(_handleUserScroll);
|
||||
_scrollController.dispose();
|
||||
_summaryController.dispose();
|
||||
_styleController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 自动滚动到底部
|
||||
void _scrollToBottom() {
|
||||
if (!_scrollController.hasClients) {
|
||||
AppLogger.d('AIStreamGenerationDisplay', '滚动控制器还没有客户端,延迟滚动');
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollToBottom();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
AppLogger.d('AIStreamGenerationDisplay', '执行滚动到底部');
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
} catch (e) {
|
||||
AppLogger.e('AIStreamGenerationDisplay', '滚动到底部失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 复制内容到剪贴板
|
||||
void _copyToClipboard(String content) {
|
||||
Clipboard.setData(ClipboardData(text: content)).then((_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('内容已复制到剪贴板')),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// 生成场景
|
||||
void _generateScene(BuildContext context) {
|
||||
if (_summaryController.text.isEmpty) return;
|
||||
|
||||
try {
|
||||
final state = context.read<EditorBloc>().state;
|
||||
if (state is! EditorLoaded) return;
|
||||
|
||||
// 触发场景生成请求
|
||||
context.read<EditorBloc>().add(
|
||||
GenerateSceneFromSummaryRequested(
|
||||
novelId: state.novel.id,
|
||||
summary: _summaryController.text,
|
||||
chapterId: state.activeChapterId,
|
||||
styleInstructions: _styleController.text.isNotEmpty
|
||||
? _styleController.text
|
||||
: null,
|
||||
useStreamingMode: true,
|
||||
),
|
||||
);
|
||||
|
||||
// 隐藏生成面板
|
||||
setState(() {
|
||||
_showGeneratePanel = false;
|
||||
});
|
||||
|
||||
// 重置用户滚动标记
|
||||
_userScrolled = false;
|
||||
|
||||
} catch (e) {
|
||||
AppLogger.e('AIStreamGenerationDisplay', '生成场景错误', e);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('启动AI生成时出错: ${e.toString()}')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocConsumer<EditorBloc, EditorState>(
|
||||
listener: (context, state) {
|
||||
if (state is EditorLoaded &&
|
||||
state.isStreamingGeneration &&
|
||||
state.generatedSceneContent != null &&
|
||||
state.generatedSceneContent!.isNotEmpty &&
|
||||
!_userScrolled) {
|
||||
_scrollToBottom();
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state is! EditorLoaded) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final isGenerating = state.aiSceneGenerationStatus == AIGenerationStatus.generating;
|
||||
final hasGenerated = state.aiSceneGenerationStatus == AIGenerationStatus.completed;
|
||||
final hasFailed = state.aiSceneGenerationStatus == AIGenerationStatus.failed;
|
||||
final content = state.generatedSceneContent ?? '';
|
||||
|
||||
return Container(
|
||||
width: 350, // 固定宽度
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(-2, 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 标题栏
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.7),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'AI 生成助手',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// 状态指示器
|
||||
if (isGenerating)
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
WebTheme.getPrimaryColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'正在流式生成...',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else if (hasGenerated)
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
size: 14,
|
||||
color: Colors.green.shade600,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'生成完成',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.green.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else if (hasFailed)
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error,
|
||||
size: 14,
|
||||
color: Colors.red.shade600,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'生成失败',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.red.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
padding: const EdgeInsets.all(4),
|
||||
onPressed: widget.onClose,
|
||||
tooltip: '关闭',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 内容标签
|
||||
if (!_showGeneratePanel)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
TabPageSelector(
|
||||
selectedColor: WebTheme.getPrimaryColor(context),
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
controller: TabController(
|
||||
initialIndex: 0,
|
||||
length: 2,
|
||||
vsync: const _TickerProviderImpl(),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// 添加生成场景按钮
|
||||
if (!isGenerating) // 只在不生成时显示
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showGeneratePanel = true;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('生成新场景'),
|
||||
style: TextButton.styleFrom(
|
||||
textStyle: const TextStyle(fontSize: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 生成面板 (新增)
|
||||
if (_showGeneratePanel)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'创建新场景',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _summaryController,
|
||||
maxLines: 4,
|
||||
decoration: InputDecoration(
|
||||
labelText: '场景摘要/大纲',
|
||||
hintText: '请输入场景大纲或摘要...',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _styleController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '风格指令(可选)',
|
||||
hintText: '多对话,少描写,悬疑风格...',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: (_summaryController.text.isNotEmpty || content.isNotEmpty)
|
||||
? () => _generateScene(context)
|
||||
: null,
|
||||
icon: const Icon(Icons.auto_awesome, size: 16),
|
||||
label: const Text('开始生成'),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showGeneratePanel = false;
|
||||
});
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 内容区域
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
if (content.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(), // 允许滚动
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
content,
|
||||
style: TextStyle(
|
||||
height: 1.8,
|
||||
fontSize: 15,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
// 底部空间
|
||||
if (isGenerating)
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (!isGenerating && !hasFailed)
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.description_outlined,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'生成的内容将显示在这里',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (isGenerating && content.isEmpty)
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'正在准备内容...',
|
||||
style: TextStyle(
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 生成指示器 (流式生成时在底部显示小提示)
|
||||
if (isGenerating && content.isNotEmpty)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
left: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Theme.of(context).colorScheme.surface.withOpacity(0),
|
||||
Theme.of(context).colorScheme.surface,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'正在生成中...',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 错误信息
|
||||
if (hasFailed && state.aiGenerationError != null)
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.red.shade200),
|
||||
),
|
||||
child: Text(
|
||||
'错误: ${state.aiGenerationError}',
|
||||
style: TextStyle(
|
||||
color: Colors.red.shade800,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 底部操作栏
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5),
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// 左侧按钮
|
||||
if (isGenerating)
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
context.read<EditorBloc>().add(StopSceneGeneration());
|
||||
},
|
||||
icon: const Icon(Icons.stop, size: 16),
|
||||
label: const Text('停止生成'),
|
||||
style: TextButton.styleFrom(
|
||||
textStyle: const TextStyle(fontSize: 13),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
)
|
||||
else
|
||||
FilledButton.icon(
|
||||
onPressed: hasGenerated && content.isNotEmpty
|
||||
? () {
|
||||
// 创建新场景并使用生成的内容
|
||||
if (widget.onOpenInEditor != null) {
|
||||
widget.onOpenInEditor!(content);
|
||||
AppLogger.i('AIStreamGenerationDisplay', '在编辑器中打开生成内容');
|
||||
widget.onClose();
|
||||
}
|
||||
}
|
||||
: null,
|
||||
icon: const Icon(Icons.save, size: 16),
|
||||
label: const Text('保存为场景'),
|
||||
style: FilledButton.styleFrom(
|
||||
textStyle: const TextStyle(fontSize: 13),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
),
|
||||
|
||||
// 右侧按钮
|
||||
Row(
|
||||
children: [
|
||||
if (!isGenerating && hasGenerated)
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showGeneratePanel = true;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
tooltip: '重新生成',
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
padding: const EdgeInsets.all(8),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: hasGenerated && content.isNotEmpty
|
||||
? () => _copyToClipboard(content)
|
||||
: null,
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
tooltip: '复制全部内容',
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
padding: const EdgeInsets.all(8),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 简单的TickerProvider实现,用于TabController
|
||||
class _TickerProviderImpl extends TickerProvider {
|
||||
const _TickerProviderImpl();
|
||||
|
||||
@override
|
||||
Ticker createTicker(TickerCallback onTick) => Ticker(onTick);
|
||||
}
|
||||
973
AINoval/lib/screens/editor/widgets/ai_summary_panel.dart
Normal file
973
AINoval/lib/screens/editor/widgets/ai_summary_panel.dart
Normal file
@@ -0,0 +1,973 @@
|
||||
import 'package:ainoval/blocs/editor/editor_bloc.dart';
|
||||
import 'package:ainoval/blocs/universal_ai/universal_ai_bloc.dart';
|
||||
import 'package:ainoval/blocs/universal_ai/universal_ai_event.dart';
|
||||
import 'package:ainoval/blocs/universal_ai/universal_ai_state.dart';
|
||||
import 'package:ainoval/blocs/ai_config/ai_config_bloc.dart';
|
||||
import 'package:ainoval/blocs/public_models/public_models_bloc.dart';
|
||||
import 'package:ainoval/models/novel_structure.dart';
|
||||
import 'package:ainoval/models/unified_ai_model.dart';
|
||||
import 'package:ainoval/models/ai_request_models.dart';
|
||||
// import 'package:ainoval/screens/editor/managers/editor_layout_manager.dart';
|
||||
import 'package:ainoval/widgets/common/unified_ai_model_dropdown.dart';
|
||||
import 'package:ainoval/models/context_selection_models.dart';
|
||||
import 'package:ainoval/widgets/common/form_dialog_template.dart';
|
||||
import 'package:ainoval/screens/editor/components/ai_dialog_common_logic.dart';
|
||||
import 'package:ainoval/widgets/common/scene_selector.dart';
|
||||
import 'package:ainoval/config/app_config.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:ainoval/utils/quill_helper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:ainoval/widgets/common/top_toast.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// AI摘要生成面板,提供根据场景内容生成摘要的功能
|
||||
class AISummaryPanel extends StatefulWidget {
|
||||
const AISummaryPanel({
|
||||
Key? key,
|
||||
required this.novelId,
|
||||
required this.onClose,
|
||||
this.isCardMode = false,
|
||||
}) : super(key: key);
|
||||
|
||||
final String novelId;
|
||||
final VoidCallback onClose;
|
||||
final bool isCardMode; // 是否以卡片模式显示
|
||||
|
||||
@override
|
||||
State<AISummaryPanel> createState() => _AISummaryPanelState();
|
||||
}
|
||||
|
||||
class _AISummaryPanelState extends State<AISummaryPanel> with AIDialogCommonLogic {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final TextEditingController _summaryController = TextEditingController();
|
||||
final LayerLink _layerLink = LayerLink();
|
||||
|
||||
UnifiedAIModel? _selectedModel;
|
||||
bool _enableSmartContext = true;
|
||||
// bool _userScrolled = false; // 未使用,先注释避免警告
|
||||
// bool _contentEdited = false; // 未使用,先注释避免警告
|
||||
bool _isGenerating = false;
|
||||
bool _thisInstanceIsGenerating = false; // 标记是否是当前实例发起的生成请求
|
||||
late ContextSelectionData _contextSelectionData;
|
||||
String? _selectedPromptTemplateId;
|
||||
// 临时自定义提示词
|
||||
String? _customSystemPrompt;
|
||||
String? _customUserPrompt;
|
||||
bool _contextInitialized = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// _contentEdited = false;
|
||||
|
||||
// 监听滚动事件,检测用户是否主动滚动
|
||||
_scrollController.addListener(_handleUserScroll);
|
||||
|
||||
// 初始化默认模型配置
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_initializeDefaultModel();
|
||||
_initializeContextData();
|
||||
});
|
||||
}
|
||||
|
||||
void _initializeDefaultModel() {
|
||||
final aiConfigState = context.read<AiConfigBloc>().state;
|
||||
final publicModelsState = context.read<PublicModelsBloc>().state;
|
||||
|
||||
// 合并私有模型和公共模型
|
||||
final allModels = _combineModels(aiConfigState, publicModelsState);
|
||||
|
||||
if (allModels.isNotEmpty && _selectedModel == null) {
|
||||
// 优先选择默认配置
|
||||
UnifiedAIModel? defaultModel;
|
||||
|
||||
// 首先查找私有模型中的默认配置
|
||||
for (final model in allModels) {
|
||||
if (!model.isPublic && (model as PrivateAIModel).userConfig.isDefault) {
|
||||
defaultModel = model;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有默认私有模型,选择第一个公共模型
|
||||
defaultModel ??= allModels.firstWhere(
|
||||
(model) => model.isPublic,
|
||||
orElse: () => allModels.first,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_selectedModel = defaultModel;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 合并私有模型和公共模型
|
||||
List<UnifiedAIModel> _combineModels(AiConfigState aiState, PublicModelsState publicState) {
|
||||
final List<UnifiedAIModel> allModels = [];
|
||||
|
||||
// 添加已验证的私有模型
|
||||
final validatedConfigs = aiState.validatedConfigs;
|
||||
for (final config in validatedConfigs) {
|
||||
allModels.add(PrivateAIModel(config));
|
||||
}
|
||||
|
||||
// 添加公共模型
|
||||
if (publicState is PublicModelsLoaded) {
|
||||
for (final publicModel in publicState.models) {
|
||||
allModels.add(PublicAIModel(publicModel));
|
||||
}
|
||||
}
|
||||
|
||||
return allModels;
|
||||
}
|
||||
|
||||
void _initializeContextData() {
|
||||
if (_contextInitialized) return;
|
||||
final editorState = context.read<EditorBloc>().state;
|
||||
if (editorState is EditorLoaded) {
|
||||
_contextSelectionData = ContextSelectionDataBuilder.fromNovel(editorState.novel);
|
||||
_contextInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_handleUserScroll);
|
||||
_scrollController.dispose();
|
||||
_summaryController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleUserScroll() {}
|
||||
|
||||
/// 复制内容到剪贴板
|
||||
void _copyToClipboard(String content) {
|
||||
Clipboard.setData(ClipboardData(text: content)).then((_) {
|
||||
TopToast.success(context, '摘要已复制到剪贴板');
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<EditorBloc, EditorState>(
|
||||
builder: (context, editorState) {
|
||||
if (editorState is! EditorLoaded) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return BlocConsumer<UniversalAIBloc, UniversalAIState>(
|
||||
listener: (context, state) {
|
||||
// 只处理摘要生成相关的状态变化
|
||||
if (state is UniversalAIStreaming) {
|
||||
// 检查是否是摘要生成请求
|
||||
if (_isSummaryRequest(state)) {
|
||||
setState(() {
|
||||
_isGenerating = true;
|
||||
_summaryController.text = state.partialResponse;
|
||||
// _contentEdited = false;
|
||||
});
|
||||
}
|
||||
} else if (state is UniversalAISuccess) {
|
||||
// 检查是否是摘要生成请求
|
||||
if (_isSummaryRequest(state)) {
|
||||
setState(() {
|
||||
_isGenerating = false;
|
||||
_thisInstanceIsGenerating = false; // 重置实例生成标记
|
||||
_summaryController.text = state.response.content;
|
||||
// _contentEdited = false;
|
||||
});
|
||||
}
|
||||
} else if (state is UniversalAIError) {
|
||||
// 检查是否是摘要生成请求
|
||||
if (_isSummaryRequest(state)) {
|
||||
setState(() {
|
||||
_isGenerating = false;
|
||||
_thisInstanceIsGenerating = false; // 重置实例生成标记
|
||||
});
|
||||
TopToast.error(context, '生成摘要失败: ${state.message}');
|
||||
}
|
||||
} else if (state is UniversalAILoading) {
|
||||
// 检查是否是摘要生成请求
|
||||
if (_isSummaryRequest(state)) {
|
||||
setState(() {
|
||||
_isGenerating = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
builder: (context, universalAIState) {
|
||||
return Column(
|
||||
children: [
|
||||
// 面板标题栏
|
||||
_buildHeader(context, editorState),
|
||||
|
||||
// 面板内容
|
||||
Expanded(
|
||||
child: _buildSummaryContentPanel(context, editorState),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context, EditorLoaded state) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 10.0),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getSurfaceColor(context),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: WebTheme.getSecondaryBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 标题行
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.summarize,
|
||||
size: 14,
|
||||
color: WebTheme.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'AI摘要助手',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
// 状态指示器
|
||||
if (_isGenerating) ...[
|
||||
const SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.black),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'正在生成...',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
|
||||
// 帮助按钮
|
||||
Tooltip(
|
||||
message: '使用说明',
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
Icons.help_outline,
|
||||
size: 16,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
padding: const EdgeInsets.all(4),
|
||||
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: WebTheme.getCardColor(context),
|
||||
surfaceTintColor: Colors.transparent,
|
||||
title: Text(
|
||||
'AI摘要生成说明',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
content: const SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'1. 选择要生成摘要的场景',
|
||||
style: TextStyle(fontSize: 12, color: Colors.black87),
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
Text(
|
||||
'2. 选择AI模型和配置',
|
||||
style: TextStyle(fontSize: 12, color: Colors.black87),
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
Text(
|
||||
'3. 点击"生成摘要"按钮',
|
||||
style: TextStyle(fontSize: 12, color: Colors.black87),
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
Text(
|
||||
'4. 生成完成后,可以直接编辑摘要内容',
|
||||
style: TextStyle(fontSize: 12, color: Colors.black87),
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
Text(
|
||||
'5. 点击"保存摘要"按钮将摘要保存到场景',
|
||||
style: TextStyle(fontSize: 12, color: Colors.black87),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: WebTheme.getPrimaryColor(context),
|
||||
foregroundColor: WebTheme.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
),
|
||||
child: const Text('了解了', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
IconButton(
|
||||
icon: Icon(Icons.close, size: 16, color: WebTheme.getSecondaryTextColor(context)),
|
||||
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
|
||||
padding: const EdgeInsets.all(4),
|
||||
onPressed: widget.onClose,
|
||||
tooltip: '关闭',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 当前场景信息行
|
||||
const SizedBox(height: 8),
|
||||
_buildCurrentSceneSelector(context, state),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCurrentSceneSelector(BuildContext context, EditorLoaded state) {
|
||||
return SceneSelector(
|
||||
novel: state.novel,
|
||||
activeSceneId: state.activeSceneId,
|
||||
onSceneSelected: (sceneId, actId, chapterId) {
|
||||
// 更新活跃场景
|
||||
context.read<EditorBloc>().add(SetActiveScene(
|
||||
actId: actId,
|
||||
chapterId: chapterId,
|
||||
sceneId: sceneId,
|
||||
));
|
||||
},
|
||||
onSummaryLoaded: (summary) {
|
||||
// 加载场景摘要到输入框
|
||||
setState(() {
|
||||
_summaryController.text = summary;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 构建摘要内容面板
|
||||
Widget _buildSummaryContentPanel(BuildContext context, EditorLoaded state) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(10.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 模型配置区域
|
||||
_buildModelConfigSection(context, state),
|
||||
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// 分割线
|
||||
Container(
|
||||
height: 1,
|
||||
color: WebTheme.getSecondaryBorderColor(context),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// 生成的摘要区域
|
||||
Expanded(
|
||||
child: _buildSummarySection(context, state),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildModelConfigSection(BuildContext context, EditorLoaded state) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getSurfaceColor(context),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: WebTheme.getSecondaryBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'模型设置',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 统一模型选择器
|
||||
_buildUnifiedModelSelector(context, state),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 智能上下文开关
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'智能上下文',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'启用后将自动检索相关的小说设定和背景信息',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Transform.scale(
|
||||
scale: 0.8,
|
||||
child: Switch(
|
||||
value: _enableSmartContext,
|
||||
activeColor: WebTheme.getPrimaryColor(context),
|
||||
activeTrackColor: WebTheme.getSecondaryBorderColor(context),
|
||||
inactiveThumbColor: WebTheme.getCardColor(context),
|
||||
inactiveTrackColor: WebTheme.getSecondaryBorderColor(context),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_enableSmartContext = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 上下文选择
|
||||
if (_contextInitialized)
|
||||
FormFieldFactory.createContextSelectionField(
|
||||
contextData: _contextSelectionData,
|
||||
onSelectionChanged: (newData) {
|
||||
setState(() {
|
||||
_contextSelectionData = newData;
|
||||
});
|
||||
},
|
||||
title: '附加上下文',
|
||||
description: '选择要包含在生成中的上下文信息',
|
||||
onReset: () {
|
||||
setState(() {
|
||||
_contextSelectionData = ContextSelectionDataBuilder.fromNovel(state.novel);
|
||||
});
|
||||
},
|
||||
dropdownWidth: 400,
|
||||
initialChapterId: state.activeChapterId,
|
||||
initialSceneId: state.activeSceneId,
|
||||
),
|
||||
|
||||
if (_contextInitialized) const SizedBox(height: 12),
|
||||
|
||||
// 关联提示词模板
|
||||
FormFieldFactory.createPromptTemplateSelectionField(
|
||||
selectedTemplateId: _selectedPromptTemplateId,
|
||||
onTemplateSelected: (templateId) {
|
||||
setState(() {
|
||||
_selectedPromptTemplateId = templateId;
|
||||
});
|
||||
},
|
||||
aiFeatureType: 'SCENE_TO_SUMMARY',
|
||||
title: '关联提示词模板',
|
||||
description: '可选,选择一个提示词模板优化摘要生成',
|
||||
onReset: () {
|
||||
setState(() {
|
||||
_selectedPromptTemplateId = null;
|
||||
});
|
||||
},
|
||||
onTemporaryPromptsSaved: (sys, user) {
|
||||
setState(() {
|
||||
_customSystemPrompt = sys.trim().isEmpty ? null : sys.trim();
|
||||
_customUserPrompt = user.trim().isEmpty ? null : user.trim();
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 生成按钮
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 36,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: (_getActiveScene(state) == null ||
|
||||
_getActiveScene(state)!.content.isEmpty ||
|
||||
_selectedModel == null ||
|
||||
_isGenerating)
|
||||
? null
|
||||
: () => _generateSummary(context, state),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.black,
|
||||
foregroundColor: Colors.white,
|
||||
disabledBackgroundColor: Colors.grey[300],
|
||||
disabledForegroundColor: Colors.grey[600],
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
icon: _isGenerating
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.auto_awesome, size: 14),
|
||||
label: Text(
|
||||
_isGenerating ? '生成中...' : '生成摘要',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建统一模型选择器
|
||||
Widget _buildUnifiedModelSelector(BuildContext context, EditorLoaded state) {
|
||||
return BlocBuilder<AiConfigBloc, AiConfigState>(
|
||||
builder: (context, aiState) {
|
||||
return BlocBuilder<PublicModelsBloc, PublicModelsState>(
|
||||
builder: (context, publicState) {
|
||||
final allModels = _combineModels(aiState, publicState);
|
||||
|
||||
return CompositedTransformTarget(
|
||||
link: _layerLink,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
_showModelDropdown(context, state, allModels);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: Colors.grey[300]!, width: 1),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _selectedModel != null
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_selectedModel!.displayName,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: _selectedModel!.isPublic ? Colors.green[50] : Colors.blue[50],
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
border: Border.all(
|
||||
color: _selectedModel!.isPublic ? Colors.green[200]! : Colors.blue[200]!,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_selectedModel!.isPublic ? '系统' : '私有',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: _selectedModel!.isPublic ? Colors.green[700] : Colors.blue[700],
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
_selectedModel!.provider,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
: const Text(
|
||||
'选择AI模型',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.black54,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.arrow_drop_down,
|
||||
color: Colors.black54,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示模型选择下拉菜单
|
||||
void _showModelDropdown(BuildContext context, EditorLoaded state, List<UnifiedAIModel> allModels) {
|
||||
UnifiedAIModelDropdown.show(
|
||||
context: context,
|
||||
layerLink: _layerLink,
|
||||
selectedModel: _selectedModel,
|
||||
onModelSelected: (model) {
|
||||
setState(() {
|
||||
_selectedModel = model;
|
||||
});
|
||||
},
|
||||
showSettingsButton: false,
|
||||
maxHeight: 300,
|
||||
novel: state.novel,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummarySection(BuildContext context, EditorLoaded state) {
|
||||
final hasContent = _summaryController.text.isNotEmpty;
|
||||
final activeScene = _getActiveScene(state);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'生成的摘要',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
if (hasContent && !_isGenerating) ...[
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(color: Colors.grey[300]!),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.copy, size: 14, color: Colors.black),
|
||||
tooltip: '复制到剪贴板',
|
||||
constraints: const BoxConstraints(),
|
||||
padding: EdgeInsets.zero,
|
||||
onPressed: () {
|
||||
_copyToClipboard(_summaryController.text);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
if (activeScene != null) ...[
|
||||
SizedBox(
|
||||
height: 28,
|
||||
child: ElevatedButton(
|
||||
onPressed: _summaryController.text.trim().isEmpty
|
||||
? null
|
||||
: () => _saveSummary(context, state, activeScene),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
disabledBackgroundColor: Colors.grey[200],
|
||||
disabledForegroundColor: Colors.grey,
|
||||
side: BorderSide(color: Colors.grey[300]!),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'保存摘要',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: Colors.grey[300]!,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: _isGenerating && _summaryController.text.isEmpty
|
||||
? const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.black),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'正在生成摘要...',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: !hasContent && !_isGenerating
|
||||
? const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.summarize,
|
||||
color: Colors.grey,
|
||||
size: 32,
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'点击"生成摘要"按钮开始生成',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: TextField(
|
||||
controller: _summaryController,
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
decoration: const InputDecoration(
|
||||
contentPadding: EdgeInsets.all(12),
|
||||
border: InputBorder.none,
|
||||
hintText: '生成的摘要将显示在这里',
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.4,
|
||||
color: Colors.black,
|
||||
),
|
||||
onChanged: (_) {
|
||||
setState(() {
|
||||
// _contentEdited = true;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 检查是否是摘要生成请求
|
||||
bool _isSummaryRequest(UniversalAIState state) {
|
||||
// 对于流式响应状态,只有当前实例发起的请求才处理
|
||||
if (state is UniversalAIStreaming) {
|
||||
return _thisInstanceIsGenerating;
|
||||
}
|
||||
// 对于成功状态,检查请求类型
|
||||
else if (state is UniversalAISuccess) {
|
||||
return state.response.requestType == AIRequestType.sceneSummary;
|
||||
}
|
||||
// 对于错误和加载状态,检查当前实例是否有生成任务
|
||||
else if (state is UniversalAIError || state is UniversalAILoading) {
|
||||
return _thisInstanceIsGenerating;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 生成摘要
|
||||
void _generateSummary(BuildContext context, EditorLoaded state) {
|
||||
final activeScene = _getActiveScene(state);
|
||||
if (activeScene == null || _selectedModel == null) return;
|
||||
|
||||
// 清空现有内容
|
||||
_summaryController.clear();
|
||||
|
||||
AppLogger.i('AISummaryPanel', '开始生成摘要,场景ID: ${activeScene.id}');
|
||||
|
||||
// 使用公共逻辑创建模型配置(公共模型会被包装为临时配置)
|
||||
final modelConfig = createModelConfig(_selectedModel!);
|
||||
|
||||
// 构建AI请求(先将Quill内容转换为纯文本)
|
||||
final String plainSceneText = QuillHelper.deltaToText(activeScene.content);
|
||||
// 构建元数据(包含公共模型标识)
|
||||
final metadata = createModelMetadata(_selectedModel!, {
|
||||
'actId': state.activeActId,
|
||||
'chapterId': state.activeChapterId,
|
||||
'sceneId': state.activeSceneId,
|
||||
'sceneTitle': activeScene.title,
|
||||
'wordCount': activeScene.wordCount,
|
||||
'action': 'scene_summary',
|
||||
'source': 'ai_summary_panel',
|
||||
});
|
||||
final request = UniversalAIRequest(
|
||||
requestType: AIRequestType.sceneSummary,
|
||||
userId: AppConfig.userId ?? 'unknown',
|
||||
novelId: widget.novelId,
|
||||
modelConfig: modelConfig,
|
||||
selectedText: plainSceneText, // 使用纯文本作为输入
|
||||
instructions: '请为这个小说场景生成一个准确、简洁的摘要,突出关键情节和重要细节。',
|
||||
contextSelections: _contextSelectionData,
|
||||
enableSmartContext: _enableSmartContext,
|
||||
parameters: {
|
||||
'temperature': 0.7,
|
||||
'maxTokens': 500,
|
||||
'promptTemplateId': _selectedPromptTemplateId,
|
||||
if (_customSystemPrompt != null) 'customSystemPrompt': _customSystemPrompt,
|
||||
if (_customUserPrompt != null) 'customUserPrompt': _customUserPrompt,
|
||||
},
|
||||
metadata: metadata,
|
||||
);
|
||||
|
||||
// 公共模型预估积分并确认
|
||||
if (_selectedModel!.isPublic) {
|
||||
handlePublicModelCreditConfirmation(_selectedModel!, request).then((ok) {
|
||||
if (!ok) return;
|
||||
setState(() { _thisInstanceIsGenerating = true; });
|
||||
context.read<UniversalAIBloc>().add(SendAIStreamRequestEvent(request));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 发送流式请求(私有模型直接发送)
|
||||
setState(() { _thisInstanceIsGenerating = true; });
|
||||
context.read<UniversalAIBloc>().add(SendAIStreamRequestEvent(request));
|
||||
}
|
||||
|
||||
void _saveSummary(BuildContext context, EditorLoaded state, Scene activeScene) {
|
||||
final summary = _summaryController.text.trim();
|
||||
if (summary.isEmpty) return;
|
||||
|
||||
// 保存摘要到场景
|
||||
context.read<EditorBloc>().add(
|
||||
UpdateSummary(
|
||||
novelId: widget.novelId,
|
||||
actId: state.activeActId!,
|
||||
chapterId: state.activeChapterId!,
|
||||
sceneId: activeScene.id,
|
||||
summary: summary,
|
||||
),
|
||||
);
|
||||
|
||||
// 显示保存成功提示
|
||||
TopToast.success(context, '摘要已保存');
|
||||
|
||||
// 已移除未使用的编辑状态标记
|
||||
|
||||
AppLogger.i('AISummaryPanel', '摘要已保存: ${activeScene.id}');
|
||||
}
|
||||
|
||||
// 获取当前活动场景
|
||||
Scene? _getActiveScene(EditorLoaded state) {
|
||||
if (state.activeSceneId != null && state.activeActId != null && state.activeChapterId != null) {
|
||||
// 获取完整的场景对象而不仅仅是ID
|
||||
final scene = state.novel.getScene(state.activeActId!, state.activeChapterId!, sceneId: state.activeSceneId);
|
||||
return scene;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
408
AINoval/lib/screens/editor/widgets/continue_writing_form.dart
Normal file
408
AINoval/lib/screens/editor/widgets/continue_writing_form.dart
Normal file
@@ -0,0 +1,408 @@
|
||||
import 'package:ainoval/models/user_ai_model_config_model.dart';
|
||||
import 'package:ainoval/services/api_service/repositories/user_ai_model_config_repository.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:ainoval/widgets/common/top_toast.dart';
|
||||
|
||||
/// 自动续写表单组件
|
||||
class ContinueWritingForm extends StatefulWidget {
|
||||
const ContinueWritingForm({
|
||||
super.key,
|
||||
required this.novelId,
|
||||
required this.userId,
|
||||
required this.onCancel,
|
||||
required this.onSubmit,
|
||||
required this.userAiModelConfigRepository,
|
||||
});
|
||||
|
||||
final String novelId;
|
||||
final String userId;
|
||||
final VoidCallback onCancel;
|
||||
final Function(Map<String, dynamic> parameters) onSubmit;
|
||||
final UserAIModelConfigRepository userAiModelConfigRepository;
|
||||
|
||||
@override
|
||||
State<ContinueWritingForm> createState() => _ContinueWritingFormState();
|
||||
}
|
||||
|
||||
class _ContinueWritingFormState extends State<ContinueWritingForm> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _numberOfChaptersController = TextEditingController(text: '1');
|
||||
final _contextChapterCountController = TextEditingController(text: '3');
|
||||
final _customContextController = TextEditingController();
|
||||
final _writingStyleController = TextEditingController();
|
||||
|
||||
List<UserAIModelConfigModel> _aiConfigs = [];
|
||||
bool _isLoadingConfigs = true;
|
||||
bool _isSubmitting = false;
|
||||
|
||||
String? _selectedSummaryConfigId;
|
||||
String? _selectedContentConfigId;
|
||||
String _startContextMode = 'AUTO'; // 默认为自动模式
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadAiConfigs();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_numberOfChaptersController.dispose();
|
||||
_contextChapterCountController.dispose();
|
||||
_customContextController.dispose();
|
||||
_writingStyleController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadAiConfigs() async {
|
||||
setState(() {
|
||||
_isLoadingConfigs = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final configs = await widget.userAiModelConfigRepository.listConfigurations(
|
||||
userId: widget.userId,
|
||||
validatedOnly: true,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_aiConfigs = configs;
|
||||
_isLoadingConfigs = false;
|
||||
|
||||
// 如果有配置,预选第一个
|
||||
if (configs.isNotEmpty) {
|
||||
_selectedSummaryConfigId = configs.first.id;
|
||||
_selectedContentConfigId = configs.first.id;
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
AppLogger.e('ContinueWritingForm', '加载AI配置失败', e);
|
||||
setState(() {
|
||||
_isLoadingConfigs = false;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
TopToast.error(context, '加载AI配置失败: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _submitForm() async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isSubmitting = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final parameters = <String, dynamic>{
|
||||
'novelId': widget.novelId,
|
||||
'numberOfChapters': int.parse(_numberOfChaptersController.text),
|
||||
'aiConfigIdSummary': _selectedSummaryConfigId,
|
||||
'aiConfigIdContent': _selectedContentConfigId,
|
||||
'startContextMode': _startContextMode,
|
||||
};
|
||||
|
||||
// 根据上下文模式添加对应参数
|
||||
if (_startContextMode == 'LAST_N_CHAPTERS') {
|
||||
parameters['contextChapterCount'] = int.parse(_contextChapterCountController.text);
|
||||
} else if (_startContextMode == 'CUSTOM') {
|
||||
parameters['customContext'] = _customContextController.text;
|
||||
}
|
||||
|
||||
// 添加写作风格参数(如果有)
|
||||
if (_writingStyleController.text.isNotEmpty) {
|
||||
parameters['writingStyle'] = _writingStyleController.text;
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
widget.onSubmit(parameters);
|
||||
} catch (e) {
|
||||
AppLogger.e('ContinueWritingForm', '提交表单失败', e);
|
||||
if (mounted) {
|
||||
TopToast.error(context, '提交失败: ${e.toString()}');
|
||||
}
|
||||
} finally {
|
||||
setState(() {
|
||||
_isSubmitting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
elevation: 3,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'自动续写设置',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: widget.onCancel,
|
||||
splashRadius: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 表单
|
||||
Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 续写章节数
|
||||
TextFormField(
|
||||
controller: _numberOfChaptersController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '续写章节数',
|
||||
helperText: '设置要自动续写的章节数量',
|
||||
prefixIcon: Icon(Icons.book_outlined),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入续写章节数';
|
||||
}
|
||||
final number = int.tryParse(value);
|
||||
if (number == null || number <= 0) {
|
||||
return '请输入有效的章节数';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 摘要模型选择
|
||||
_isLoadingConfigs
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: DropdownButtonFormField<String>(
|
||||
decoration: const InputDecoration(
|
||||
labelText: '摘要生成模型',
|
||||
helperText: '选择用于生成章节摘要的AI模型',
|
||||
prefixIcon: Icon(Icons.summarize_outlined),
|
||||
),
|
||||
value: _selectedSummaryConfigId,
|
||||
items: _aiConfigs
|
||||
.map((config) => DropdownMenuItem<String>(
|
||||
value: config.id,
|
||||
child: Text(config.alias ?? config.modelName),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedSummaryConfigId = value;
|
||||
});
|
||||
},
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请选择摘要生成模型';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 内容模型选择
|
||||
_isLoadingConfigs
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: DropdownButtonFormField<String>(
|
||||
decoration: const InputDecoration(
|
||||
labelText: '内容生成模型',
|
||||
helperText: '选择用于生成章节内容的AI模型',
|
||||
prefixIcon: Icon(Icons.text_fields),
|
||||
),
|
||||
value: _selectedContentConfigId,
|
||||
items: _aiConfigs
|
||||
.map((config) => DropdownMenuItem<String>(
|
||||
value: config.id,
|
||||
child: Text(config.alias ?? config.modelName),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedContentConfigId = value;
|
||||
});
|
||||
},
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请选择内容生成模型';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 上下文模式选择
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'上下文模式',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 上下文模式单选组
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
_buildContextModeRadio('自动', 'AUTO'),
|
||||
_buildContextModeRadio('最近N章', 'LAST_N_CHAPTERS'),
|
||||
_buildContextModeRadio('自定义', 'CUSTOM'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'选择AI续写时使用的上下文模式',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 上下文章节数(仅当模式为LAST_N_CHAPTERS时显示)
|
||||
if (_startContextMode == 'LAST_N_CHAPTERS')
|
||||
TextFormField(
|
||||
controller: _contextChapterCountController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '上下文章节数',
|
||||
helperText: '设置AI生成时参考的最近章节数量',
|
||||
prefixIcon: Icon(Icons.format_list_numbered),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入上下文章节数';
|
||||
}
|
||||
final number = int.tryParse(value);
|
||||
if (number == null || number <= 0) {
|
||||
return '请输入有效的章节数';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
|
||||
// 自定义上下文(仅当模式为CUSTOM时显示)
|
||||
if (_startContextMode == 'CUSTOM')
|
||||
TextFormField(
|
||||
controller: _customContextController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '自定义上下文',
|
||||
helperText: '输入AI生成时参考的自定义上下文内容',
|
||||
prefixIcon: Icon(Icons.description_outlined),
|
||||
),
|
||||
maxLines: 3,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入自定义上下文';
|
||||
}
|
||||
if (value.length < 10) {
|
||||
return '上下文内容过短,请提供更详细的信息';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 写作风格(可选)
|
||||
TextFormField(
|
||||
controller: _writingStyleController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '写作风格提示 (可选)',
|
||||
helperText: '描述期望的写作风格,例如:悬疑、浪漫、幽默等',
|
||||
prefixIcon: Icon(Icons.style),
|
||||
),
|
||||
maxLines: 1,
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 提交按钮
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: _isSubmitting ? null : widget.onCancel,
|
||||
child: const Text('取消'),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _isSubmitting ? null : _submitForm,
|
||||
child: _isSubmitting
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: theme.colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text('提交中...'),
|
||||
],
|
||||
)
|
||||
: const Text('开始任务'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 构建上下文模式单选按钮
|
||||
Widget _buildContextModeRadio(String label, String value) {
|
||||
return FilterChip(
|
||||
label: Text(label),
|
||||
selected: _startContextMode == value,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setState(() {
|
||||
_startContextMode = value;
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
354
AINoval/lib/screens/editor/widgets/custom_dropdown.dart
Normal file
354
AINoval/lib/screens/editor/widgets/custom_dropdown.dart
Normal file
@@ -0,0 +1,354 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// 通用下拉菜单组件,用于替换项目中的三点水下拉菜单
|
||||
class CustomDropdown extends StatefulWidget {
|
||||
/// 触发下拉菜单的小部件
|
||||
final Widget trigger;
|
||||
|
||||
/// 下拉菜单内容
|
||||
final Widget child;
|
||||
|
||||
/// 下拉菜单宽度
|
||||
final double width;
|
||||
|
||||
/// 下拉菜单对齐方式 ('left' 或 'right')
|
||||
final String align;
|
||||
|
||||
/// 是否为暗色主题
|
||||
final bool isDarkTheme;
|
||||
|
||||
/// 菜单出现/消失的动画时长
|
||||
final Duration animationDuration;
|
||||
|
||||
const CustomDropdown({
|
||||
Key? key,
|
||||
required this.trigger,
|
||||
required this.child,
|
||||
this.width = 240,
|
||||
this.align = 'left',
|
||||
this.isDarkTheme = false,
|
||||
this.animationDuration = const Duration(milliseconds: 150),
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<CustomDropdown> createState() => _CustomDropdownState();
|
||||
}
|
||||
|
||||
class _CustomDropdownState extends State<CustomDropdown> {
|
||||
bool isOpen = false;
|
||||
final LayerLink _layerLink = LayerLink();
|
||||
OverlayEntry? _overlayEntry;
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode.addListener(_onFocusChange);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_removeOverlay();
|
||||
_focusNode.removeListener(_onFocusChange);
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onFocusChange() {
|
||||
if (!_focusNode.hasFocus && isOpen) {
|
||||
_closeDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleDropdown() {
|
||||
if (isOpen) {
|
||||
_closeDropdown();
|
||||
} else {
|
||||
_openDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
void _closeDropdown() {
|
||||
_removeOverlay();
|
||||
setState(() {
|
||||
isOpen = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _openDropdown() {
|
||||
_showOverlay();
|
||||
setState(() {
|
||||
isOpen = true;
|
||||
});
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
|
||||
void _removeOverlay() {
|
||||
_overlayEntry?.remove();
|
||||
_overlayEntry = null;
|
||||
}
|
||||
|
||||
void _showOverlay() {
|
||||
_overlayEntry = _createOverlayEntry();
|
||||
Overlay.of(context).insert(_overlayEntry!);
|
||||
}
|
||||
|
||||
OverlayEntry _createOverlayEntry() {
|
||||
RenderBox renderBox = context.findRenderObject() as RenderBox;
|
||||
var size = renderBox.size;
|
||||
var offset = renderBox.localToGlobal(Offset.zero);
|
||||
|
||||
return OverlayEntry(
|
||||
builder: (context) => GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: _closeDropdown,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: widget.align == 'left' ? offset.dx : null,
|
||||
right: widget.align == 'right' ? (MediaQuery.of(context).size.width - offset.dx - size.width) : null,
|
||||
top: offset.dy + size.height + 4,
|
||||
width: widget.width,
|
||||
child: CompositedTransformFollower(
|
||||
link: _layerLink,
|
||||
followerAnchor: widget.align == 'left' ? Alignment.topLeft : Alignment.topRight,
|
||||
targetAnchor: widget.align == 'left' ? Alignment.bottomLeft : Alignment.bottomRight,
|
||||
offset: const Offset(0, 4),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
duration: widget.animationDuration,
|
||||
curve: Curves.easeOutCubic,
|
||||
tween: Tween<double>(begin: 0.0, end: 1.0),
|
||||
builder: (context, value, child) => Transform.scale(
|
||||
scale: 0.95 + (0.05 * value),
|
||||
alignment: widget.align == 'left'
|
||||
? Alignment.topLeft
|
||||
: Alignment.topRight,
|
||||
child: Opacity(
|
||||
opacity: value,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
elevation: 8,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: widget.isDarkTheme ? Colors.grey[850] : Colors.white,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: _wrapChildWithCloseCallback(widget.child),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _wrapChildWithCloseCallback(Widget child) {
|
||||
if (child is Column) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: child.children.map((item) {
|
||||
if (item is DropdownItem) {
|
||||
return DropdownItem(
|
||||
icon: item.icon,
|
||||
label: item.label,
|
||||
onTap: item.onTap,
|
||||
hasSubmenu: item.hasSubmenu,
|
||||
disabled: item.disabled,
|
||||
isDarkTheme: item.isDarkTheme,
|
||||
isDangerous: item.isDangerous,
|
||||
onClose: _closeDropdown,
|
||||
);
|
||||
}
|
||||
if (item is DropdownSection) {
|
||||
return DropdownSection(
|
||||
title: item.title,
|
||||
children: item.children.map((sectionItem) {
|
||||
if (sectionItem is DropdownItem) {
|
||||
return DropdownItem(
|
||||
icon: sectionItem.icon,
|
||||
label: sectionItem.label,
|
||||
onTap: sectionItem.onTap,
|
||||
hasSubmenu: sectionItem.hasSubmenu,
|
||||
disabled: sectionItem.disabled,
|
||||
isDarkTheme: sectionItem.isDarkTheme,
|
||||
isDangerous: sectionItem.isDangerous,
|
||||
onClose: _closeDropdown,
|
||||
);
|
||||
}
|
||||
return sectionItem;
|
||||
}).toList(),
|
||||
isDarkTheme: item.isDarkTheme,
|
||||
dividerAtBottom: item.dividerAtBottom,
|
||||
);
|
||||
}
|
||||
return item;
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
return child;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return KeyboardListener(
|
||||
focusNode: _focusNode,
|
||||
onKeyEvent: (keyEvent) {
|
||||
if (keyEvent is KeyDownEvent && keyEvent.logicalKey == LogicalKeyboardKey.escape) {
|
||||
_closeDropdown();
|
||||
}
|
||||
},
|
||||
child: CompositedTransformTarget(
|
||||
link: _layerLink,
|
||||
child: GestureDetector(
|
||||
onTap: _toggleDropdown,
|
||||
child: widget.trigger,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 下拉菜单项
|
||||
class DropdownItem extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Future<void> Function()? onTap;
|
||||
final bool hasSubmenu;
|
||||
final bool disabled;
|
||||
final bool isDarkTheme;
|
||||
final bool isDangerous;
|
||||
final VoidCallback? onClose;
|
||||
|
||||
const DropdownItem({
|
||||
Key? key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
this.onTap,
|
||||
this.hasSubmenu = false,
|
||||
this.disabled = false,
|
||||
this.isDarkTheme = false,
|
||||
this.isDangerous = false,
|
||||
this.onClose,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: disabled
|
||||
? null
|
||||
: () async {
|
||||
if (onTap != null) {
|
||||
await onTap!();
|
||||
}
|
||||
onClose?.call();
|
||||
},
|
||||
child: Opacity(
|
||||
opacity: disabled ? 0.5 : 1.0,
|
||||
child: Container(
|
||||
height: 40,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: isDangerous
|
||||
? Colors.red.shade700
|
||||
: (isDarkTheme ? Colors.white70 : Colors.black87)
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: isDangerous
|
||||
? Colors.red.shade700
|
||||
: (isDarkTheme ? Colors.white : Colors.black87),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hasSubmenu)
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
size: 16,
|
||||
color: isDarkTheme ? Colors.white38 : Colors.black45,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 下拉菜单分区
|
||||
class DropdownSection extends StatelessWidget {
|
||||
final String? title;
|
||||
final List<Widget> children;
|
||||
final bool isDarkTheme;
|
||||
final bool dividerAtBottom;
|
||||
|
||||
const DropdownSection({
|
||||
Key? key,
|
||||
this.title,
|
||||
required this.children,
|
||||
this.isDarkTheme = false,
|
||||
this.dividerAtBottom = true,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (title != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: Text(
|
||||
title!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isDarkTheme ? Colors.white54 : Colors.black54,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
...children,
|
||||
if (dividerAtBottom)
|
||||
Divider(
|
||||
height: 8,
|
||||
thickness: 1,
|
||||
color: isDarkTheme ? Colors.white12 : Colors.black12,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 下拉菜单分隔线
|
||||
class DropdownDivider extends StatelessWidget {
|
||||
final bool isDarkTheme;
|
||||
|
||||
const DropdownDivider({
|
||||
Key? key,
|
||||
this.isDarkTheme = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Divider(
|
||||
height: 8,
|
||||
thickness: 1,
|
||||
color: isDarkTheme ? Colors.white12 : Colors.black12,
|
||||
);
|
||||
}
|
||||
}
|
||||
126
AINoval/lib/screens/editor/widgets/dialogs.dart
Normal file
126
AINoval/lib/screens/editor/widgets/dialogs.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 对话框工具类
|
||||
///
|
||||
/// 用于创建和显示各种常用对话框
|
||||
class DialogUtils {
|
||||
/// 显示确认对话框
|
||||
static Future<bool> showConfirmDialog({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required String message,
|
||||
String confirmText = '确认',
|
||||
String cancelText = '取消',
|
||||
bool isDangerous = false,
|
||||
}) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(cancelText),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(confirmText),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: isDangerous ? Colors.red : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
/// 显示危险操作确认对话框
|
||||
static Future<bool> showDangerousConfirmDialog({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required String message,
|
||||
String confirmText = '删除',
|
||||
String cancelText = '取消',
|
||||
}) async {
|
||||
return showConfirmDialog(
|
||||
context: context,
|
||||
title: title,
|
||||
message: message,
|
||||
confirmText: confirmText,
|
||||
cancelText: cancelText,
|
||||
isDangerous: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示删除确认对话框
|
||||
static Future<bool> showDeleteConfirmDialog({
|
||||
required BuildContext context,
|
||||
required String itemType,
|
||||
String? itemName,
|
||||
}) async {
|
||||
final title = '删除$itemType';
|
||||
final message = itemName != null
|
||||
? '确定要删除"$itemName"吗?此操作不可撤销。'
|
||||
: '确定要删除这个$itemType吗?此操作不可撤销。';
|
||||
|
||||
return showDangerousConfirmDialog(
|
||||
context: context,
|
||||
title: title,
|
||||
message: message,
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示输入对话框
|
||||
static Future<String?> showInputDialog({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
String? initialValue,
|
||||
String hintText = '',
|
||||
String confirmText = '确认',
|
||||
String cancelText = '取消',
|
||||
}) async {
|
||||
final controller = TextEditingController(text: initialValue);
|
||||
|
||||
final result = await showDialog<String?>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
),
|
||||
autofocus: true,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, null),
|
||||
child: Text(cancelText),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, controller.text),
|
||||
child: Text(confirmText),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 显示重命名对话框
|
||||
static Future<String?> showRenameDialog({
|
||||
required BuildContext context,
|
||||
required String itemType,
|
||||
required String currentName,
|
||||
}) async {
|
||||
return showInputDialog(
|
||||
context: context,
|
||||
title: '重命名$itemType',
|
||||
initialValue: currentName,
|
||||
hintText: '输入新的名称',
|
||||
);
|
||||
}
|
||||
}
|
||||
469
AINoval/lib/screens/editor/widgets/dropdown_manager.dart
Normal file
469
AINoval/lib/screens/editor/widgets/dropdown_manager.dart
Normal file
@@ -0,0 +1,469 @@
|
||||
import 'package:ainoval/blocs/editor/editor_bloc.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/custom_dropdown.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/menu_definitions.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/preset_menu_definitions.dart';
|
||||
import 'package:ainoval/services/ai_preset_service.dart';
|
||||
import 'package:ainoval/models/preset_models.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 下拉菜单管理器
|
||||
///
|
||||
/// 用于统一构建和管理所有下拉菜单,包括Act、Chapter、Scene和Model的菜单
|
||||
class DropdownManager {
|
||||
/// 菜单构建上下文
|
||||
final BuildContext context;
|
||||
|
||||
/// 编辑器状态管理(模型菜单时可为null)
|
||||
final EditorBloc? editorBloc;
|
||||
|
||||
/// 菜单显示设置
|
||||
final DropdownDisplaySettings displaySettings;
|
||||
|
||||
DropdownManager({
|
||||
required this.context,
|
||||
required this.editorBloc,
|
||||
this.displaySettings = const DropdownDisplaySettings(),
|
||||
});
|
||||
|
||||
/// 构建Act菜单
|
||||
Widget buildActMenu({
|
||||
required String actId,
|
||||
Function()? onRenamePressed,
|
||||
IconData? icon,
|
||||
String? tooltip,
|
||||
}) {
|
||||
return _buildMenu(
|
||||
menuItems: ActMenuDefinitions.getMenuItems(),
|
||||
id: actId,
|
||||
secondaryId: null,
|
||||
tertiaryId: null,
|
||||
onRenamePressed: onRenamePressed,
|
||||
icon: icon ?? Icons.more_vert,
|
||||
tooltip: tooltip ?? 'Act操作',
|
||||
width: displaySettings.actMenuWidth,
|
||||
align: displaySettings.actMenuAlign,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建Chapter菜单
|
||||
Widget buildChapterMenu({
|
||||
required String actId,
|
||||
required String chapterId,
|
||||
Function()? onRenamePressed,
|
||||
IconData? icon,
|
||||
String? tooltip,
|
||||
}) {
|
||||
// 动态统计该章节下的场景数量,用作菜单顶部信息
|
||||
int? sceneCount;
|
||||
try {
|
||||
final state = editorBloc?.state;
|
||||
if (state is EditorLoaded) {
|
||||
final novel = state.novel;
|
||||
for (final act in novel.acts) {
|
||||
if (act.id == actId) {
|
||||
for (final chapter in act.chapters) {
|
||||
if (chapter.id == chapterId) {
|
||||
sceneCount = chapter.scenes.length;
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// 构建带有“章节信息:共N个场景”的菜单项,放在最前面
|
||||
final List<dynamic> items = [];
|
||||
if (sceneCount != null) {
|
||||
items.add(MenuItemData(
|
||||
icon: Icons.info_outline,
|
||||
label: '共${sceneCount}个场景',
|
||||
onTap: null,
|
||||
disabled: true,
|
||||
));
|
||||
items.add("divider");
|
||||
}
|
||||
items.addAll(ChapterMenuDefinitions.getMenuItems());
|
||||
|
||||
return _buildMenu(
|
||||
menuItems: items,
|
||||
id: actId,
|
||||
secondaryId: chapterId,
|
||||
tertiaryId: null,
|
||||
onRenamePressed: onRenamePressed,
|
||||
icon: icon ?? Icons.more_vert,
|
||||
tooltip: tooltip ?? '章节操作',
|
||||
width: displaySettings.chapterMenuWidth,
|
||||
align: displaySettings.chapterMenuAlign,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建Scene菜单
|
||||
Widget buildSceneMenu({
|
||||
required String actId,
|
||||
required String chapterId,
|
||||
required String sceneId,
|
||||
IconData? icon,
|
||||
String? tooltip,
|
||||
}) {
|
||||
return _buildMenu(
|
||||
menuItems: SceneMenuDefinitions.getMenuItems(),
|
||||
id: actId,
|
||||
secondaryId: chapterId,
|
||||
tertiaryId: sceneId,
|
||||
icon: icon ?? Icons.more_horiz,
|
||||
tooltip: tooltip ?? '场景操作',
|
||||
width: displaySettings.sceneMenuWidth,
|
||||
align: displaySettings.sceneMenuAlign,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建Model菜单
|
||||
Widget buildModelMenu({
|
||||
required String configId,
|
||||
required bool isValidated,
|
||||
required bool isDefault,
|
||||
required Future<void> Function(String) onValidate,
|
||||
required Future<void> Function(String) onSetDefault,
|
||||
required Future<void> Function(String) onEdit,
|
||||
required Future<void> Function(String) onDelete,
|
||||
IconData? icon,
|
||||
String? tooltip,
|
||||
}) {
|
||||
final menuItems = ModelMenuDefinitions.getMenuItems(
|
||||
isValidated: isValidated,
|
||||
isDefault: isDefault,
|
||||
onValidate: onValidate,
|
||||
onSetDefault: onSetDefault,
|
||||
onEdit: onEdit,
|
||||
onDelete: onDelete,
|
||||
);
|
||||
|
||||
return _buildModelMenu(
|
||||
menuItems: menuItems,
|
||||
configId: configId,
|
||||
icon: icon ?? Icons.more_vert,
|
||||
tooltip: tooltip ?? '模型操作',
|
||||
width: displaySettings.modelMenuWidth,
|
||||
align: displaySettings.modelMenuAlign,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建预设菜单
|
||||
Widget buildPresetMenu({
|
||||
required String featureType,
|
||||
required Function() onCreatePreset,
|
||||
required Function() onManagePresets,
|
||||
required Function(AIPromptPreset preset) onPresetSelected,
|
||||
IconData? icon,
|
||||
String? tooltip,
|
||||
}) {
|
||||
return CustomDropdown(
|
||||
width: displaySettings.presetMenuWidth,
|
||||
align: displaySettings.presetMenuAlign,
|
||||
trigger: IconButton(
|
||||
icon: Icon(icon ?? Icons.bookmark_border, size: 18),
|
||||
onPressed: null, // 由CustomDropdown处理点击
|
||||
tooltip: tooltip ?? '预设管理',
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
splashRadius: 20,
|
||||
),
|
||||
child: FutureBuilder<List<dynamic>>(
|
||||
future: PresetMenuDefinitions.getDynamicMenuItems(
|
||||
featureType: featureType,
|
||||
onCreatePreset: onCreatePreset,
|
||||
onManagePresets: onManagePresets,
|
||||
onPresetSelected: onPresetSelected,
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final menuItems = snapshot.data ?? [];
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: _buildPresetMenuItemWidgets(
|
||||
menuItems,
|
||||
featureType,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 内部方法:构建通用菜单
|
||||
Widget _buildMenu({
|
||||
required List<dynamic> menuItems,
|
||||
required String id,
|
||||
String? secondaryId,
|
||||
String? tertiaryId,
|
||||
Function()? onRenamePressed,
|
||||
required IconData icon,
|
||||
required String tooltip,
|
||||
double width = 240,
|
||||
String align = 'left',
|
||||
}) {
|
||||
return CustomDropdown(
|
||||
width: width,
|
||||
align: align,
|
||||
trigger: IconButton(
|
||||
icon: Icon(icon, size: 20),
|
||||
onPressed: null, // 由CustomDropdown处理点击
|
||||
tooltip: tooltip,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
splashRadius: 20,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: _buildMenuItemWidgets(
|
||||
menuItems,
|
||||
id,
|
||||
secondaryId,
|
||||
tertiaryId,
|
||||
onRenamePressed,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 内部方法:构建模型菜单
|
||||
Widget _buildModelMenu({
|
||||
required List<dynamic> menuItems,
|
||||
required String configId,
|
||||
required IconData icon,
|
||||
required String tooltip,
|
||||
double width = 180,
|
||||
String align = 'right',
|
||||
}) {
|
||||
return CustomDropdown(
|
||||
width: width,
|
||||
align: align,
|
||||
trigger: IconButton(
|
||||
icon: Icon(icon, size: 16),
|
||||
onPressed: null, // 由CustomDropdown处理点击
|
||||
tooltip: tooltip,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
splashRadius: 20,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: _buildModelMenuItemWidgets(
|
||||
menuItems,
|
||||
configId,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建菜单项列表
|
||||
List<Widget> _buildMenuItemWidgets(
|
||||
List<dynamic> menuItems,
|
||||
String id,
|
||||
String? secondaryId,
|
||||
String? tertiaryId,
|
||||
Function()? onRenamePressed,
|
||||
) {
|
||||
final List<Widget> widgets = [];
|
||||
|
||||
for (final item in menuItems) {
|
||||
if (item is String && item == "divider") {
|
||||
widgets.add(const DropdownDivider());
|
||||
} else if (item is MenuSectionData) {
|
||||
widgets.add(
|
||||
DropdownSection(
|
||||
title: item.title,
|
||||
children: item.items.map((menuItem) {
|
||||
return _buildSingleMenuItem(
|
||||
menuItem,
|
||||
id,
|
||||
secondaryId,
|
||||
tertiaryId,
|
||||
onRenamePressed,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
} else if (item is MenuItemData) {
|
||||
widgets.add(
|
||||
_buildSingleMenuItem(
|
||||
item,
|
||||
id,
|
||||
secondaryId,
|
||||
tertiaryId,
|
||||
onRenamePressed,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return widgets;
|
||||
}
|
||||
|
||||
/// 构建模型菜单项列表
|
||||
List<Widget> _buildModelMenuItemWidgets(
|
||||
List<dynamic> menuItems,
|
||||
String configId,
|
||||
) {
|
||||
final List<Widget> widgets = [];
|
||||
|
||||
for (final item in menuItems) {
|
||||
if (item is String && item == "divider") {
|
||||
widgets.add(const DropdownDivider());
|
||||
} else if (item is ModelMenuSectionData) {
|
||||
widgets.add(
|
||||
DropdownSection(
|
||||
title: item.title,
|
||||
children: item.items.map((menuItem) {
|
||||
return _buildSingleModelMenuItem(menuItem, configId);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
} else if (item is ModelMenuItemData) {
|
||||
widgets.add(_buildSingleModelMenuItem(item, configId));
|
||||
}
|
||||
}
|
||||
|
||||
return widgets;
|
||||
}
|
||||
|
||||
/// 构建单个菜单项
|
||||
Widget _buildSingleMenuItem(
|
||||
MenuItemData item,
|
||||
String id,
|
||||
String? secondaryId,
|
||||
String? tertiaryId,
|
||||
Function()? onRenamePressed,
|
||||
) {
|
||||
// 特殊处理重命名操作,因为需要直接访问State
|
||||
Future<void> Function()? onTapHandler;
|
||||
if (item.label == '重命名Act' || item.label == '重命名章节') {
|
||||
onTapHandler = null;
|
||||
} else if (item.onTap != null) {
|
||||
onTapHandler = () async {
|
||||
await item.onTap!(context, editorBloc!, id, secondaryId, tertiaryId);
|
||||
};
|
||||
}
|
||||
|
||||
return DropdownItem(
|
||||
icon: item.icon,
|
||||
label: item.label,
|
||||
hasSubmenu: item.hasSubmenu,
|
||||
disabled: item.disabled,
|
||||
isDangerous: item.isDangerous,
|
||||
onTap: onTapHandler,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建单个模型菜单项
|
||||
Widget _buildSingleModelMenuItem(
|
||||
ModelMenuItemData item,
|
||||
String configId,
|
||||
) {
|
||||
Future<void> Function()? onTapHandler;
|
||||
if (item.onTap != null) {
|
||||
onTapHandler = () async {
|
||||
await item.onTap!(configId);
|
||||
};
|
||||
}
|
||||
|
||||
return DropdownItem(
|
||||
icon: item.icon,
|
||||
label: item.label,
|
||||
hasSubmenu: item.hasSubmenu,
|
||||
disabled: item.disabled,
|
||||
isDangerous: item.isDangerous,
|
||||
onTap: onTapHandler,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建预设菜单项列表
|
||||
List<Widget> _buildPresetMenuItemWidgets(
|
||||
List<dynamic> menuItems,
|
||||
String featureType,
|
||||
) {
|
||||
final List<Widget> widgets = [];
|
||||
final presetService = AIPresetService();
|
||||
|
||||
for (final item in menuItems) {
|
||||
if (item is String && item == "divider") {
|
||||
widgets.add(const DropdownDivider());
|
||||
} else if (item is PresetMenuSectionData) {
|
||||
widgets.add(
|
||||
DropdownSection(
|
||||
title: item.title,
|
||||
children: item.items.map((menuItem) {
|
||||
return _buildSinglePresetMenuItem(menuItem, presetService, featureType);
|
||||
}).toList(),
|
||||
dividerAtBottom: item.dividerAtBottom,
|
||||
),
|
||||
);
|
||||
} else if (item is PresetMenuItemData) {
|
||||
widgets.add(_buildSinglePresetMenuItem(item, presetService, featureType));
|
||||
}
|
||||
}
|
||||
|
||||
return widgets;
|
||||
}
|
||||
|
||||
/// 构建单个预设菜单项
|
||||
Widget _buildSinglePresetMenuItem(
|
||||
PresetMenuItemData item,
|
||||
AIPresetService presetService,
|
||||
String featureType,
|
||||
) {
|
||||
Future<void> Function()? onTapHandler;
|
||||
if (item.onTap != null) {
|
||||
onTapHandler = () async {
|
||||
await item.onTap!(context, presetService, featureType);
|
||||
};
|
||||
}
|
||||
|
||||
return DropdownItem(
|
||||
icon: item.icon,
|
||||
label: item.label,
|
||||
hasSubmenu: item.hasSubmenu,
|
||||
disabled: item.disabled,
|
||||
isDangerous: item.isDangerous,
|
||||
onTap: onTapHandler,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 下拉菜单显示设置
|
||||
class DropdownDisplaySettings {
|
||||
final double actMenuWidth;
|
||||
final double chapterMenuWidth;
|
||||
final double sceneMenuWidth;
|
||||
final double modelMenuWidth;
|
||||
final double presetMenuWidth;
|
||||
final String actMenuAlign;
|
||||
final String chapterMenuAlign;
|
||||
final String sceneMenuAlign;
|
||||
final String modelMenuAlign;
|
||||
final String presetMenuAlign;
|
||||
|
||||
const DropdownDisplaySettings({
|
||||
this.actMenuWidth = 240,
|
||||
this.chapterMenuWidth = 240,
|
||||
this.sceneMenuWidth = 240,
|
||||
this.modelMenuWidth = 180,
|
||||
this.presetMenuWidth = 280,
|
||||
this.actMenuAlign = 'left',
|
||||
this.chapterMenuAlign = 'right',
|
||||
this.sceneMenuAlign = 'right',
|
||||
this.modelMenuAlign = 'right',
|
||||
this.presetMenuAlign = 'right',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/models/novel_setting_item.dart';
|
||||
import 'package:ainoval/models/setting_group.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/novel_setting_detail.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/novel_setting_group_dialog.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/novel_setting_group_selection_dialog.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/novel_setting_relationship_dialog.dart';
|
||||
|
||||
/// 统一的浮动设定对话框管理器
|
||||
class FloatingSettingDialogs {
|
||||
|
||||
/// 显示设定详情编辑卡片
|
||||
static void showSettingDetail({
|
||||
required BuildContext context,
|
||||
String? itemId,
|
||||
required String novelId,
|
||||
String? groupId,
|
||||
bool isEditing = false,
|
||||
required Function(NovelSettingItem, String?) onSave,
|
||||
required VoidCallback onCancel,
|
||||
}) {
|
||||
// 使用浮动设定详情管理器
|
||||
FloatingNovelSettingDetail.show(
|
||||
context: context,
|
||||
itemId: itemId,
|
||||
novelId: novelId,
|
||||
groupId: groupId,
|
||||
isEditing: isEditing,
|
||||
onSave: onSave,
|
||||
onCancel: onCancel,
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示设定组管理卡片
|
||||
static void showSettingGroup({
|
||||
required BuildContext context,
|
||||
required String novelId,
|
||||
SettingGroup? group,
|
||||
required Function(SettingGroup) onSave,
|
||||
}) {
|
||||
// 使用浮动设定组管理器
|
||||
FloatingNovelSettingGroupDialog.show(
|
||||
context: context,
|
||||
novelId: novelId,
|
||||
group: group,
|
||||
onSave: onSave,
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示设定组选择卡片
|
||||
static void showSettingGroupSelection({
|
||||
required BuildContext context,
|
||||
required String novelId,
|
||||
required Function(String groupId, String groupName) onGroupSelected,
|
||||
}) {
|
||||
// 使用浮动设定组选择管理器
|
||||
FloatingNovelSettingGroupSelectionDialog.show(
|
||||
context: context,
|
||||
novelId: novelId,
|
||||
onGroupSelected: onGroupSelected,
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示设定关系创建卡片
|
||||
static void showSettingRelationship({
|
||||
required BuildContext context,
|
||||
required String novelId,
|
||||
required String sourceItemId,
|
||||
required String sourceName,
|
||||
required List<NovelSettingItem> availableTargets,
|
||||
required Function(String relationType, String targetItemId, String? description) onSave,
|
||||
}) {
|
||||
// 使用浮动设定关系管理器
|
||||
FloatingNovelSettingRelationshipDialog.show(
|
||||
context: context,
|
||||
novelId: novelId,
|
||||
sourceItemId: sourceItemId,
|
||||
sourceName: sourceName,
|
||||
availableTargets: availableTargets,
|
||||
onSave: onSave,
|
||||
);
|
||||
}
|
||||
}
|
||||
160
AINoval/lib/screens/editor/widgets/generate_scene_dialog.dart
Normal file
160
AINoval/lib/screens/editor/widgets/generate_scene_dialog.dart
Normal file
@@ -0,0 +1,160 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/models/novel_structure.dart';
|
||||
|
||||
/// 生成场景对话框结果
|
||||
class GenerateSceneDialogResult {
|
||||
final String summary;
|
||||
final String? chapterId;
|
||||
final String? styleInstructions;
|
||||
|
||||
GenerateSceneDialogResult({
|
||||
required this.summary,
|
||||
this.chapterId,
|
||||
this.styleInstructions,
|
||||
});
|
||||
}
|
||||
|
||||
/// 生成场景对话框,用于输入摘要/大纲,然后触发AI生成场景内容
|
||||
class GenerateSceneDialog extends StatefulWidget {
|
||||
const GenerateSceneDialog({
|
||||
Key? key,
|
||||
required this.novel,
|
||||
this.initialSummary = '',
|
||||
this.initialChapterId,
|
||||
}) : super(key: key);
|
||||
|
||||
/// 当前小说
|
||||
final Novel novel;
|
||||
|
||||
/// 初始摘要文本
|
||||
final String initialSummary;
|
||||
|
||||
/// 初始章节ID
|
||||
final String? initialChapterId;
|
||||
|
||||
@override
|
||||
State<GenerateSceneDialog> createState() => _GenerateSceneDialogState();
|
||||
}
|
||||
|
||||
class _GenerateSceneDialogState extends State<GenerateSceneDialog> {
|
||||
final TextEditingController _summaryController = TextEditingController();
|
||||
final TextEditingController _styleController = TextEditingController();
|
||||
String? _selectedChapterId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_summaryController.text = widget.initialSummary;
|
||||
_selectedChapterId = widget.initialChapterId;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_summaryController.dispose();
|
||||
_styleController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 准备章节列表,包含篇章>章节层级
|
||||
List<DropdownMenuItem<String>> _buildChapterItems() {
|
||||
final items = <DropdownMenuItem<String>>[];
|
||||
|
||||
// 空选项
|
||||
items.add(const DropdownMenuItem<String>(
|
||||
value: null,
|
||||
child: Text('(无指定章节)'),
|
||||
));
|
||||
|
||||
// 遍历篇章和章节
|
||||
for (final act in widget.novel.acts) {
|
||||
for (final chapter in act.chapters) {
|
||||
items.add(DropdownMenuItem<String>(
|
||||
value: chapter.id,
|
||||
child: Text('${act.title} > ${chapter.title}'),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('AI 生成场景内容'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 摘要/大纲输入
|
||||
TextField(
|
||||
controller: _summaryController,
|
||||
maxLines: 5,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '场景摘要/大纲 *',
|
||||
hintText: '请输入场景的摘要或大纲,AI将根据此内容生成详细场景',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 章节选择
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedChapterId,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '选择章节(可选)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: _buildChapterItems(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedChapterId = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 风格指令
|
||||
TextField(
|
||||
controller: _styleController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '风格指令(可选)',
|
||||
hintText: '例如:多对话,少描写,悬疑风格',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
// 取消按钮
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('取消'),
|
||||
),
|
||||
|
||||
// 生成按钮
|
||||
ElevatedButton(
|
||||
onPressed: _summaryController.text.trim().isEmpty
|
||||
? null
|
||||
: () {
|
||||
// 返回生成结果
|
||||
Navigator.of(context).pop(
|
||||
GenerateSceneDialogResult(
|
||||
summary: _summaryController.text.trim(),
|
||||
chapterId: _selectedChapterId,
|
||||
styleInstructions: _styleController.text.trim().isNotEmpty
|
||||
? _styleController.text.trim()
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('生成'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
116
AINoval/lib/screens/editor/widgets/menu_builder.dart
Normal file
116
AINoval/lib/screens/editor/widgets/menu_builder.dart
Normal file
@@ -0,0 +1,116 @@
|
||||
import 'package:ainoval/blocs/editor/editor_bloc.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/dropdown_manager.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 通用菜单构建器
|
||||
/// 用于构建Act、Chapter、Scene和Model的下拉菜单
|
||||
class MenuBuilder {
|
||||
/// 构建Act菜单
|
||||
static Widget buildActMenu({
|
||||
required BuildContext context,
|
||||
required EditorBloc editorBloc,
|
||||
required String actId,
|
||||
required Function()? onRenamePressed,
|
||||
double width = 240,
|
||||
String align = 'left',
|
||||
}) {
|
||||
final dropdownManager = DropdownManager(
|
||||
context: context,
|
||||
editorBloc: editorBloc,
|
||||
displaySettings: DropdownDisplaySettings(
|
||||
actMenuWidth: width,
|
||||
actMenuAlign: align,
|
||||
),
|
||||
);
|
||||
|
||||
return dropdownManager.buildActMenu(
|
||||
actId: actId,
|
||||
onRenamePressed: onRenamePressed,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建Chapter菜单
|
||||
static Widget buildChapterMenu({
|
||||
required BuildContext context,
|
||||
required EditorBloc editorBloc,
|
||||
required String actId,
|
||||
required String chapterId,
|
||||
required Function()? onRenamePressed,
|
||||
double width = 240,
|
||||
String align = 'right',
|
||||
}) {
|
||||
final dropdownManager = DropdownManager(
|
||||
context: context,
|
||||
editorBloc: editorBloc,
|
||||
displaySettings: DropdownDisplaySettings(
|
||||
chapterMenuWidth: width,
|
||||
chapterMenuAlign: align,
|
||||
),
|
||||
);
|
||||
|
||||
return dropdownManager.buildChapterMenu(
|
||||
actId: actId,
|
||||
chapterId: chapterId,
|
||||
onRenamePressed: onRenamePressed,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建Scene菜单
|
||||
static Widget buildSceneMenu({
|
||||
required BuildContext context,
|
||||
required EditorBloc editorBloc,
|
||||
required String actId,
|
||||
required String chapterId,
|
||||
required String sceneId,
|
||||
double width = 240,
|
||||
String align = 'right',
|
||||
}) {
|
||||
final dropdownManager = DropdownManager(
|
||||
context: context,
|
||||
editorBloc: editorBloc,
|
||||
displaySettings: DropdownDisplaySettings(
|
||||
sceneMenuWidth: width,
|
||||
sceneMenuAlign: align,
|
||||
),
|
||||
);
|
||||
|
||||
return dropdownManager.buildSceneMenu(
|
||||
actId: actId,
|
||||
chapterId: chapterId,
|
||||
sceneId: sceneId,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建Model菜单
|
||||
static Widget buildModelMenu({
|
||||
required BuildContext context,
|
||||
required String configId,
|
||||
required bool isValidated,
|
||||
required bool isDefault,
|
||||
required Future<void> Function(String) onValidate,
|
||||
required Future<void> Function(String) onSetDefault,
|
||||
required Future<void> Function(String) onEdit,
|
||||
required Future<void> Function(String) onDelete,
|
||||
double width = 180,
|
||||
String align = 'right',
|
||||
}) {
|
||||
final dropdownManager = DropdownManager(
|
||||
context: context,
|
||||
editorBloc: null, // 模型菜单不需要EditorBloc
|
||||
displaySettings: DropdownDisplaySettings(
|
||||
modelMenuWidth: width,
|
||||
modelMenuAlign: align,
|
||||
),
|
||||
);
|
||||
|
||||
return dropdownManager.buildModelMenu(
|
||||
configId: configId,
|
||||
isValidated: isValidated,
|
||||
isDefault: isDefault,
|
||||
onValidate: onValidate,
|
||||
onSetDefault: onSetDefault,
|
||||
onEdit: onEdit,
|
||||
onDelete: onDelete,
|
||||
);
|
||||
}
|
||||
}
|
||||
356
AINoval/lib/screens/editor/widgets/menu_definitions.dart
Normal file
356
AINoval/lib/screens/editor/widgets/menu_definitions.dart
Normal file
@@ -0,0 +1,356 @@
|
||||
import 'package:ainoval/blocs/editor/editor_bloc.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/dialogs.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:ainoval/screens/editor/controllers/editor_screen_controller.dart';
|
||||
|
||||
/// 通用菜单项数据模型
|
||||
class MenuItemData {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Future<void> Function(BuildContext, EditorBloc, String, String?, String?)? onTap;
|
||||
final bool hasSubmenu;
|
||||
final bool disabled;
|
||||
final bool isDangerous;
|
||||
|
||||
const MenuItemData({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
this.onTap,
|
||||
this.hasSubmenu = false,
|
||||
this.disabled = false,
|
||||
this.isDangerous = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// 模型菜单项数据模型(扩展用于模型操作)
|
||||
class ModelMenuItemData {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Future<void> Function(String configId)? onTap;
|
||||
final bool hasSubmenu;
|
||||
final bool disabled;
|
||||
final bool isDangerous;
|
||||
|
||||
const ModelMenuItemData({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
this.onTap,
|
||||
this.hasSubmenu = false,
|
||||
this.disabled = false,
|
||||
this.isDangerous = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// 菜单分区数据模型
|
||||
class MenuSectionData {
|
||||
final String title;
|
||||
final List<MenuItemData> items;
|
||||
|
||||
const MenuSectionData({
|
||||
required this.title,
|
||||
required this.items,
|
||||
});
|
||||
}
|
||||
|
||||
/// 模型菜单分区数据模型
|
||||
class ModelMenuSectionData {
|
||||
final String title;
|
||||
final List<ModelMenuItemData> items;
|
||||
|
||||
const ModelMenuSectionData({
|
||||
required this.title,
|
||||
required this.items,
|
||||
});
|
||||
}
|
||||
|
||||
/// Model菜单定义
|
||||
class ModelMenuDefinitions {
|
||||
static List<dynamic> getMenuItems({
|
||||
required bool isValidated,
|
||||
required bool isDefault,
|
||||
required Future<void> Function(String) onValidate,
|
||||
required Future<void> Function(String) onSetDefault,
|
||||
required Future<void> Function(String) onEdit,
|
||||
required Future<void> Function(String) onDelete,
|
||||
}) {
|
||||
return [
|
||||
// 验证操作
|
||||
ModelMenuItemData(
|
||||
icon: isValidated ? Icons.verified : Icons.wifi_protected_setup,
|
||||
label: isValidated ? '重新验证' : '验证连接',
|
||||
onTap: onValidate,
|
||||
),
|
||||
|
||||
// 设为默认(如果不是默认模型)
|
||||
if (!isDefault)
|
||||
ModelMenuItemData(
|
||||
icon: Icons.star,
|
||||
label: '设为默认',
|
||||
onTap: onSetDefault,
|
||||
),
|
||||
|
||||
// 编辑操作
|
||||
ModelMenuItemData(
|
||||
icon: Icons.edit,
|
||||
label: '编辑',
|
||||
onTap: onEdit,
|
||||
),
|
||||
|
||||
// 分隔线
|
||||
"divider",
|
||||
|
||||
// 危险操作
|
||||
ModelMenuItemData(
|
||||
icon: Icons.delete_outline,
|
||||
label: '删除',
|
||||
isDangerous: true,
|
||||
onTap: onDelete,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// Act菜单定义
|
||||
class ActMenuDefinitions {
|
||||
static List<dynamic> getMenuItems() {
|
||||
return [
|
||||
// 基本操作
|
||||
MenuItemData(
|
||||
icon: Icons.add,
|
||||
label: '添加新章节',
|
||||
onTap: (context, editorBloc, actId, _, __) async {
|
||||
editorBloc.add(AddNewChapter(
|
||||
novelId: editorBloc.novelId,
|
||||
actId: actId,
|
||||
title: '新章节 ${DateTime.now().millisecondsSinceEpoch % 100}',
|
||||
));
|
||||
},
|
||||
),
|
||||
MenuItemData(
|
||||
icon: Icons.edit,
|
||||
label: '重命名Act',
|
||||
onTap: null,
|
||||
),
|
||||
|
||||
// 导出选项
|
||||
MenuSectionData(
|
||||
title: '导出选项',
|
||||
items: [
|
||||
MenuItemData(
|
||||
icon: Icons.file_download,
|
||||
label: '导出为PDF',
|
||||
onTap: (context, editorBloc, actId, _, __) async {
|
||||
// 实现导出为PDF功能
|
||||
},
|
||||
),
|
||||
MenuItemData(
|
||||
icon: Icons.file_download,
|
||||
label: '导出为Word',
|
||||
onTap: (context, editorBloc, actId, _, __) async {
|
||||
// 实现导出为Word功能
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 危险操作
|
||||
MenuItemData(
|
||||
icon: Icons.delete_outline,
|
||||
label: '删除Act',
|
||||
isDangerous: true,
|
||||
onTap: (context, editorBloc, actId, _, __) async {
|
||||
final confirmed = await _confirmAndDelete(
|
||||
context,
|
||||
'删除Act',
|
||||
'确定要删除这个Act吗?此操作不可撤销。',
|
||||
);
|
||||
if (confirmed) {
|
||||
editorBloc.add(DeleteAct(
|
||||
novelId: editorBloc.novelId,
|
||||
actId: actId,
|
||||
));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('正在删除卷...'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// Chapter菜单定义
|
||||
class ChapterMenuDefinitions {
|
||||
static List<dynamic> getMenuItems() {
|
||||
return [
|
||||
// 基本操作
|
||||
MenuItemData(
|
||||
icon: Icons.add,
|
||||
label: '添加新场景',
|
||||
onTap: (context, editorBloc, actId, chapterId, _) async {
|
||||
_addNewScene(context, editorBloc, actId, chapterId!);
|
||||
},
|
||||
),
|
||||
MenuItemData(
|
||||
icon: Icons.edit,
|
||||
label: '重命名章节',
|
||||
onTap: null,
|
||||
),
|
||||
|
||||
// 分隔线
|
||||
"divider",
|
||||
|
||||
// 额外功能
|
||||
MenuItemData(
|
||||
icon: Icons.tag,
|
||||
label: '禁用编号',
|
||||
onTap: (context, editorBloc, actId, chapterId, _) async {
|
||||
// 实现禁用编号功能
|
||||
},
|
||||
),
|
||||
MenuItemData(
|
||||
icon: Icons.content_copy,
|
||||
label: '复制所有场景内容',
|
||||
onTap: (context, editorBloc, actId, chapterId, _) async {
|
||||
// 实现复制场景内容功能
|
||||
},
|
||||
),
|
||||
|
||||
// 分隔线
|
||||
"divider",
|
||||
|
||||
// 危险操作
|
||||
MenuItemData(
|
||||
icon: Icons.delete_outline,
|
||||
label: '删除章节',
|
||||
isDangerous: true,
|
||||
onTap: (context, editorBloc, actId, chapterId, _) async {
|
||||
final confirmed = await _confirmAndDelete(
|
||||
context,
|
||||
'删除章节',
|
||||
'确定要删除这个章节吗?此操作不可撤销,章节内的所有场景都将被删除。',
|
||||
);
|
||||
if (confirmed) {
|
||||
// 实现删除章节功能
|
||||
editorBloc.add(DeleteChapter(
|
||||
novelId: editorBloc.novelId,
|
||||
actId: actId,
|
||||
chapterId: chapterId!,
|
||||
));
|
||||
|
||||
// 显示操作反馈
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('正在删除章节...'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// 添加新场景
|
||||
static void _addNewScene(BuildContext context, EditorBloc editorBloc, String actId, String chapterId) {
|
||||
final newSceneId = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
AppLogger.i('Chapter', '添加新场景:actId=$actId, chapterId=$chapterId, sceneId=$newSceneId');
|
||||
|
||||
editorBloc.add(AddNewScene(
|
||||
novelId: editorBloc.novelId,
|
||||
actId: actId,
|
||||
chapterId: chapterId,
|
||||
sceneId: newSceneId,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Scene菜单定义
|
||||
class SceneMenuDefinitions {
|
||||
static List<dynamic> getMenuItems() {
|
||||
return [
|
||||
MenuItemData(
|
||||
icon: Icons.copy_outlined,
|
||||
label: '复制场景',
|
||||
onTap: (context, editorBloc, actId, chapterId, sceneId) async {
|
||||
// 实现复制场景功能
|
||||
// editorBloc.add(DuplicateScene(
|
||||
// novelId: editorBloc.novelId,
|
||||
// actId: actId,
|
||||
// chapterId: chapterId!,
|
||||
// sceneId: sceneId!,
|
||||
// ));
|
||||
},
|
||||
),
|
||||
MenuItemData(
|
||||
icon: Icons.splitscreen_outlined,
|
||||
label: '拆分场景',
|
||||
onTap: (context, editorBloc, actId, chapterId, sceneId) async {
|
||||
// 实现拆分场景功能
|
||||
},
|
||||
),
|
||||
|
||||
MenuSectionData(
|
||||
title: 'AI功能',
|
||||
items: [
|
||||
MenuItemData(
|
||||
icon: Icons.auto_awesome,
|
||||
label: '生成摘要',
|
||||
onTap: (context, editorBloc, actId, chapterId, sceneId) async {
|
||||
editorBloc.add(GenerateSceneSummaryRequested(
|
||||
sceneId: sceneId!,
|
||||
));
|
||||
},
|
||||
),
|
||||
MenuItemData(
|
||||
icon: Icons.psychology,
|
||||
label: '改进内容',
|
||||
onTap: (context, editorBloc, actId, chapterId, sceneId) async {
|
||||
// 实现AI改进内容功能
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 分隔线
|
||||
"divider",
|
||||
|
||||
// 危险操作
|
||||
MenuItemData(
|
||||
icon: Icons.delete_outline,
|
||||
label: '删除场景',
|
||||
isDangerous: true,
|
||||
onTap: (context, editorBloc, actId, chapterId, sceneId) async {
|
||||
final confirmed = await _confirmAndDelete(
|
||||
context,
|
||||
'删除场景',
|
||||
'确定要删除这个场景吗?此操作不可撤销。',
|
||||
);
|
||||
if (confirmed) {
|
||||
editorBloc.add(DeleteScene(
|
||||
novelId: editorBloc.novelId,
|
||||
actId: actId,
|
||||
chapterId: chapterId!,
|
||||
sceneId: sceneId!,
|
||||
));
|
||||
}
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// 通用确认删除对话框
|
||||
Future<bool> _confirmAndDelete(BuildContext context, String title, String message) async {
|
||||
final confirmed = await DialogUtils.showDangerousConfirmDialog(
|
||||
context: context,
|
||||
title: title,
|
||||
message: message,
|
||||
);
|
||||
return confirmed;
|
||||
}
|
||||
2595
AINoval/lib/screens/editor/widgets/novel_setting_detail.dart
Normal file
2595
AINoval/lib/screens/editor/widgets/novel_setting_detail.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,397 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:ainoval/models/setting_group.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:ainoval/screens/editor/managers/editor_layout_manager.dart';
|
||||
import 'package:ainoval/widgets/common/floating_card.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:ainoval/widgets/common/top_toast.dart';
|
||||
|
||||
|
||||
/// 浮动设定组管理器
|
||||
class FloatingNovelSettingGroupDialog {
|
||||
static bool _isShowing = false;
|
||||
|
||||
/// 显示浮动设定组卡片
|
||||
static void show({
|
||||
required BuildContext context,
|
||||
required String novelId,
|
||||
SettingGroup? group, // 若为null则表示创建新组
|
||||
required Function(SettingGroup) onSave, // 保存回调
|
||||
}) {
|
||||
if (_isShowing) {
|
||||
hide();
|
||||
}
|
||||
|
||||
// 获取布局信息
|
||||
final layoutManager = Provider.of<EditorLayoutManager>(context, listen: false);
|
||||
final sidebarWidth = layoutManager.isEditorSidebarVisible ? layoutManager.editorSidebarWidth : 0.0;
|
||||
|
||||
AppLogger.d('FloatingNovelSettingGroupDialog', '显示浮动卡片,侧边栏宽度: $sidebarWidth');
|
||||
|
||||
// 计算卡片大小
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final cardWidth = (screenSize.width * 0.25).clamp(400.0, 600.0);
|
||||
final cardHeight = (screenSize.height * 0.5).clamp(350.0, 500.0);
|
||||
|
||||
FloatingCard.show(
|
||||
context: context,
|
||||
position: FloatingCardPosition(
|
||||
left: sidebarWidth + 16.0,
|
||||
top: 80.0,
|
||||
),
|
||||
config: FloatingCardConfig(
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
showCloseButton: false,
|
||||
enableBackgroundTap: false,
|
||||
animationDuration: const Duration(milliseconds: 300),
|
||||
animationCurve: Curves.easeOutCubic,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
child: _NovelSettingGroupDialogContent(
|
||||
novelId: novelId,
|
||||
group: group,
|
||||
onSave: (settingGroup) {
|
||||
onSave(settingGroup);
|
||||
hide();
|
||||
},
|
||||
onCancel: hide,
|
||||
),
|
||||
onClose: hide,
|
||||
);
|
||||
|
||||
_isShowing = true;
|
||||
}
|
||||
|
||||
/// 隐藏浮动卡片
|
||||
static void hide() {
|
||||
if (_isShowing) {
|
||||
FloatingCard.hide();
|
||||
_isShowing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否正在显示
|
||||
static bool get isShowing => _isShowing;
|
||||
}
|
||||
|
||||
/// 小说设定组对话框内容
|
||||
///
|
||||
/// 用于创建或编辑设定组
|
||||
class _NovelSettingGroupDialogContent extends StatefulWidget {
|
||||
final String novelId;
|
||||
final SettingGroup? group; // 若为null则表示创建新组
|
||||
final Function(SettingGroup) onSave; // 保存回调
|
||||
final VoidCallback onCancel; // 取消回调
|
||||
|
||||
const _NovelSettingGroupDialogContent({
|
||||
Key? key,
|
||||
required this.novelId,
|
||||
this.group,
|
||||
required this.onSave,
|
||||
required this.onCancel,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<_NovelSettingGroupDialogContent> createState() => _NovelSettingGroupDialogContentState();
|
||||
}
|
||||
|
||||
class _NovelSettingGroupDialogContentState extends State<_NovelSettingGroupDialogContent> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// 表单控制器
|
||||
final _nameController = TextEditingController();
|
||||
final _descriptionController = TextEditingController();
|
||||
|
||||
// 激活状态
|
||||
bool _isActiveContext = false;
|
||||
|
||||
// 保存状态
|
||||
bool _isSaving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// 若为编辑模式,填充表单
|
||||
if (widget.group != null) {
|
||||
_nameController.text = widget.group!.name;
|
||||
if (widget.group!.description != null) {
|
||||
_descriptionController.text = widget.group!.description!;
|
||||
}
|
||||
if (widget.group!.isActiveContext != null) {
|
||||
_isActiveContext = widget.group!.isActiveContext!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_descriptionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// 保存设定组
|
||||
Future<void> _saveSettingGroup() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() {
|
||||
_isSaving = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// 构建设定组对象
|
||||
final settingGroup = SettingGroup(
|
||||
id: widget.group?.id,
|
||||
novelId: widget.novelId,
|
||||
name: _nameController.text,
|
||||
description: _descriptionController.text.isNotEmpty
|
||||
? _descriptionController.text
|
||||
: null,
|
||||
isActiveContext: _isActiveContext,
|
||||
itemIds: widget.group?.itemIds,
|
||||
);
|
||||
|
||||
// 调用保存回调
|
||||
widget.onSave(settingGroup);
|
||||
|
||||
setState(() {
|
||||
_isSaving = false;
|
||||
});
|
||||
|
||||
// 注意:不在这里关闭对话框,因为 FloatingNovelSettingGroupDialog.show() 的 onSave 回调会调用 hide()
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.e('NovelSettingGroupDialog', '保存设定组失败', e, stackTrace);
|
||||
setState(() {
|
||||
_isSaving = false;
|
||||
});
|
||||
|
||||
// 显示错误提示
|
||||
if (context.mounted) {
|
||||
TopToast.error(context, '保存失败: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
final isCreating = widget.group == null;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? WebTheme.darkBackground : WebTheme.lightBackground,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isDark ? WebTheme.darkGrey300 : WebTheme.grey300,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 头部
|
||||
_buildHeader(isDark, isCreating),
|
||||
|
||||
// 内容区域
|
||||
Expanded(
|
||||
child: _buildContent(isDark, isCreating),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(bool isDark, bool isCreating) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: isDark ? WebTheme.darkGrey300 : WebTheme.grey300,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
isCreating ? '创建设定组' : '编辑设定组',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: widget.onCancel,
|
||||
icon: Icon(
|
||||
Icons.close,
|
||||
size: 20,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
style: IconButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: const Size(32, 32),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(bool isDark, bool isCreating) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题
|
||||
Text(
|
||||
isCreating ? '创建设定组' : '编辑设定组',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 表单
|
||||
Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 名称
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
autofocus: true,
|
||||
maxLength: 30,
|
||||
decoration: WebTheme.getBorderedInputDecoration(
|
||||
labelText: '名称',
|
||||
hintText: '输入设定组名称 (30 字以内)',
|
||||
context: context,
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入设定组名称';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 描述
|
||||
TextFormField(
|
||||
controller: _descriptionController,
|
||||
maxLines: 3,
|
||||
maxLength: 200,
|
||||
decoration: WebTheme.getBorderedInputDecoration(
|
||||
labelText: '描述',
|
||||
hintText: '输入设定组描述(可选,200 字以内)',
|
||||
context: context,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 激活状态
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: isDark ? WebTheme.darkGrey300 : WebTheme.grey300,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Switch(
|
||||
value: _isActiveContext,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_isActiveContext = value;
|
||||
});
|
||||
},
|
||||
activeColor: WebTheme.getTextColor(context),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'设为活跃上下文',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'活跃上下文中的设定将用于AI生成和提示',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 按钮区域
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: widget.onCancel,
|
||||
style: WebTheme.getSecondaryButtonStyle(context),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: _isSaving ? null : _saveSettingGroup,
|
||||
style: WebTheme.getPrimaryButtonStyle(context),
|
||||
child: _isSaving
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
color: isDark ? WebTheme.darkBackground : WebTheme.lightBackground,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(isCreating ? '创建中...' : '保存中...'),
|
||||
],
|
||||
)
|
||||
: Text(isCreating ? '创建' : '保存'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:ainoval/models/setting_group.dart';
|
||||
import 'package:ainoval/blocs/setting/setting_bloc.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/novel_setting_group_dialog.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:ainoval/screens/editor/managers/editor_layout_manager.dart';
|
||||
import 'package:ainoval/widgets/common/floating_card.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
|
||||
/// 浮动设定组选择管理器
|
||||
class FloatingNovelSettingGroupSelectionDialog {
|
||||
static bool _isShowing = false;
|
||||
|
||||
/// 显示浮动设定组选择卡片
|
||||
static void show({
|
||||
required BuildContext context,
|
||||
required String novelId,
|
||||
required Function(String groupId, String groupName) onGroupSelected,
|
||||
}) {
|
||||
if (_isShowing) {
|
||||
hide();
|
||||
}
|
||||
|
||||
// 获取布局信息
|
||||
final layoutManager = Provider.of<EditorLayoutManager>(context, listen: false);
|
||||
final sidebarWidth = layoutManager.isEditorSidebarVisible ? layoutManager.editorSidebarWidth : 0.0;
|
||||
|
||||
AppLogger.d('FloatingNovelSettingGroupSelectionDialog', '显示浮动卡片,侧边栏宽度: $sidebarWidth');
|
||||
|
||||
// 计算卡片大小
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final cardWidth = (screenSize.width * 0.3).clamp(400.0, 600.0);
|
||||
final cardHeight = (screenSize.height * 0.6).clamp(400.0, 600.0);
|
||||
|
||||
// 获取当前的 Provider 实例
|
||||
final settingBloc = context.read<SettingBloc>();
|
||||
|
||||
FloatingCard.show(
|
||||
context: context,
|
||||
position: FloatingCardPosition(
|
||||
left: sidebarWidth + 16.0,
|
||||
top: 80.0,
|
||||
),
|
||||
config: FloatingCardConfig(
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
showCloseButton: false,
|
||||
enableBackgroundTap: false,
|
||||
animationDuration: const Duration(milliseconds: 300),
|
||||
animationCurve: Curves.easeOutCubic,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
child: MultiProvider(
|
||||
providers: [
|
||||
Provider<EditorLayoutManager>.value(value: layoutManager),
|
||||
BlocProvider<SettingBloc>.value(value: settingBloc),
|
||||
],
|
||||
child: _NovelSettingGroupSelectionDialogContent(
|
||||
novelId: novelId,
|
||||
onGroupSelected: onGroupSelected,
|
||||
onCancel: hide,
|
||||
),
|
||||
),
|
||||
onClose: hide,
|
||||
);
|
||||
|
||||
_isShowing = true;
|
||||
}
|
||||
|
||||
/// 隐藏浮动卡片
|
||||
static void hide() {
|
||||
if (_isShowing) {
|
||||
FloatingCard.hide();
|
||||
_isShowing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否正在显示
|
||||
static bool get isShowing => _isShowing;
|
||||
}
|
||||
|
||||
/// 小说设定组选择对话框内容
|
||||
///
|
||||
/// 用于选择现有设定组或创建新设定组
|
||||
class _NovelSettingGroupSelectionDialogContent extends StatefulWidget {
|
||||
final String novelId;
|
||||
final Function(String groupId, String groupName) onGroupSelected;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
const _NovelSettingGroupSelectionDialogContent({
|
||||
Key? key,
|
||||
required this.novelId,
|
||||
required this.onGroupSelected,
|
||||
required this.onCancel,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<_NovelSettingGroupSelectionDialogContent> createState() => _NovelSettingGroupSelectionDialogContentState();
|
||||
}
|
||||
|
||||
class _NovelSettingGroupSelectionDialogContentState extends State<_NovelSettingGroupSelectionDialogContent> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 加载设定组列表
|
||||
context.read<SettingBloc>().add(LoadSettingGroups(widget.novelId));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
elevation: 5,
|
||||
child: Container(
|
||||
width: 400,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'选择设定组',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 设定组列表
|
||||
BlocBuilder<SettingBloc, SettingState>(
|
||||
builder: (context, state) {
|
||||
if (state.groupsStatus == SettingStatus.loading) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24.0),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.groupsStatus == SettingStatus.failure) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24.0),
|
||||
child: Text(
|
||||
'加载设定组失败:${state.error}',
|
||||
style: const TextStyle(color: Colors.red),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.groupsStatus == SettingStatus.success && state.groups.isEmpty) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24.0),
|
||||
child: Text(
|
||||
'没有可用的设定组,请创建新设定组',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.groupsStatus == SettingStatus.success) {
|
||||
return SizedBox(
|
||||
height: 300,
|
||||
child: ListView.builder(
|
||||
itemCount: state.groups.length,
|
||||
itemBuilder: (context, index) {
|
||||
final group = state.groups[index];
|
||||
return ListTile(
|
||||
title: Text(group.name),
|
||||
subtitle: group.description != null && group.description!.isNotEmpty
|
||||
? Text(
|
||||
group.description!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: null,
|
||||
leading: Icon(
|
||||
Icons.folder_outlined,
|
||||
color: group.isActiveContext == true
|
||||
? Colors.blue
|
||||
: Colors.grey,
|
||||
),
|
||||
onTap: () {
|
||||
// 正确关闭浮动卡片,而不是使用Navigator.pop()
|
||||
// 使用Future.microtask确保回调在对话框处理之后执行
|
||||
Future.microtask(() {
|
||||
// 关闭浮动卡片
|
||||
FloatingNovelSettingGroupSelectionDialog.hide();
|
||||
// 延迟调用回调
|
||||
Future.delayed(Duration.zero, () {
|
||||
widget.onGroupSelected(group.id!, group.name);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24.0),
|
||||
child: Text('请加载设定组'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 操作按钮
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('创建新设定组'),
|
||||
onPressed: () {
|
||||
_showCreateGroupDialog(context);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: WebTheme.getPrimaryColor(context),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: widget.onCancel,
|
||||
child: const Text('取消'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 显示创建设定组对话框
|
||||
void _showCreateGroupDialog(BuildContext context) {
|
||||
FloatingNovelSettingGroupDialog.show(
|
||||
context: context,
|
||||
novelId: widget.novelId,
|
||||
onSave: (SettingGroup group) {
|
||||
AppLogger.i('NovelSettingGroupSelectionDialog', '创建设定组:${group.name}');
|
||||
|
||||
// 保存设定组
|
||||
context.read<SettingBloc>().add(CreateSettingGroup(
|
||||
novelId: widget.novelId,
|
||||
group: group,
|
||||
));
|
||||
|
||||
// 监听状态变化,找到新创建的设定组,但不要直接调用导航回调
|
||||
final settingBloc = context.read<SettingBloc>();
|
||||
late final subscription;
|
||||
subscription = settingBloc.stream.listen((state) {
|
||||
if (state.groupsStatus == SettingStatus.success) {
|
||||
// 检查是否有新添加的设定组
|
||||
final newGroup = state.groups.where((g) => g.name == group.name).lastOrNull;
|
||||
if (newGroup != null && newGroup.id != null) {
|
||||
subscription.cancel(); // 先停止监听
|
||||
|
||||
// 只显示成功提示,不执行选择回调,让用户手动选择
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('设定组 "${newGroup.name}" 创建成功!'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 刷新当前对话框的设定组列表
|
||||
if (context.mounted) {
|
||||
context.read<SettingBloc>().add(LoadSettingGroups(widget.novelId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.groupsStatus == SettingStatus.failure) {
|
||||
subscription.cancel();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('创建设定组失败:${state.error}'),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 一段时间后如果没有成功,取消订阅
|
||||
Future.delayed(const Duration(seconds: 10), () {
|
||||
subscription.cancel();
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user