马良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,740 @@
import 'package:ainoval/blocs/next_outline/next_outline_bloc.dart';
import 'package:ainoval/blocs/next_outline/next_outline_event.dart';
import 'package:ainoval/blocs/next_outline/next_outline_state.dart';
import 'package:ainoval/models/next_outline/next_outline_dto.dart';
import 'package:ainoval/models/user_ai_model_config_model.dart';
import 'package:ainoval/screens/next_outline/widgets/modern_config_card.dart';
import 'package:ainoval/screens/next_outline/widgets/results_grid.dart';
import 'package:ainoval/services/api_service/base/api_client.dart';
import 'package:ainoval/services/api_service/repositories/editor_repository.dart';
import 'package:ainoval/services/api_service/repositories/impl/editor_repository_impl.dart';
import 'package:ainoval/services/api_service/repositories/impl/next_outline_repository_impl.dart';
import 'package:ainoval/services/api_service/repositories/impl/user_ai_model_config_repository_impl.dart';
import 'package:ainoval/services/api_service/repositories/next_outline_repository.dart';
import 'package:ainoval/services/api_service/repositories/user_ai_model_config_repository.dart';
import 'package:ainoval/utils/web_theme.dart';
import 'package:ainoval/widgets/common/loading_indicator.dart';
import 'package:ainoval/widgets/common/top_toast.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
/// 剧情推演屏幕 - 核心功能组件
///
/// 此组件负责剧情推演的完整功能流程:
/// 1. 配置生成参数(章节范围、模型选择、生成数量等)
/// 2. 调用AI服务生成多个剧情选项
/// 3. 展示生成结果并支持交互操作
/// 4. 保存选中的剧情到小说结构中
///
/// 设计特点:
/// - 采用纯黑白配色方案,符合现代简洁审美
/// - 使用响应式布局,适配不同屏幕尺寸
/// - 合理的间距和尺寸,避免界面拥挤
/// - 统一的视觉层次和交互反馈
class NextOutlineScreen extends StatelessWidget {
/// 小说ID - 用于关联具体的小说项目
final String novelId;
/// 小说标题 - 用于上下文展示
final String novelTitle;
/// 切换到写作模式回调 - 完成推演后返回编辑
final VoidCallback onSwitchToWrite;
/// 跳转到添加模型页面的回调 - 配置新的AI模型
final VoidCallback? onNavigateToAddModel;
/// 跳转到配置特定模型页面的回调 - 调整模型参数
final Function(String configId)? onConfigureModel;
const NextOutlineScreen({
Key? key,
required this.novelId,
required this.novelTitle,
required this.onSwitchToWrite,
this.onNavigateToAddModel,
this.onConfigureModel,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final apiClient = ApiClient();
final editorRepository = EditorRepositoryImpl(apiClient: apiClient);
return MultiRepositoryProvider(
providers: [
RepositoryProvider<NextOutlineRepository>(
create: (context) => NextOutlineRepositoryImpl(
apiClient: apiClient,
),
),
RepositoryProvider<UserAIModelConfigRepository>(
create: (context) => UserAIModelConfigRepositoryImpl(
apiClient: apiClient,
),
),
],
child: BlocProvider(
create: (context) => NextOutlineBloc(
nextOutlineRepository: context.read<NextOutlineRepository>(),
editorRepository: editorRepository,
userAIModelConfigRepository: context.read<UserAIModelConfigRepository>(),
)..add(NextOutlineInitialized(novelId: novelId)),
child: _NextOutlineScreenContent(
novelId: novelId,
novelTitle: novelTitle,
onSwitchToWrite: onSwitchToWrite,
onNavigateToAddModel: onNavigateToAddModel,
onConfigureModel: onConfigureModel,
),
),
);
}
}
/// 剧情推演屏幕内容组件 - 核心业务逻辑实现
///
/// 此组件专注于:
/// 1. 状态管理和业务逻辑处理
/// 2. 用户界面的响应式布局
/// 3. 错误处理和用户反馈
/// 4. 组件间的数据传递和事件处理
///
/// 布局结构:
/// - 左侧配置面板和AI模型选择
/// - 右侧:结果展示区域(生成的剧情选项网格)
/// - 统一的间距和视觉层次
class _NextOutlineScreenContent extends StatefulWidget {
/// 小说ID
final String novelId;
/// 小说标题
final String novelTitle;
/// 切换到写作模式回调
final VoidCallback onSwitchToWrite;
/// 跳转到添加模型页面的回调
final VoidCallback? onNavigateToAddModel;
/// 跳转到配置特定模型页面的回调
final Function(String configId)? onConfigureModel;
const _NextOutlineScreenContent({
Key? key,
required this.novelId,
required this.novelTitle,
required this.onSwitchToWrite,
this.onNavigateToAddModel,
this.onConfigureModel,
}) : super(key: key);
@override
State<_NextOutlineScreenContent> createState() => _NextOutlineScreenContentState();
}
/// 剧情推演屏幕状态管理
class _NextOutlineScreenContentState extends State<_NextOutlineScreenContent> {
List<String> _selectedConfigIds = [];
bool _hasInitialized = false;
@override
void initState() {
super.initState();
}
/// 根据AI模型配置列表初始化选中状态
void _initializeSelectedConfigs(List<UserAIModelConfigModel> aiModelConfigs) {
if (!_hasInitialized && aiModelConfigs.isNotEmpty) {
// 默认选择第一个已验证的模型配置
final validatedConfigs = aiModelConfigs.where((config) => config.isValidated).toList();
if (validatedConfigs.isNotEmpty && _selectedConfigIds.isEmpty) {
_selectedConfigIds = [validatedConfigs.first.id];
_hasInitialized = true;
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
// 使用卡片颜色作为页面背景,避免多层颜色差异
backgroundColor: WebTheme.getCardColor(context),
body: BlocConsumer<NextOutlineBloc, NextOutlineState>(
listenWhen: (previous, current) =>
previous.generationStatus != current.generationStatus,
listener: (context, state) {
// 统一的错误处理 - 使用TopToast显示错误信息
if (state.generationStatus == GenerationStatus.error &&
state.errorMessage != null) {
TopToast.error(context, state.errorMessage!);
}
},
builder: (context, state) {
// 初始化AI模型选择状态不调用setState直接设置状态
_initializeSelectedConfigs(state.aiModelConfigs);
// 加载状态 - 现代简洁的加载指示器
if (state.generationStatus == GenerationStatus.loadingChapters ||
state.generationStatus == GenerationStatus.loadingModels) {
return Center(
child: Container(
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: WebTheme.getCardColor(context),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: WebTheme.getShadowColor(context, opacity: 0.1),
blurRadius: 20,
offset: const Offset(0, 4),
),
],
),
child: const LoadingIndicator(message: '正在初始化...'),
),
);
}
// 主内容区域 - 左右分栏布局
return Container(
constraints: const BoxConstraints(
maxWidth: 1600, // 适应左右布局的更大宽度
),
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(
vertical: 32, // 顶部和底部的充足间距
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 页面标题区域
Container(
padding: const EdgeInsets.symmetric(horizontal: 24), // 标题区域添加内边距
child: _buildPageHeader(context),
),
const SizedBox(height: 32), // 标题与主内容的间距
// 左右分栏主内容区域
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 左侧栏 - 表单和AI模型列表
Expanded(
flex: 2, // 左侧占比
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 配置表单面板
Container(
width: double.infinity,
decoration: const BoxDecoration(),
child: ModernConfigCard(
chapters: state.chapters,
aiModelConfigs: state.aiModelConfigs,
startChapterId: state.startChapterId,
endChapterId: state.endChapterId,
numOptions: state.numOptions,
authorGuidance: state.authorGuidance,
isGenerating: state.generationStatus == GenerationStatus.generatingInitial ||
state.generationStatus == GenerationStatus.generatingSingle,
onStartChapterChanged: (chapterId) {
context.read<NextOutlineBloc>().add(
UpdateChapterRangeRequested(
startChapterId: chapterId,
endChapterId: state.endChapterId,
),
);
},
onEndChapterChanged: (chapterId) {
context.read<NextOutlineBloc>().add(
UpdateChapterRangeRequested(
startChapterId: state.startChapterId,
endChapterId: chapterId,
),
);
},
onNumOptionsChanged: (value) {
// 数量变更处理 - 暂存在本地,生成时更新状态
},
onAuthorGuidanceChanged: (value) {
// 引导变更处理 - 暂存在本地,生成时更新状态
},
onGenerate: (numOptions, authorGuidance, selectedConfigIds) {
final request = GenerateNextOutlinesRequest(
startChapterId: state.startChapterId,
endChapterId: state.endChapterId,
numOptions: numOptions,
authorGuidance: authorGuidance,
selectedConfigIds: _selectedConfigIds.isEmpty ? null : _selectedConfigIds,
);
context.read<NextOutlineBloc>().add(
GenerateNextOutlinesRequested(request: request),
);
},
onNavigateToAddModel: widget.onNavigateToAddModel,
onConfigureModel: widget.onConfigureModel,
),
),
const SizedBox(height: 24), // 表单与AI模型列表的间距
// AI模型列表区域
_buildAIModelList(context, state),
const SizedBox(height: 16),
// AI模型选择提示
_buildModelSelectionHints(context, state),
],
),
),
const SizedBox(width: 16), // 左右栏间距
// 右侧栏 - 生成结果展示
Expanded(
flex: 3, // 右侧占比更大,用于展示结果
child: Container(
width: double.infinity,
decoration: const BoxDecoration(),
padding: const EdgeInsets.all(24), // 内部间距
child: ResultsGrid(
outlineOptions: state.outlineOptions,
selectedOptionId: state.selectedOptionId,
aiModelConfigs: state.aiModelConfigs,
isGenerating: state.generationStatus == GenerationStatus.generatingInitial,
isSaving: state.generationStatus == GenerationStatus.saving,
onOptionSelected: (optionId) {
context.read<NextOutlineBloc>().add(
OutlineSelected(optionId: optionId),
);
},
onRegenerateSingle: (optionId, configId, hint) {
final request = RegenerateOptionRequest(
optionId: optionId,
selectedConfigId: configId,
regenerateHint: hint,
);
context.read<NextOutlineBloc>().add(
RegenerateSingleOutlineRequested(request: request),
);
},
onRegenerateAll: (hint) {
context.read<NextOutlineBloc>().add(
RegenerateAllOutlinesRequested(regenerateHint: hint),
);
},
onSaveOutline: (optionId, insertType) {
final request = SaveNextOutlineRequest(
outlineId: optionId,
insertType: insertType,
);
// 查找选中选项的索引
final selectedOptionIndex = state.outlineOptions.indexWhere(
(option) => option.optionId == optionId
);
context.read<NextOutlineBloc>().add(
SaveSelectedOutlineRequested(
request: request,
selectedOutlineIndex: selectedOptionIndex >= 0 ? selectedOptionIndex : null,
),
);
},
),
),
),
],
),
// 底部安全间距
const SizedBox(height: 32),
],
),
),
);
},
),
);
}
/// 构建页面标题区域(可选)
/// 提供视觉层次和上下文信息
Widget _buildPageHeader(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 主标题
Row(
children: [
Icon(
LucideIcons.brain_circuit,
size: 28,
color: WebTheme.getTextColor(context),
),
const SizedBox(width: 16),
Expanded(
child: Text(
'剧情推演',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: WebTheme.getTextColor(context),
fontWeight: FontWeight.w600,
height: 1.2,
),
),
),
],
),
const SizedBox(height: 8),
// 副标题/说明
Padding(
padding: const EdgeInsets.only(left: 44), // 与图标对齐
child: Text(
'为《${widget.novelTitle}》生成多个剧情发展选项,助力创作灵感',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: WebTheme.getSecondaryTextColor(context),
height: 1.4,
),
),
),
],
);
}
/// 构建AI模型列表区域
/// 独立的AI模型管理和选择界面
Widget _buildAIModelList(BuildContext context, NextOutlineState state) {
final allConfigs = state.aiModelConfigs;
return Container(
width: double.infinity,
decoration: BoxDecoration(
color: WebTheme.getCardColor(context),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: WebTheme.getShadowColor(context, opacity: 0.5),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题和操作按钮
Row(
children: [
Icon(
LucideIcons.list_checks,
size: 20,
color: WebTheme.getTextColor(context),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'AI 模型选择',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: WebTheme.getTextColor(context),
),
),
),
if (widget.onNavigateToAddModel != null)
TextButton.icon(
icon: Icon(
LucideIcons.plus,
size: 16,
color: WebTheme.getTextColor(context),
),
label: Text(
'添加',
style: TextStyle(
fontSize: 12,
color: WebTheme.getTextColor(context),
),
),
onPressed: widget.onNavigateToAddModel,
style: WebTheme.getSecondaryButtonStyle(context).copyWith(
padding: MaterialStateProperty.all(
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
),
minimumSize: MaterialStateProperty.all(Size.zero),
),
),
],
),
const SizedBox(height: 8),
// 副标题说明
Text(
'选择用于生成的AI模型',
style: TextStyle(
fontSize: 14,
color: WebTheme.getSecondaryTextColor(context),
),
),
const SizedBox(height: 16),
// 模型列表
if (allConfigs.isEmpty)
_buildEmptyModelState(context)
else
_buildModelList(context, allConfigs),
],
),
);
}
/// 构建空模型状态
Widget _buildEmptyModelState(BuildContext context) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: WebTheme.getEmptyStateColor(context),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: WebTheme.getBorderColor(context),
width: 1,
),
),
child: Column(
children: [
Icon(
LucideIcons.info,
size: 32,
color: WebTheme.getSecondaryTextColor(context),
),
const SizedBox(height: 12),
Text(
'暂无可用模型',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: WebTheme.getTextColor(context),
),
),
const SizedBox(height: 8),
Text(
'请添加和配置AI模型服务',
style: TextStyle(
fontSize: 14,
color: WebTheme.getSecondaryTextColor(context),
),
textAlign: TextAlign.center,
),
],
),
);
}
/// 构建模型列表
Widget _buildModelList(BuildContext context, List<UserAIModelConfigModel> configs) {
return Container(
decoration: BoxDecoration(
border: Border.all(
color: WebTheme.getBorderColor(context),
width: 1,
),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: configs.asMap().entries.map((entry) {
final index = entry.key;
final config = entry.value;
final isSelected = _selectedConfigIds.contains(config.id);
final isValidated = config.isValidated;
final isLast = index == configs.length - 1;
return Container(
decoration: BoxDecoration(
border: isLast ? null : Border(
bottom: BorderSide(
color: WebTheme.getBorderColor(context),
width: 1,
),
),
),
child: isValidated
? _buildValidatedModelItem(context, config, isSelected)
: _buildUnvalidatedModelItem(context, config),
);
}).toList(),
),
);
}
/// 构建已验证的模型项 - 支持多选
Widget _buildValidatedModelItem(BuildContext context, UserAIModelConfigModel config, bool isSelected) {
return CheckboxListTile(
title: Text(
config.name,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: WebTheme.getTextColor(context),
),
),
subtitle: Text(
'已验证可用',
style: TextStyle(
fontSize: 12,
color: WebTheme.success,
),
),
value: isSelected,
onChanged: (selected) {
setState(() {
if (selected == true) {
_selectedConfigIds.add(config.id);
} else {
_selectedConfigIds.remove(config.id);
}
});
},
secondary: Icon(
_getIconForModel(config.name),
color: isSelected
? WebTheme.getTextColor(context)
: WebTheme.getSecondaryTextColor(context),
size: 20,
),
controlAffinity: ListTileControlAffinity.leading,
activeColor: WebTheme.getTextColor(context),
checkColor: WebTheme.getCardColor(context),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
);
}
/// 构建未验证的模型项
Widget _buildUnvalidatedModelItem(BuildContext context, UserAIModelConfigModel config) {
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
leading: Icon(
_getIconForModel(config.name),
color: WebTheme.getSecondaryTextColor(context),
size: 20,
),
title: Text(
config.name,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: WebTheme.getSecondaryTextColor(context),
fontStyle: FontStyle.italic,
),
),
subtitle: Text(
'需要配置验证',
style: TextStyle(
fontSize: 12,
color: WebTheme.warning,
),
),
trailing: widget.onConfigureModel != null
? OutlinedButton(
onPressed: () => widget.onConfigureModel!(config.id),
style: WebTheme.getSecondaryButtonStyle(context).copyWith(
padding: MaterialStateProperty.all(
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
),
minimumSize: MaterialStateProperty.all(Size.zero),
),
child: Text(
'配置',
style: TextStyle(
fontSize: 12,
color: WebTheme.getTextColor(context),
),
),
)
: null,
enabled: false,
);
}
/// 构建模型选择提示信息
Widget _buildModelSelectionHints(BuildContext context, NextOutlineState state) {
if (_selectedConfigIds.isEmpty) {
return _buildHintBox(
context,
'请至少选择一个AI模型',
LucideIcons.circle_alert,
WebTheme.error,
);
} else if (_selectedConfigIds.length < state.numOptions) {
return _buildHintBox(
context,
'注意:选择的模型数量少于生成数量,部分模型将被重复使用',
LucideIcons.info,
WebTheme.warning,
);
}
return const SizedBox.shrink();
}
/// 构建提示框组件
Widget _buildHintBox(BuildContext context, String message, IconData icon, Color color) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: color.withOpacity(0.3),
width: 1,
),
),
child: Row(
children: [
Icon(
icon,
size: 16,
color: color,
),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
style: TextStyle(
fontSize: 12,
color: color,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
/// 根据模型名称获取对应的图标
IconData _getIconForModel(String modelName) {
final lowerName = modelName.toLowerCase();
if (lowerName.contains('gpt') || lowerName.contains('openai')) {
return LucideIcons.gem;
} else if (lowerName.contains('claude')) {
return LucideIcons.search_code;
} else if (lowerName.contains('gemini') || lowerName.contains('bard')) {
return LucideIcons.brain_circuit;
} else if (lowerName.contains('llama') || lowerName.contains('meta')) {
return LucideIcons.flask_conical;
} else if (lowerName.contains('mistral') || lowerName.contains('mixtral')) {
return LucideIcons.zap;
}
return LucideIcons.cpu; // 默认图标
}
}

View File

@@ -0,0 +1,72 @@
import 'package:ainoval/screens/next_outline/next_outline_screen.dart';
import 'package:ainoval/utils/web_theme.dart';
import 'package:flutter/material.dart';
/// 剧情推演视图 - 全局通用组件
///
/// 此组件作为剧情推演功能的顶级容器,负责:
/// 1. 提供统一的主题样式和布局约束
/// 2. 在编辑器中嵌入剧情推演功能
/// 3. 管理与外部组件的交互回调
///
/// 设计原则:
/// - 使用纯黑白配色方案,保持现代简洁的视觉风格
/// - 采用全局主题WebTheme进行样式统一
/// - 提供合理的布局间距,避免界面拥挤或臃肿
/// - 支持响应式设计,适配不同屏幕尺寸
class NextOutlineView extends StatelessWidget {
/// 小说ID - 用于标识当前编辑的小说
final String novelId;
/// 小说标题 - 用于显示上下文信息
final String novelTitle;
/// 切换到写作模式回调 - 用于在推演完成后返回编辑器
final VoidCallback onSwitchToWrite;
/// 跳转到添加模型页面的回调 - 用于配置AI模型
final VoidCallback? onNavigateToAddModel;
/// 跳转到配置特定模型页面的回调 - 用于模型参数调整
final Function(String configId)? onConfigureModel;
const NextOutlineView({
Key? key,
required this.novelId,
required this.novelTitle,
required this.onSwitchToWrite,
this.onNavigateToAddModel,
this.onConfigureModel,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
// 使用主题定义的纯净背景色,确保视觉统一
decoration: BoxDecoration(
color: WebTheme.getSurfaceColor(context),
),
child: Column(
children: [
// 主内容区域 - 使用Expanded确保占据所有可用空间
Expanded(
child: Container(
// 设置最大宽度,防止在超宽屏幕上内容过于分散
constraints: const BoxConstraints(
maxWidth: 1400, // 合理的最大宽度约束
),
margin: const EdgeInsets.symmetric(horizontal: 16), // 左右边距
child: NextOutlineScreen(
novelId: novelId,
novelTitle: novelTitle,
onSwitchToWrite: onSwitchToWrite,
onNavigateToAddModel: onNavigateToAddModel,
onConfigureModel: onConfigureModel,
),
),
),
],
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,468 @@
import 'package:ainoval/blocs/next_outline/next_outline_state.dart';
import 'package:ainoval/utils/web_theme.dart';
import '../../../models/user_ai_model_config_model.dart';
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
/// 现代化剧情推演结果卡片 - 全局通用组件
///
/// 此组件负责展示单个剧情推演结果:
/// 1. 内容展示 - 显示生成的剧情内容
/// 2. 状态指示 - 加载、完成、选中状态
/// 3. 操作控件 - 选择、重新生成、保存等
/// 4. 交互反馈 - 悬停效果和状态变化
///
/// 设计特点:
/// - 采用纯黑白配色方案,保持视觉一致性
/// - 现代化的卡片设计和微交互
/// - 清晰的信息层次和操作引导
/// - 优化的间距和组件尺寸
class ModernResultCard extends StatefulWidget {
/// 剧情选项数据
final OutlineOptionState option;
/// 是否被选中
final bool isSelected;
/// AI模型配置列表 - 用于重新生成操作
final List<UserAIModelConfigModel> aiModelConfigs;
/// 选中回调
final VoidCallback onSelected;
/// 重新生成回调
final Function(String configId, String? hint) onRegenerateSingle;
/// 保存回调
final Function(String insertType) onSave;
const ModernResultCard({
Key? key,
required this.option,
this.isSelected = false,
required this.aiModelConfigs,
required this.onSelected,
required this.onRegenerateSingle,
required this.onSave,
}) : super(key: key);
@override
State<ModernResultCard> createState() => _ModernResultCardState();
}
/// 结果卡片状态管理
///
/// 负责:
/// 1. 悬停状态管理
/// 2. 模型选择状态
/// 3. 交互动画控制
/// 4. 用户操作处理
class _ModernResultCardState extends State<ModernResultCard> {
String? _selectedConfigId;
bool _isHovering = false;
@override
void initState() {
super.initState();
// 默认选择第一个已验证的模型配置
final validatedConfigs = widget.aiModelConfigs
.where((config) => config.isValidated)
.toList();
if (validatedConfigs.isNotEmpty) {
_selectedConfigId = validatedConfigs.first.id;
}
}
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => setState(() => _isHovering = true),
onExit: (_) => setState(() => _isHovering = false),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
transform: _isHovering
? (Matrix4.identity()..translate(0, -2))
: Matrix4.identity(),
child: Container(
height: double.infinity,
decoration: BoxDecoration(
color: WebTheme.getCardColor(context),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: widget.isSelected
? WebTheme.getTextColor(context)
: _isHovering
? WebTheme.getSecondaryTextColor(context)
: WebTheme.getBorderColor(context),
width: widget.isSelected ? 2 : 1,
),
boxShadow: [
if (_isHovering || widget.isSelected)
BoxShadow(
color: WebTheme.getShadowColor(context, opacity: 0.3),
blurRadius: widget.isSelected ? 12 : 8,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: [
// 内容区域
Expanded(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题区域
_buildTitleSection(context),
const SizedBox(height: 16),
// 内容区域
Expanded(
child: _buildContentSection(context),
),
],
),
),
),
// 底部操作区域
_buildActionSection(context),
],
),
),
),
);
}
/// 构建标题区域
Widget _buildTitleSection(BuildContext context) {
return Row(
children: [
// 状态指示器
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: widget.option.isGenerating
? WebTheme.warning
: widget.isSelected
? WebTheme.getTextColor(context)
: WebTheme.success,
),
),
const SizedBox(width: 12),
// 标题文本
Expanded(
child: Text(
widget.option.title ?? '生成中...',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: WebTheme.getTextColor(context),
fontWeight: FontWeight.w600,
fontSize: 20,
height: 1.2,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
// 选中指示器
if (widget.isSelected)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: WebTheme.getTextColor(context),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'已选择',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: WebTheme.getCardColor(context),
),
),
),
],
);
}
/// 构建内容区域
Widget _buildContentSection(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: WebTheme.getEmptyStateColor(context),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: WebTheme.getBorderColor(context),
width: 1,
),
),
child: ValueListenableBuilder<String>(
valueListenable: widget.option.contentStreamController,
builder: (context, content, child) {
// 生成中状态
if (content.isEmpty && widget.option.isGenerating) {
return _buildLoadingContent(context);
}
// 内容展示
return _buildTextContent(context, content);
},
),
);
}
/// 构建加载内容
Widget _buildLoadingContent(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: WebTheme.getSecondaryTextColor(context),
),
),
const SizedBox(height: 12),
Text(
'正在生成内容...',
style: TextStyle(
fontSize: 12,
color: WebTheme.getSecondaryTextColor(context),
fontWeight: FontWeight.w500,
),
),
],
),
);
}
/// 构建文本内容
Widget _buildTextContent(BuildContext context, String content) {
return SingleChildScrollView(
child: Text(
content.isEmpty ? '暂无内容' : content,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontSize: 20,
height: 2.0,
color: content.isEmpty
? WebTheme.getSecondaryTextColor(context)
: WebTheme.getTextColor(context),
),
),
);
}
/// 构建操作区域
Widget _buildActionSection(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: WebTheme.getEmptyStateColor(context),
border: Border(
top: BorderSide(
color: WebTheme.getBorderColor(context),
width: 1,
),
),
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(12),
bottomRight: Radius.circular(12),
),
),
child: Column(
children: [
// 模型选择和重新生成
Row(
children: [
// 模型选择下拉框
Expanded(
child: _buildModelSelector(context),
),
const SizedBox(width: 12),
// 重新生成按钮
_buildRegenerateButton(context),
],
),
const SizedBox(height: 12),
// 主要操作按钮
_buildMainActionButton(context),
],
),
);
}
/// 构建模型选择器
Widget _buildModelSelector(BuildContext context) {
final validatedConfigs = widget.aiModelConfigs
.where((config) => config.isValidated)
.toList();
if (validatedConfigs.isEmpty) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: WebTheme.getCardColor(context),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: WebTheme.getBorderColor(context),
width: 1,
),
),
child: Text(
'无可用模型',
style: TextStyle(
fontSize: 12,
color: WebTheme.getSecondaryTextColor(context),
),
),
);
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: WebTheme.getCardColor(context),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: WebTheme.getBorderColor(context),
width: 1,
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _selectedConfigId,
items: validatedConfigs.map((config) {
return DropdownMenuItem<String>(
value: config.id,
child: Text(
config.name,
style: TextStyle(
fontSize: 12,
color: WebTheme.getTextColor(context),
),
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_selectedConfigId = value;
});
}
},
isDense: true,
isExpanded: true,
icon: Icon(
LucideIcons.chevron_down,
size: 14,
color: WebTheme.getSecondaryTextColor(context),
),
dropdownColor: WebTheme.getCardColor(context),
),
),
);
}
/// 构建重新生成按钮
Widget _buildRegenerateButton(BuildContext context) {
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: WebTheme.getBorderColor(context),
width: 1,
),
),
child: IconButton(
icon: Icon(
LucideIcons.refresh_cw,
size: 16,
color: widget.option.isGenerating || _selectedConfigId == null
? WebTheme.getSecondaryTextColor(context)
: WebTheme.getTextColor(context),
),
tooltip: '重新生成',
onPressed: widget.option.isGenerating || _selectedConfigId == null
? null
: () => widget.onRegenerateSingle(_selectedConfigId!, null),
padding: const EdgeInsets.all(8),
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
),
);
}
/// 构建主要操作按钮
Widget _buildMainActionButton(BuildContext context) {
if (widget.option.isGenerating) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: WebTheme.getEmptyStateColor(context),
borderRadius: BorderRadius.circular(6),
),
child: Center(
child: Text(
'生成中...',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: WebTheme.getSecondaryTextColor(context),
),
),
),
);
}
return SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: widget.onSelected,
style: widget.isSelected
? WebTheme.getPrimaryButtonStyle(context).copyWith(
backgroundColor: MaterialStateProperty.all(WebTheme.getTextColor(context)),
padding: MaterialStateProperty.all(
const EdgeInsets.symmetric(vertical: 12),
),
)
: WebTheme.getSecondaryButtonStyle(context).copyWith(
padding: MaterialStateProperty.all(
const EdgeInsets.symmetric(vertical: 12),
),
),
child: Text(
widget.isSelected ? '已选择' : '选择此大纲',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: widget.isSelected
? WebTheme.getCardColor(context)
: WebTheme.getTextColor(context),
),
),
),
);
}
}

