马良AI写作初始化仓库

This commit is contained in:
邓滨杰
2025-09-10 00:07:52 +08:00
parent 3c06bb1a03
commit 39c0f8840f
1309 changed files with 318528 additions and 0 deletions

View 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),
),
),
],
),
),
);
}
}

View 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,
),
),
),
],
),
),
],
),
),
),
),
),
),
),
),
);
}
}

View 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),
),
],
),
],
),
),
);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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,
),
);
},
);
}
}

View File

@@ -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')),
);
}
}
}

View 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('加载角色'),
),
],
),
);
}
},
),
),
],
),
),
),
),
);
}
}

View 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('加载订阅计划'),
),
],
),
);
}
},
),
),
],
),
),
),
),
);
}
}

View 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']}'),
],
],
),
);
}
}

View 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('加载用户'),
),
],
),
);
}
},
),
),
],
),
),
),
),
);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View 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,
),
);
}
}

View 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();
}
}
}

View 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),
),
),
),
),
],
),
);
}
}

View 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),
),
);
}
}

View 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,
),
};
}

View 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,
});
}
}
}

View 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,
),
);
}
}

View 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 '刚刚';
}
}
}

View 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';
}
}
}

View 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;
}
}
}

View 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;
}
}
}

View File

@@ -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,
),
),
);
}
}

View 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';
}
}
}

View 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,
),
);
}
}
}

View 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),
),
),
],
),
),
);
}
}

View 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,
),
);
}
}
}

View 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;
}
}
}

View 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),
),
);
}
}

View 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 '刚刚';
}
}
}

View File

@@ -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;
}
}
}

View 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();
}
}
}
}

View 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),
),
],
),
);
}
}

View 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),
),
);
}
}

View 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
),
],
),
);
}
}

View File

@@ -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

View 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,
),
),
],
),
],
),
),
));
}
}

View 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模型'

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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('新建对话'),
),
],
),
);
}
}

View 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事件到ChatBlocBLoC实例: ${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为nullsessionId=${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));
},
);
},
),
);
},
);
}
}

View 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();
}
}

View 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,
),
),
),
),
);
}
}

View 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, // 加粗错误文本
),
),
),
],
);
}
}

File diff suppressed because it is too large Load Diff

View 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),
);
}
}

View 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();
}
}

View 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,
// );
// }
// }

View 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;
},
),
),
),
),
);
}
}

View File

@@ -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('确认生成'),
),
],
);
},
),
);
}
}

View File

@@ -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,
),
),
),
);
}
}

View File

@@ -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,
),
),
];
}
}

File diff suppressed because it is too large Load Diff

View 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统一管理
],
),
);
}
}

View 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,
),
),
),
),
);
}
}

View 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);
}

View 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;
}
}
}

View 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),
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View 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,
),
),
),
],
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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),
),
),
],
);
}
}

View File

@@ -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),
),
),
],
),
],
),
);
},
);
}
}

View 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)';
}

View 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,
),
),
],
),
),
),
),
);
}
}

View 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,
);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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,
),
);
},
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
// 文本生成对话框统一导出文件
// 集中导出扩写、重构、缩写三个对话框组件
export 'expansion_dialog.dart';
export 'refactor_dialog.dart';
export 'summary_dialog.dart';

View File

@@ -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,
),
),
],
);
}
}

File diff suppressed because it is too large Load Diff

View 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,
),
);
},
),
),
),
);
}
}

View 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
)
],
),
),
);
}
}

View 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();
}
}

View 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';
}
}

View 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-1010最高
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,
});
}

View 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();
},
);
},
);
}
}

File diff suppressed because it is too large Load Diff

View 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,
),
),
],
),
),
),
),
);
}
}

View File

@@ -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,
),
],
),
),
],
),
);
},
);
}
}

View File

@@ -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,
));
});
},
);
}
}

View File

@@ -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);
}

View 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;
}
}

View 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;
});
}
},
);
}
}

View 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,
);
}
}

View 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: '输入新的名称',
);
}
}

View 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',
});
}

View File

@@ -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,
);
}
}

View 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('生成'),
),
],
);
}
}

View 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,
);
}
}

View 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;
}

File diff suppressed because it is too large Load Diff

View File

@@ -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 ? '创建' : '保存'),
),
],
),
],
),
);
}
}

View File

@@ -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