View File

@@ -0,0 +1,890 @@
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:ainoval/utils/web_theme.dart';
import '../../../models/novel_structure.dart';
import '../../../models/user_ai_model_config_model.dart';
/// 剧情大纲生成配置卡片 - 全局通用组件
///
/// 此组件负责剧情推演的参数配置功能:
/// 1. 章节范围选择 - 确定推演的上下文范围
/// 2. AI模型配置 - 选择和管理生成模型
/// 3. 生成参数设置 - 选项数量、作者引导等
/// 4. 配置验证和错误提示
///
/// 设计特点:
/// - 采用纯黑白配色方案,符合现代简洁审美
/// - 响应式布局,适配宽屏和窄屏设备
/// - 统一的视觉层次和组件间距
/// - 清晰的信息架构和用户引导
class OutlineGenerationConfigCard extends StatefulWidget {
/// 章节列表
final List<Chapter> chapters;
/// AI模型配置列表
final List<UserAIModelConfigModel> aiModelConfigs;
/// 当前选中的上下文开始章节ID
final String? startChapterId;
/// 当前选中的上下文结束章节ID
final String? endChapterId;
/// 生成选项数量
final int numOptions;
/// 作者引导
final String? authorGuidance;
/// 是否正在生成
final bool isGenerating;
/// 开始章节变更回调
final Function(String?) onStartChapterChanged;
/// 结束章节变更回调
final Function(String?) onEndChapterChanged;
/// 选项数量变更回调
final Function(int) onNumOptionsChanged;
/// 作者引导变更回调
final Function(String?) onAuthorGuidanceChanged;
/// 生成回调
final Function(int numOptions, String? authorGuidance, List<String>? selectedConfigIds) onGenerate;
/// 跳转到添加模型页面的回调
final VoidCallback? onNavigateToAddModel;
/// 跳转到配置特定模型页面的回调
final Function(String configId)? onConfigureModel;
const OutlineGenerationConfigCard({
Key? key,
required this.chapters,
required this.aiModelConfigs,
this.startChapterId,
this.endChapterId,
this.numOptions = 3,
this.authorGuidance,
this.isGenerating = false,
required this.onStartChapterChanged,
required this.onEndChapterChanged,
required this.onNumOptionsChanged,
required this.onAuthorGuidanceChanged,
required this.onGenerate,
this.onNavigateToAddModel,
this.onConfigureModel,
}) : super(key: key);
@override
State<OutlineGenerationConfigCard> createState() => _OutlineGenerationConfigCardState();
}
class _OutlineGenerationConfigCardState extends State<OutlineGenerationConfigCard> {
late int _numOptions;
late TextEditingController _authorGuidanceController;
List<String> _selectedConfigIds = [];
String? _chapterRangeError;
@override
void initState() {
super.initState();
_numOptions = widget.numOptions;
_authorGuidanceController = TextEditingController(text: widget.authorGuidance);
// 默认选择第一个模型配置
if (widget.aiModelConfigs.isNotEmpty) {
_selectedConfigIds = [widget.aiModelConfigs.first.id];
}
// 初始化时验证章节范围
_validateChapterRange(widget.startChapterId, widget.endChapterId);
}
@override
void didUpdateWidget(OutlineGenerationConfigCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.authorGuidance != widget.authorGuidance) {
_authorGuidanceController.text = widget.authorGuidance ?? '';
}
if (oldWidget.numOptions != widget.numOptions) {
_numOptions = widget.numOptions;
}
// 当起止章节ID变化时验证范围
if (oldWidget.startChapterId != widget.startChapterId ||
oldWidget.endChapterId != widget.endChapterId) {
_validateChapterRange(widget.startChapterId, widget.endChapterId);
}
}
/// 验证章节范围,确保开始章节不晚于结束章节
void _validateChapterRange(String? startId, String? endId) {
setState(() {
_chapterRangeError = null;
if (startId != null && endId != null && widget.chapters.isNotEmpty) {
// 查找章节索引
int? startIndex;
int? endIndex;
for (int i = 0; i < widget.chapters.length; i++) {
if (widget.chapters[i].id == startId) {
startIndex = i;
}
if (widget.chapters[i].id == endId) {
endIndex = i;
}
// 如果两个索引都找到了,可以提前结束循环
if (startIndex != null && endIndex != null) {
break;
}
}
// 检查有效性
if (startIndex != null && endIndex != null && startIndex > endIndex) {
_chapterRangeError = '起始章节不能晚于结束章节';
}
}
});
}
@override
void dispose() {
_authorGuidanceController.dispose();
super.dispose();
}
// --- 新增:根据模型名称获取图标 ---
IconData _getIconForModel(String modelName) {
final lowerCaseName = modelName.toLowerCase();
if (lowerCaseName.contains('gemini')) {
return LucideIcons.gem;
} else if (lowerCaseName.contains('deepseek')) {
return LucideIcons.search_code;
} else if (lowerCaseName.contains('gpt') || lowerCaseName.contains('openai')) {
return LucideIcons.brain_circuit;
} else if (lowerCaseName.contains('beta') || lowerCaseName.contains('test')) {
return LucideIcons.flask_conical;
} else if (lowerCaseName.contains('flash') || lowerCaseName.contains('fast')) {
return LucideIcons.zap;
}
return LucideIcons.cpu; // 默认图标
}
// --- 结束新增 ---
@override
Widget build(BuildContext context) {
// 检查生成按钮是否应该禁用
final bool isGenerateButtonDisabled = widget.isGenerating ||
_selectedConfigIds.isEmpty ||
_chapterRangeError != null;
return Container(
padding: const EdgeInsets.all(32), // 统一的内边距
child: LayoutBuilder(
builder: (context, constraints) {
// 响应式布局判断
final isWideScreen = constraints.maxWidth >= 960;
return isWideScreen
? _buildWideLayout(context, isGenerateButtonDisabled, constraints)
: _buildNarrowLayout(context, isGenerateButtonDisabled);
},
),
);
}
/// 宽屏布局AI模型配置显示在右侧
/// 充分利用宽屏空间,提供更好的信息组织
Widget _buildWideLayout(BuildContext context, bool isGenerateButtonDisabled, BoxConstraints constraints) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 左侧:主要配置区域
Expanded(
flex: 3,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题区域
Text(
'生成配置',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: WebTheme.getTextColor(context),
),
),
const SizedBox(height: 24),
// 章节配置字段
_buildChapterConfigFields(constraints.maxWidth * 0.6, WebTheme.getTextColor(context)),
// 章节范围验证错误提示
if (_chapterRangeError != null)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: WebTheme.error.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: WebTheme.error.withOpacity(0.3),
width: 1,
),
),
child: Row(
children: [
Icon(
LucideIcons.circle_alert,
size: 16,
color: WebTheme.error,
),
const SizedBox(width: 8),
Text(
_chapterRangeError!,
style: TextStyle(
color: WebTheme.error,
fontSize: 14,
),
),
],
),
),
),
const SizedBox(height: 24),
// 作者引导输入区域
_buildAuthorGuidanceField(WebTheme.getTextColor(context)),
const SizedBox(height: 32),
// 生成按钮
Align(
alignment: Alignment.centerRight,
child: _buildGenerateButton(isGenerateButtonDisabled, WebTheme.getTextColor(context)),
),
],
),
),
const SizedBox(width: 40), // 左右区域间距
// 右侧AI模型选择区域
if (widget.aiModelConfigs.isNotEmpty)
Expanded(
flex: 2,
child: _buildAIModelSelection(true, WebTheme.getTextColor(context)),
),
],
);
}
/// 窄屏布局AI模型配置显示在下方
Widget _buildNarrowLayout(BuildContext context, bool isGenerateButtonDisabled) {
final Color primaryColor = Colors.indigo; // 定义主色调为靛蓝色
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'生成选项',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: primaryColor, // 使用靛蓝色
),
),
const SizedBox(height: 20),
_buildChapterConfigFields(double.infinity, primaryColor),
if (_chapterRangeError != null)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
_chapterRangeError!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 14,
),
),
),
const SizedBox(height: 24),
_buildAuthorGuidanceField(primaryColor),
const SizedBox(height: 24),
if (widget.aiModelConfigs.isNotEmpty)
_buildAIModelSelection(false, primaryColor),
const SizedBox(height: 32),
Align(
alignment: Alignment.centerRight,
child: _buildGenerateButton(isGenerateButtonDisabled, primaryColor),
),
],
);
}
/// 构建章节配置区域
Widget _buildChapterConfigFields(double totalWidth, Color primaryColor) {
return Wrap(
spacing: 16,
runSpacing: 16,
children: [
SizedBox(
width: totalWidth < 600 ? totalWidth : (totalWidth - 32) / 3,
child: _buildStartChapterDropdown(primaryColor),
),
SizedBox(
width: totalWidth < 600 ? totalWidth : (totalWidth - 32) / 3,
child: _buildEndChapterDropdown(primaryColor),
),
SizedBox(
width: totalWidth < 600 ? totalWidth : (totalWidth - 32) / 3,
child: _buildNumOptionsDropdown(primaryColor),
),
],
);
}
/// 构建生成按钮
Widget _buildGenerateButton(bool isDisabled, Color primaryColor) {
return ElevatedButton.icon(
onPressed: isDisabled
? null
: () {
widget.onGenerate(
_numOptions,
_authorGuidanceController.text.isEmpty ? null : _authorGuidanceController.text,
_selectedConfigIds.isEmpty ? null : _selectedConfigIds,
);
},
icon: widget.isGenerating
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
color: Colors.white, // 加载时图标颜色为白色
strokeWidth: 2,
),
)
: const Icon(LucideIcons.brain_circuit, size: 20),
label: Text(
widget.isGenerating ? '生成中...' : '生成剧情大纲',
style: const TextStyle(fontWeight: FontWeight.bold),
),
style: ElevatedButton.styleFrom(
backgroundColor: primaryColor, // 按钮背景色为靛蓝色
foregroundColor: Colors.white, // 按钮文字和图标颜色为白色
disabledBackgroundColor: primaryColor.withOpacity(0.5),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
);
}
/// 构建上下文开始章节下拉框
Widget _buildStartChapterDropdown(Color primaryColor) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
LucideIcons.book_copy, // 更换图标
size: 18,
color: primaryColor,
),
const SizedBox(width: 8),
const Text(
'上下文开始章节',
style: TextStyle(
fontWeight: FontWeight.w500,
),
),
],
),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
value: widget.startChapterId,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: primaryColor), // 聚焦时边框颜色
),
filled: true,
fillColor: Colors.grey.shade50,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
isDense: true,
),
items: widget.chapters.map((chapter) {
return DropdownMenuItem<String>(
value: chapter.id,
child: Text(
chapter.title,
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
onChanged: (value) {
widget.onStartChapterChanged(value);
},
hint: const Text('选择开始章节'),
isExpanded: true,
icon: const Icon(LucideIcons.chevron_down, size: 20), // 更换图标
dropdownColor: Colors.white,
),
const SizedBox(height: 6),
Text(
'选择剧情上下文的起始章节',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
],
);
}
/// 构建上下文结束章节下拉框
Widget _buildEndChapterDropdown(Color primaryColor) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
LucideIcons.book_marked, // 更换图标
size: 18,
color: primaryColor,
),
const SizedBox(width: 8),
const Text(
'上下文结束章节',
style: TextStyle(
fontWeight: FontWeight.w500,
),
),
],
),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
value: widget.endChapterId,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: primaryColor), // 聚焦时边框颜色
),
filled: true,
fillColor: Colors.grey.shade50,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
isDense: true,
),
items: widget.chapters.map((chapter) {
return DropdownMenuItem<String>(
value: chapter.id,
child: Text(
chapter.title,
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
onChanged: (value) {
widget.onEndChapterChanged(value);
},
hint: const Text('选择结束章节'),
isExpanded: true,
icon: const Icon(LucideIcons.chevron_down, size: 20), // 更换图标
dropdownColor: Colors.white,
),
const SizedBox(height: 6),
Text(
'选择剧情上下文的结束章节',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
],
);
}
/// 构建生成选项数量下拉框
Widget _buildNumOptionsDropdown(Color primaryColor) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
LucideIcons.list_ordered, // 更换图标
size: 18,
color: primaryColor,
),
const SizedBox(width: 8),
const Text(
'生成选项数量',
style: TextStyle(
fontWeight: FontWeight.w500,
),
),
],
),
const SizedBox(height: 8),
DropdownButtonFormField<int>(
value: _numOptions,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: primaryColor), // 聚焦时边框颜色
),
filled: true,
fillColor: Colors.grey.shade50,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
isDense: true,
),
items: [2, 3, 4, 5].map((number) {
return DropdownMenuItem<int>(
value: number,
child: Text('$number'),
);
}).toList(),
onChanged: widget.isGenerating
? null
: (value) {
if (value != null) {
setState(() {
_numOptions = value;
});
widget.onNumOptionsChanged(value);
}
},
isExpanded: true,
icon: const Icon(LucideIcons.chevron_down, size: 20), // 更换图标
dropdownColor: Colors.white,
),
],
);
}
/// 构建作者引导文本框
Widget _buildAuthorGuidanceField(Color primaryColor) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
LucideIcons.lightbulb, // 更换图标
size: 18,
color: primaryColor,
),
const SizedBox(width: 8),
const Text(
'作者偏好/引导 (可选)',
style: TextStyle(
fontWeight: FontWeight.w500,
),
),
],
),
const SizedBox(height: 10),
TextField(
controller: _authorGuidanceController,
enabled: !widget.isGenerating,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: primaryColor), // 聚焦时边框颜色
),
filled: true,
fillColor: Colors.grey.shade50,
hintText: '例如希望侧重角色A的成长引入新的反派避免涉及魔法元素...',
hintStyle: TextStyle(color: Colors.grey.shade500, fontSize: 13),
contentPadding: const EdgeInsets.all(16),
),
style: const TextStyle(height: 1.5),
maxLines: 3,
onChanged: widget.onAuthorGuidanceChanged,
),
const SizedBox(height: 8),
Row(
children: [
Icon(
LucideIcons.info, // 更换图标
size: 14,
color: Colors.grey.shade600,
),
const SizedBox(width: 6),
Text(
'告诉 AI 您对下一段剧情的期望或限制',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
],
),
],
);
}
/// 构建AI模型选择器 (列表形式)
Widget _buildAIModelSelection(bool isWideScreen, Color primaryColor) {
// --- 不再过滤,使用全部模型 ---
final allConfigs = widget.aiModelConfigs;
// 如果模型列表为空
if (allConfigs.isEmpty) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
LucideIcons.list_checks,
size: 18,
color: primaryColor.withOpacity(0.6),
),
const SizedBox(width: 8),
const Text(
'AI 模型选择',
style: TextStyle(
fontWeight: FontWeight.w500,
),
),
const Spacer(),
if (widget.onNavigateToAddModel != null)
TextButton.icon(
icon: const Icon(Icons.add_circle_outline, size: 16),
label: const Text('添加模型', style: TextStyle(fontSize: 12)),
onPressed: widget.onNavigateToAddModel,
style: TextButton.styleFrom(padding: EdgeInsets.zero)
)
],
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(LucideIcons.info, size: 14, color: Colors.grey.shade600),
const SizedBox(width: 8),
Expanded(
child: Text(
'没有配置任何模型。请前往设置页面添加模型服务。',
style: TextStyle(fontSize: 12, color: Colors.grey.shade700),
),
),
],
),
)
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
LucideIcons.list_checks,
size: 18,
color: primaryColor,
),
const SizedBox(width: 8),
const Text(
'AI 模型选择',
style: TextStyle(
fontWeight: FontWeight.w500,
),
),
const Spacer(),
if (widget.onNavigateToAddModel != null)
TextButton.icon(
icon: const Icon(Icons.add_circle_outline, size: 16),
label: const Text('添加模型', style: TextStyle(fontSize: 12)),
onPressed: widget.onNavigateToAddModel,
style: TextButton.styleFrom(padding: EdgeInsets.zero)
)
],
),
const SizedBox(height: 12),
// 模型列表 - 显示所有,区分已验证和未验证
Column(
children: allConfigs.map((config) { // <-- 使用全部列表
final isSelected = _selectedConfigIds.contains(config.id);
final isValidated = config.isValidated;
final iconColor = isSelected ? primaryColor : (isValidated ? Colors.grey.shade700 : Colors.grey.shade400);
final textColor = isValidated ? Theme.of(context).textTheme.bodyLarge?.color : Colors.grey.shade500;
// --- 如果已验证 ---
if (isValidated) {
return CheckboxListTile(
title: Text(config.name, style: TextStyle(color: textColor)),
value: isSelected,
onChanged: widget.isGenerating
? null
: (selected) {
setState(() {
if (selected == true) {
_selectedConfigIds.add(config.id);
} else {
_selectedConfigIds.remove(config.id);
}
});
},
secondary: Icon(
_getIconForModel(config.name),
color: iconColor,
),
controlAffinity: ListTileControlAffinity.leading,
activeColor: primaryColor,
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
);
}
// --- 如果未验证 ---
else {
return ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
leading: Icon( // 使用 CheckboxListTile 的 secondary 占位,保持对齐
_getIconForModel(config.name),
color: iconColor,
),
title: Text(config.name, style: TextStyle(color: textColor, fontStyle: FontStyle.italic)),
subtitle: Text('未验证', style: TextStyle(fontSize: 11, color: Colors.orange.shade700)),
trailing: OutlinedButton.icon( // 添加配置按钮
icon: const Icon(Icons.settings_outlined, size: 14),
label: const Text('前往配置', style: TextStyle(fontSize: 11)),
onPressed: () {
if (widget.onConfigureModel != null) {
widget.onConfigureModel!(config.id);
}
},
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
side: BorderSide(color: Colors.grey.shade300),
visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
enabled: false, // 整体禁用 ListTile 的交互
);
}
}).toList(),
),
// 提示信息 (保持不变)
if (_selectedConfigIds.isEmpty)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
children: [
Icon(
LucideIcons.circle_alert, // 更换图标
size: 16,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(width: 8),
Text(
'请至少选择一个 AI 模型',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
),
],
),
)
else if (_selectedConfigIds.length < _numOptions)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
children: [
Icon(
LucideIcons.circle_alert, // 更换图标
size: 16,
color: Colors.amber.shade800,
),
const SizedBox(width: 8),
Text(
'注意:部分模型将被重复使用',
style: TextStyle(
color: Colors.amber.shade800,
fontSize: 12,
),
),
],
),
),
],
);
}
}

View File

@@ -0,0 +1,308 @@
import 'package:ainoval/blocs/next_outline/next_outline_state.dart';
import '../../../models/novel_structure.dart';
import '../../../models/user_ai_model_config_model.dart';
import 'package:flutter/material.dart';
import 'package:animated_text_kit/animated_text_kit.dart';
/// 结果卡片
class ResultCard extends StatefulWidget {
/// 剧情选项
final OutlineOptionState option;
/// 是否被选中
final bool isSelected;
/// AI模型配置列表
final List<UserAIModelConfigModel> aiModelConfigs;
/// 选中回调
final VoidCallback onSelected;
/// 重新生成回调
final Function(String configId, String? hint) onRegenerateSingle;
/// 保存回调
final Function(String insertType) onSave;
const ResultCard({
Key? key,
required this.option,
this.isSelected = false,
required this.aiModelConfigs,
required this.onSelected,
required this.onRegenerateSingle,
required this.onSave,
}) : super(key: key);
@override
State<ResultCard> createState() => _ResultCardState();
}
class _ResultCardState extends State<ResultCard> {
String? _selectedConfigId;
bool _isHovering = false;
@override
void initState() {
super.initState();
// 默认选择第一个模型配置
if (widget.aiModelConfigs.isNotEmpty) {
_selectedConfigId = widget.aiModelConfigs.first.id;
}
}
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => setState(() => _isHovering = true),
onExit: (_) => setState(() => _isHovering = false),
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
transform: _isHovering
? (Matrix4.identity()..translate(0, -4))
: Matrix4.identity(),
child: Card(
clipBehavior: Clip.antiAlias,
elevation: _isHovering || widget.isSelected ? 8.0 : 2.0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: widget.isSelected
? BorderSide(color: Theme.of(context).colorScheme.primary, width: 2)
: _isHovering
? BorderSide(color: Theme.of(context).colorScheme.primary.withAlpha(128), width: 1.5)
: BorderSide.none,
),
child: Stack(
children: [
// 卡片内容
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 内容区域
Expanded(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题
Row(
children: [
Icon(
Icons.auto_stories,
size: 20,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
widget.option.title ?? '生成中...',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 16),
// 内容
Expanded(
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade200),
),
child: ValueListenableBuilder<String>(
valueListenable: widget.option.contentStreamController,
builder: (context, content, child) {
if (content.isEmpty && widget.option.isGenerating) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5),
),
),
const SizedBox(height: 16),
Text(
'正在生成内容...',
style: TextStyle(
color: Colors.grey.shade600,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
return SingleChildScrollView(
child: AnimatedTextKit(
animatedTexts: [
TypewriterAnimatedText(
content,
textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
height: 1.6,
color: Colors.grey.shade800,
),
speed: const Duration(milliseconds: 40),
),
],
isRepeatingAnimation: false,
displayFullTextOnTap: true,
key: ValueKey(widget.option.optionId + content),
)
);
},
),
),
),
],
),
),
),
// 底部操作区
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.shade50,
border: Border(
top: BorderSide(
color: Colors.grey.shade200,
width: 1,
),
),
),
child: Row(
children: [
// 模型选择下拉框
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _selectedConfigId,
items: widget.aiModelConfigs
.where((config) => config.isValidated)
.map((config) {
return DropdownMenuItem<String>(
value: config.id,
child: Text(
config.name,
style: const TextStyle(fontSize: 13),
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
onChanged: (value) {
if (value != null) {
if (widget.aiModelConfigs.any((c) => c.isValidated && c.id == value)) {
setState(() {
_selectedConfigId = value;
});
}
}
},
isDense: true,
isExpanded: true,
icon: const Icon(Icons.arrow_drop_down, size: 20),
),
),
),
),
const SizedBox(width: 10),
// 重新生成按钮
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
child: IconButton(
icon: const Icon(Icons.refresh, size: 18),
tooltip: '使用选定模型重新生成',
onPressed: widget.option.isGenerating || _selectedConfigId == null
? null
: () => widget.onRegenerateSingle(_selectedConfigId!, null),
color: Theme.of(context).colorScheme.primary,
padding: const EdgeInsets.all(8),
constraints: const BoxConstraints(),
),
),
const SizedBox(width: 10),
// 选择按钮
ElevatedButton(
onPressed: widget.option.isGenerating
? null
: widget.onSelected,
style: ElevatedButton.styleFrom(
backgroundColor: widget.isSelected
? Theme.of(context).colorScheme.primary
: Colors.white,
foregroundColor: widget.isSelected
? Colors.white
: Theme.of(context).colorScheme.primary,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(
color: widget.isSelected
? Colors.transparent
: Theme.of(context).colorScheme.primary.withOpacity(0.5),
),
),
elevation: widget.isSelected ? 2 : 0,
),
child: Text(
widget.isSelected ? '已选择' : '选择此大纲',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
],
),
),
],
),
// 加载遮罩
if (widget.option.isGenerating)
Positioned.fill(
child: Container(
color: Colors.white.withOpacity(0.7),
child: const Center(
child: CircularProgressIndicator(),
),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,612 @@
import 'package:ainoval/blocs/next_outline/next_outline_state.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../blocs/next_outline/next_outline_bloc.dart';
import '../../../models/user_ai_model_config_model.dart';
import 'package:ainoval/screens/next_outline/widgets/modern_result_card.dart';
import 'package:ainoval/widgets/common/loading_indicator.dart';
import 'package:ainoval/utils/web_theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
/// 剧情推演结果网格 - 全局通用组件
///
/// 此组件负责展示和管理剧情推演的生成结果:
/// 1. 结果展示 - 以网格形式展示多个剧情选项
/// 2. 交互操作 - 支持选择、重新生成、保存等操作
/// 3. 状态管理 - 处理加载、空状态、错误状态
/// 4. 响应式布局 - 适配不同屏幕尺寸
///
/// 设计特点:
/// - 采用纯黑白配色方案,保持视觉一致性
/// - 现代化的卡片设计和交互反馈
/// - 清晰的信息层次和操作引导
/// - 优化的间距和组件尺寸
class ResultsGrid extends StatefulWidget {
/// 剧情选项列表 - 生成的剧情推演结果
final List<OutlineOptionState> outlineOptions;
/// 当前选中的剧情选项ID
final String? selectedOptionId;
/// AI模型配置列表 - 用于重新生成操作
final List<UserAIModelConfigModel> aiModelConfigs;
/// 是否正在生成 - 控制全局生成状态
final bool isGenerating;
/// 是否正在保存 - 控制保存操作状态
final bool isSaving;
/// 选项选中回调 - 用户选择特定剧情选项
final Function(String optionId) onOptionSelected;
/// 重新生成单个选项回调 - 重新生成特定选项
final Function(String optionId, String configId, String? hint) onRegenerateSingle;
/// 重新生成全部选项回调 - 批量重新生成
final Function(String? hint) onRegenerateAll;
/// 保存大纲回调 - 保存选中的剧情到小说结构
final Function(String optionId, String insertType) onSaveOutline;
const ResultsGrid({
Key? key,
required this.outlineOptions,
this.selectedOptionId,
required this.aiModelConfigs,
this.isGenerating = false,
this.isSaving = false,
required this.onOptionSelected,
required this.onRegenerateSingle,
required this.onRegenerateAll,
required this.onSaveOutline,
}) : super(key: key);
@override
State<ResultsGrid> createState() => _ResultsGridState();
}
/// 结果网格状态管理
///
/// 负责:
/// 1. 本地状态管理(重新生成提示等)
/// 2. 响应式布局计算
/// 3. 用户交互处理
/// 4. 对话框和弹窗管理
class _ResultsGridState extends State<ResultsGrid> {
final TextEditingController _regenerateHintController = TextEditingController();
@override
void dispose() {
_regenerateHintController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题区域 - 统一的视觉标识
_buildSectionHeader(context),
const SizedBox(height: 24), // 标题与内容的间距
// 主内容区域 - 根据状态显示不同内容
_buildMainContent(context),
],
);
}
/// 构建区域标题
/// 提供清晰的功能标识和视觉层次
Widget _buildSectionHeader(BuildContext context) {
return Row(
children: [
Icon(
LucideIcons.layout_grid,
size: 24,
color: WebTheme.getTextColor(context),
),
const SizedBox(width: 12),
Text(
'生成结果',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: WebTheme.getTextColor(context),
fontWeight: FontWeight.w600,
height: 1.2,
),
),
const Spacer(),
// 结果数量指示器
if (widget.outlineOptions.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: WebTheme.getEmptyStateColor(context),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: WebTheme.getBorderColor(context),
width: 1,
),
),
child: Text(
'${widget.outlineOptions.length} 个选项',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: WebTheme.getSecondaryTextColor(context),
),
),
),
],
);
}
/// 构建主内容区域
/// 根据不同状态显示相应的内容
Widget _buildMainContent(BuildContext context) {
// 全局加载状态 - 首次生成时的加载指示
if (widget.isGenerating && widget.outlineOptions.isEmpty) {
return _buildLoadingState();
}
// 空状态 - 尚未生成任何结果
if (widget.outlineOptions.isEmpty) {
return _buildEmptyState();
}
// 有结果状态 - 显示结果网格和操作区域
return _buildResultsContent(context);
}
/// 构建加载状态
/// 现代化的加载指示器
Widget _buildLoadingState() {
return Container(
padding: const EdgeInsets.all(40),
child: Center(
child: Column(
children: [
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: WebTheme.getCardColor(context),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: WebTheme.getShadowColor(context, opacity: 0.5),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: const LoadingIndicator(message: '正在生成剧情选项...'),
),
],
),
),
);
}
/// 构建空状态
/// 引导用户进行首次生成
Widget _buildEmptyState() {
return Container(
padding: const EdgeInsets.all(40),
child: Center(
child: Column(
children: [
Container(
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: WebTheme.getEmptyStateColor(context),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: WebTheme.getBorderColor(context),
width: 1,
),
),
child: Column(
children: [
Icon(
LucideIcons.sparkles,
size: 48,
color: WebTheme.getSecondaryTextColor(context),
),
const SizedBox(height: 16),
Text(
'尚未生成剧情',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: WebTheme.getTextColor(context),
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
'请在上方配置参数后点击"生成剧情大纲"',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: WebTheme.getSecondaryTextColor(context),
),
textAlign: TextAlign.center,
),
],
),
),
],
),
),
);
}
/// 构建结果内容
/// 包含结果网格和操作按钮
Widget _buildResultsContent(BuildContext context) {
return Column(
children: [
// 结果卡片网格 - 响应式布局
LayoutBuilder(
builder: (context, constraints) {
final crossAxisCount = _calculateCrossAxisCount(constraints.maxWidth);
return GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
childAspectRatio: 0.62, // 调高卡片高度
crossAxisSpacing: 24, // 增加间距
mainAxisSpacing: 24, // 增加间距
),
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: widget.outlineOptions.length,
itemBuilder: (context, index) {
final option = widget.outlineOptions[index];
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: WebTheme.getShadowColor(context, opacity: 0.5),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: ModernResultCard(
option: option,
isSelected: widget.selectedOptionId == option.optionId,
aiModelConfigs: widget.aiModelConfigs,
onSelected: () => widget.onOptionSelected(option.optionId),
onRegenerateSingle: (configId, hint) =>
widget.onRegenerateSingle(option.optionId, configId, hint),
onSave: (insertType) =>
widget.onSaveOutline(option.optionId, insertType),
),
);
},
);
},
),
const SizedBox(height: 32), // 网格与操作按钮的间距
// 全局操作按钮区域
if (widget.outlineOptions.isNotEmpty && !widget.isGenerating)
_buildGlobalActionButtons(context),
],
);
}
/// 构建全局操作按钮
/// 提供批量操作和主要功能入口
Widget _buildGlobalActionButtons(BuildContext context) {
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: WebTheme.getEmptyStateColor(context),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: WebTheme.getBorderColor(context),
width: 1,
),
),
child: Row(
children: [
// 重新生成按钮 - 次要操作
OutlinedButton.icon(
onPressed: widget.isGenerating || widget.isSaving
? null
: () => widget.onRegenerateAll(null),
icon: Icon(
LucideIcons.refresh_cw,
size: 18,
color: WebTheme.getTextColor(context),
),
label: Text(
'重新生成全部',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: WebTheme.getTextColor(context),
),
),
style: WebTheme.getSecondaryButtonStyle(context).copyWith(
padding: MaterialStateProperty.all(
const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
),
),
),
const Spacer(),
// 保存按钮 - 主要操作
if (widget.selectedOptionId != null)
ElevatedButton.icon(
onPressed: widget.isGenerating || widget.isSaving
? null
: () => _showSaveOptionsDialog(context),
icon: widget.isSaving
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: WebTheme.getCardColor(context),
),
)
: Icon(
LucideIcons.save,
size: 18,
color: WebTheme.getCardColor(context),
),
label: Text(
widget.isSaving ? '保存中...' : '保存选中的大纲',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: WebTheme.getCardColor(context),
),
),
style: WebTheme.getPrimaryButtonStyle(context).copyWith(
padding: MaterialStateProperty.all(
const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
),
),
],
),
);
}
/// 显示保存选项对话框
/// 提供不同的保存方式选择
void _showSaveOptionsDialog(BuildContext context) {
showDialog(
context: context,
builder: (dialogContext) {
return AlertDialog(
backgroundColor: WebTheme.getCardColor(context),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
title: Row(
children: [
Icon(
LucideIcons.save,
size: 24,
color: WebTheme.getTextColor(context),
),
const SizedBox(width: 12),
Text(
'保存大纲',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: WebTheme.getTextColor(context),
),
),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'选择保存方式:',
style: TextStyle(
fontSize: 14,
color: WebTheme.getSecondaryTextColor(context),
),
),
const SizedBox(height: 16),
// 保存选项列表
_buildSaveOption(
context,
icon: LucideIcons.folder_plus,
title: '添加为新章节',
subtitle: '在小说末尾添加新章节',
onTap: () {
Navigator.of(dialogContext).pop();
widget.onSaveOutline(widget.selectedOptionId!, 'NEW_CHAPTER');
},
),
const SizedBox(height: 12),
_buildSaveOption(
context,
icon: LucideIcons.list_plus,
title: '插入到现有章节',
subtitle: '选择插入位置',
onTap: () {
Navigator.of(dialogContext).pop();
_showChapterInsertDialog(context);
},
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
style: WebTheme.getSecondaryButtonStyle(context),
child: Text(
'取消',
style: TextStyle(
color: WebTheme.getTextColor(context),
),
),
),
],
);
},
);
}
/// 构建保存选项项目
Widget _buildSaveOption(
BuildContext context, {
required IconData icon,
required String title,
required String subtitle,
required VoidCallback onTap,
}) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(
color: WebTheme.getBorderColor(context),
width: 1,
),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
icon,
size: 24,
color: WebTheme.getTextColor(context),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: WebTheme.getTextColor(context),
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: TextStyle(
fontSize: 12,
color: WebTheme.getSecondaryTextColor(context),
),
),
],
),
),
Icon(
LucideIcons.chevron_right,
size: 18,
color: WebTheme.getSecondaryTextColor(context),
),
],
),
),
);
}
/// 显示章节插入对话框
/// 选择具体的插入位置
void _showChapterInsertDialog(BuildContext context) {
// 获取章节列表
final chapters = context.read<NextOutlineBloc>().state.chapters;
showDialog(
context: context,
builder: (dialogContext) {
return AlertDialog(
backgroundColor: WebTheme.getCardColor(context),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
title: Text(
'选择插入位置',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: WebTheme.getTextColor(context),
),
),
content: SizedBox(
width: double.maxFinite,
child: ListView.separated(
shrinkWrap: true,
itemCount: chapters.length,
separatorBuilder: (context, index) => Divider(
color: WebTheme.getBorderColor(context),
height: 1,
),
itemBuilder: (context, index) {
final chapter = chapters[index];
return ListTile(
title: Text(
chapter.title,
style: TextStyle(
fontSize: 14,
color: WebTheme.getTextColor(context),
),
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'插入到此章节后',
style: TextStyle(
fontSize: 12,
color: WebTheme.getSecondaryTextColor(context),
),
),
onTap: () {
Navigator.of(dialogContext).pop();
widget.onSaveOutline(widget.selectedOptionId!, 'CHAPTER_END');
},
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
style: WebTheme.getSecondaryButtonStyle(context),
child: Text(
'取消',
style: TextStyle(
color: WebTheme.getTextColor(context),
),
),
),
],
);
},
);
}
/// 计算网格列数
/// 基于屏幕宽度的响应式计算
int _calculateCrossAxisCount(double width) {
if (width < 600) return 1; // 移动设备:单列
if (width < 900) return 2; // 平板:双列
if (width < 1200) return 3; // 小桌面:三列
return 3; // 大桌面:最多三列,保持卡片适当大小
}
}