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

View File

@@ -0,0 +1,464 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:ainoval/models/novel_setting_item.dart';
import 'package:ainoval/models/setting_type.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 FloatingNovelSettingRelationshipDialog {
static bool _isShowing = false;
/// 显示浮动设定关系卡片
static void show({
required BuildContext context,
required String novelId,
required String sourceItemId, // 源条目ID
required String sourceName, // 源条目名称,用于显示
required List<NovelSettingItem> availableTargets, // 可选的目标条目
required Function(String relationType, String targetItemId, String? description) onSave, // 保存回调(关系类型, 目标条目ID, 描述)
}) {
if (_isShowing) {
hide();
}
// 获取布局信息
final layoutManager = Provider.of<EditorLayoutManager>(context, listen: false);
final sidebarWidth = layoutManager.isEditorSidebarVisible ? layoutManager.editorSidebarWidth : 0.0;
AppLogger.d('FloatingNovelSettingRelationshipDialog', '显示浮动卡片,侧边栏宽度: $sidebarWidth');
// 计算卡片大小
final screenSize = MediaQuery.of(context).size;
final cardWidth = (screenSize.width * 0.25).clamp(400.0, 600.0);
final cardHeight = (screenSize.height * 0.6).clamp(400.0, 600.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: _NovelSettingRelationshipDialogContent(
novelId: novelId,
sourceItemId: sourceItemId,
sourceName: sourceName,
availableTargets: availableTargets,
onSave: (relationType, targetItemId, description) {
onSave(relationType, targetItemId, description);
hide();
},
onCancel: hide,
),
onClose: hide,
);
_isShowing = true;
}
/// 隐藏浮动卡片
static void hide() {
if (_isShowing) {
FloatingCard.hide();
_isShowing = false;
}
}
/// 检查是否正在显示
static bool get isShowing => _isShowing;
}
/// 小说设定条目关系对话框内容
///
/// 用于创建条目之间的关系
class _NovelSettingRelationshipDialogContent extends StatefulWidget {
final String novelId;
final String sourceItemId; // 源条目ID
final String sourceName; // 源条目名称,用于显示
final List<NovelSettingItem> availableTargets; // 可选的目标条目
final Function(String relationType, String targetItemId, String? description) onSave; // 保存回调(关系类型, 目标条目ID, 描述)
final VoidCallback onCancel; // 取消回调
const _NovelSettingRelationshipDialogContent({
Key? key,
required this.novelId,
required this.sourceItemId,
required this.sourceName,
required this.availableTargets,
required this.onSave,
required this.onCancel,
}) : super(key: key);
@override
State<_NovelSettingRelationshipDialogContent> createState() => _NovelSettingRelationshipDialogContentState();
}
class _NovelSettingRelationshipDialogContentState extends State<_NovelSettingRelationshipDialogContent> {
final _formKey = GlobalKey<FormState>();
// 表单控制器
final _descriptionController = TextEditingController();
// 选中的目标条目
String? _selectedTargetId;
// 关系类型
String? _relationType;
// 常见关系类型
final List<String> _relationTypes = [
'朋友', '敌人', '亲戚', '同伴', '主从', '师徒', '恋人',
'位于', '拥有', '使用', '创造', '参与', '影响',
'属于', '领导', '成员', '其他'
];
// 保存状态
bool _isSaving = false;
@override
void dispose() {
_descriptionController.dispose();
super.dispose();
}
// 保存关系
void _saveRelationship() {
if (_formKey.currentState!.validate()) {
setState(() {
_isSaving = true;
});
// 调用保存回调
widget.onSave(
_relationType!,
_selectedTargetId!,
_descriptionController.text.isNotEmpty ? _descriptionController.text : null,
);
// 注意:不在这里关闭对话框,因为 FloatingNovelSettingRelationshipDialog.show() 的 onSave 回调会调用 hide()
}
}
@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: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'添加设定关系',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
// 源条目信息
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'源设定条目:',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.grey.shade700,
),
),
const SizedBox(height: 4),
Text(
widget.sourceName,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
),
const SizedBox(height: 16),
// 关系类型
DropdownButtonFormField<String>(
decoration: const InputDecoration(
labelText: '关系类型',
border: OutlineInputBorder(),
),
value: _relationType,
items: _relationTypes.map((type) {
return DropdownMenuItem<String>(
value: type,
child: Text(type),
);
}).toList(),
onChanged: (value) {
setState(() {
_relationType = value;
});
},
validator: (value) {
if (value == null || value.isEmpty) {
return '请选择关系类型';
}
return null;
},
),
const SizedBox(height: 16),
// 目标条目
DropdownButtonFormField<String>(
decoration: const InputDecoration(
labelText: '目标设定条目',
border: OutlineInputBorder(),
),
value: _selectedTargetId,
items: widget.availableTargets.map((target) {
// 使用SettingType枚举显示类型
final typeEnum = SettingType.fromValue(target.type ?? 'OTHER');
return DropdownMenuItem<String>(
value: target.id,
child: Row(
children: [
_buildTypeIcon(typeEnum),
const SizedBox(width: 8),
Expanded(
child: Text(
target.name,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}).toList(),
onChanged: (value) {
setState(() {
_selectedTargetId = value;
});
},
validator: (value) {
if (value == null || value.isEmpty) {
return '请选择目标设定条目';
}
return null;
},
),
const SizedBox(height: 16),
// 描述
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: '关系描述 (可选)',
border: OutlineInputBorder(),
alignLabelWithHint: true,
),
maxLines: 2,
),
const SizedBox(height: 24),
// 操作按钮
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('取消'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _isSaving ? null : _saveRelationship,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
),
child: _isSaving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: const Text('保存'),
),
],
),
],
),
),
),
);
}
// 构建类型图标
Widget _buildTypeIcon(SettingType type) {
final Color iconColor = _getTypeColor(type);
return CircleAvatar(
radius: 12,
backgroundColor: iconColor.withOpacity(0.1),
child: Icon(
_getTypeIconData(type),
size: 12,
color: iconColor,
),
);
}
// 获取类型图标
IconData _getTypeIconData(SettingType type) {
switch (type) {
case SettingType.character:
return Icons.person;
case SettingType.location:
return Icons.place;
case SettingType.item:
return Icons.inventory_2;
case SettingType.lore:
return Icons.public;
case SettingType.event:
return Icons.event;
case SettingType.concept:
return Icons.auto_awesome;
case SettingType.faction:
return Icons.groups;
case SettingType.creature:
return Icons.pets;
case SettingType.magicSystem:
return Icons.auto_fix_high;
case SettingType.technology:
return Icons.science;
case SettingType.culture:
return Icons.emoji_people;
case SettingType.history:
return Icons.history;
case SettingType.organization:
return Icons.apartment;
case SettingType.worldview:
return Icons.public;
case SettingType.pleasurePoint:
return Icons.whatshot;
case SettingType.anticipationHook:
return Icons.bolt;
case SettingType.theme:
return Icons.category;
case SettingType.tone:
return Icons.tonality;
case SettingType.style:
return Icons.brush;
case SettingType.trope:
return Icons.theater_comedy;
case SettingType.plotDevice:
return Icons.schema;
case SettingType.powerSystem:
return Icons.flash_on;
case SettingType.timeline:
return Icons.timeline;
case SettingType.religion:
return Icons.account_balance;
case SettingType.politics:
return Icons.gavel;
case SettingType.economy:
return Icons.attach_money;
case SettingType.geography:
return Icons.map;
default:
return Icons.article;
}
}
// 根据类型获取颜色
Color _getTypeColor(SettingType type) {
switch (type) {
case SettingType.character:
return Colors.blue;
case SettingType.location:
return Colors.green;
case SettingType.item:
return Colors.orange;
case SettingType.lore:
return Colors.purple;
case SettingType.event:
return Colors.red;
case SettingType.concept:
return Colors.teal;
case SettingType.faction:
return Colors.indigo;
case SettingType.creature:
return Colors.brown;
case SettingType.magicSystem:
return Colors.cyan;
case SettingType.technology:
return Colors.blueGrey;
case SettingType.culture:
return Colors.deepOrange;
case SettingType.history:
return Colors.brown;
case SettingType.organization:
return Colors.indigo;
case SettingType.worldview:
return Colors.purple;
case SettingType.pleasurePoint:
return Colors.redAccent;
case SettingType.anticipationHook:
return Colors.teal;
case SettingType.theme:
return Colors.blueGrey;
case SettingType.tone:
return Colors.amber;
case SettingType.style:
return Colors.cyan;
case SettingType.trope:
return Colors.pink;
case SettingType.plotDevice:
return Colors.green;
case SettingType.powerSystem:
return Colors.orange;
case SettingType.timeline:
return Colors.blue;
case SettingType.religion:
return Colors.deepPurple;
case SettingType.politics:
return Colors.red;
case SettingType.economy:
return Colors.lightGreen;
case SettingType.geography:
return Colors.lightBlue;
default:
return Colors.grey.shade700;
}
}
}

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,233 @@
import 'package:flutter/material.dart';
import 'package:ainoval/models/preset_models.dart';
import 'package:ainoval/services/ai_preset_service.dart';
import 'package:ainoval/utils/logger.dart';
/// 预设菜单项数据
class PresetMenuItemData {
final IconData icon;
final String label;
final bool hasSubmenu;
final bool disabled;
final bool isDangerous;
final Future<void> Function(BuildContext context, AIPresetService presetService, String featureType)? onTap;
const PresetMenuItemData({
required this.icon,
required this.label,
this.hasSubmenu = false,
this.disabled = false,
this.isDangerous = false,
this.onTap,
});
}
/// 预设菜单分组数据
class PresetMenuSectionData {
final String? title;
final List<PresetMenuItemData> items;
final bool dividerAtBottom;
const PresetMenuSectionData({
this.title,
required this.items,
this.dividerAtBottom = true,
});
}
/// 预设菜单定义
class PresetMenuDefinitions {
static List<dynamic> getMenuItems({
required Function() onCreatePreset,
required Function() onManagePresets,
}) {
return [
// 主要操作
PresetMenuSectionData(
title: null,
items: [
PresetMenuItemData(
icon: Icons.bookmark_add,
label: 'Create Preset',
onTap: (context, presetService, featureType) async {
onCreatePreset();
},
),
PresetMenuItemData(
icon: Icons.edit_outlined,
label: 'Update Preset',
disabled: true, // 暂时禁用
onTap: null,
),
],
dividerAtBottom: true,
),
// 最近使用的预设
PresetMenuSectionData(
title: '最近使用',
items: [], // 动态加载
dividerAtBottom: true,
),
// 收藏预设
PresetMenuSectionData(
title: '收藏预设',
items: [], // 动态加载
dividerAtBottom: true,
),
// 管理操作
PresetMenuSectionData(
title: null,
items: [
PresetMenuItemData(
icon: Icons.settings,
label: 'Manage Presets',
onTap: (context, presetService, featureType) async {
onManagePresets();
},
),
],
dividerAtBottom: false,
),
];
}
/// 获取动态预设菜单项(包含实际预设数据)
static Future<List<dynamic>> getDynamicMenuItems({
required String featureType,
required Function() onCreatePreset,
required Function() onManagePresets,
required Function(AIPromptPreset preset) onPresetSelected,
String? novelId,
}) async {
final presetService = AIPresetService();
try {
// 使用新的统一接口获取功能预设列表
final presetListResponse = await presetService.getFeaturePresetList(featureType, novelId: novelId);
final recentPresets = presetListResponse.recentUsed.map((item) => item.preset).toList();
final favoritePresets = presetListResponse.favorites.map((item) => item.preset).toList();
return [
// 主要操作
PresetMenuSectionData(
title: null,
items: [
PresetMenuItemData(
icon: Icons.bookmark_add,
label: 'Create Preset',
onTap: (context, presetService, featureType) async {
onCreatePreset();
},
),
PresetMenuItemData(
icon: Icons.edit_outlined,
label: 'Update Preset',
disabled: true, // 暂时禁用
onTap: null,
),
],
dividerAtBottom: true,
),
// 最近使用的预设
if (recentPresets.isNotEmpty) ...[
PresetMenuSectionData(
title: '最近使用',
items: recentPresets.map((preset) => PresetMenuItemData(
icon: Icons.history,
label: preset.presetName ?? '未命名预设',
onTap: (context, presetService, featureType) async {
onPresetSelected(preset);
// 记录使用
presetService.applyPreset(preset.presetId).catchError((e) {
AppLogger.w('PresetMenu', '记录预设使用失败', e);
});
},
)).toList(),
dividerAtBottom: true,
),
],
// 收藏预设
if (favoritePresets.isNotEmpty) ...[
PresetMenuSectionData(
title: '收藏预设',
items: favoritePresets.map((preset) => PresetMenuItemData(
icon: Icons.favorite,
label: preset.presetName ?? '未命名预设',
onTap: (context, presetService, featureType) async {
onPresetSelected(preset);
// 记录使用
presetService.applyPreset(preset.presetId).catchError((e) {
AppLogger.w('PresetMenu', '记录预设使用失败', e);
});
},
)).toList(),
dividerAtBottom: true,
),
],
// 空状态提示
if (recentPresets.isEmpty && favoritePresets.isEmpty) ...[
PresetMenuSectionData(
title: null,
items: [
PresetMenuItemData(
icon: Icons.info_outline,
label: '暂无预设',
disabled: true,
onTap: null,
),
],
dividerAtBottom: true,
),
],
// 管理操作
PresetMenuSectionData(
title: null,
items: [
PresetMenuItemData(
icon: Icons.settings,
label: 'Manage Presets',
onTap: (context, presetService, featureType) async {
onManagePresets();
},
),
],
dividerAtBottom: false,
),
];
} catch (e) {
AppLogger.e('PresetMenuDefinitions', '加载预设数据失败', e);
// 返回基础菜单
return [
PresetMenuSectionData(
title: null,
items: [
PresetMenuItemData(
icon: Icons.bookmark_add,
label: 'Create Preset',
onTap: (context, presetService, featureType) async {
onCreatePreset();
},
),
PresetMenuItemData(
icon: Icons.settings,
label: 'Manage Presets',
onTap: (context, presetService, featureType) async {
onManagePresets();
},
),
],
dividerAtBottom: false,
),
];
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,561 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:ainoval/models/novel_setting_item.dart';
import 'package:ainoval/models/setting_type.dart';
import 'package:ainoval/models/setting_group.dart';
import 'package:ainoval/blocs/setting/setting_bloc.dart';
import 'package:ainoval/utils/web_theme.dart';
import 'package:ainoval/utils/logger.dart';
import 'package:ainoval/screens/editor/widgets/novel_setting_detail.dart';
/// 设定信息预览卡片组件
/// 显示设定的基本信息(分类、名称、设定组、图片、描述)
class SettingPreviewCard extends StatefulWidget {
final String settingId;
final String novelId;
final Offset position;
final VoidCallback? onClose;
const SettingPreviewCard({
Key? key,
required this.settingId,
required this.novelId,
required this.position,
this.onClose,
}) : super(key: key);
@override
State<SettingPreviewCard> createState() => _SettingPreviewCardState();
}
class _SettingPreviewCardState extends State<SettingPreviewCard> with TickerProviderStateMixin {
static const String _tag = 'SettingPreviewCard';
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
late Animation<double> _opacityAnimation;
NovelSettingItem? _settingItem;
SettingGroup? _settingGroup;
bool _isLoading = true;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 0.8,
end: 1.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutCubic,
));
_opacityAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeOut,
));
_loadSettingData();
_animationController.forward();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
/// 加载设定数据
void _loadSettingData() {
try {
final settingBloc = context.read<SettingBloc>();
final state = settingBloc.state;
// 查找设定条目
_settingItem = state.items.firstWhere(
(item) => item.id == widget.settingId,
orElse: () => NovelSettingItem(name: ''),
);
if (_settingItem != null) {
// 查找设定组
_settingGroup = state.groups.firstWhere(
(group) => group.itemIds?.any((item) => item == widget.settingId) == true,
orElse: () => SettingGroup(name: ''),
);
}
setState(() {
_isLoading = false;
});
AppLogger.d(_tag, '设定数据加载完成: ${_settingItem?.name ?? "未找到"}');
} catch (e) {
AppLogger.e(_tag, '加载设定数据失败', e);
setState(() {
_isLoading = false;
});
}
}
/// 获取设定类型图标
IconData _getTypeIcon() {
if (_settingItem?.type == null) return Icons.article;
final settingType = SettingType.fromValue(_settingItem!.type!);
switch (settingType) {
case SettingType.character:
return Icons.person;
case SettingType.location:
return Icons.place;
case SettingType.item:
return Icons.inventory_2;
case SettingType.lore:
return Icons.public;
case SettingType.event:
return Icons.event;
case SettingType.concept:
return Icons.auto_awesome;
case SettingType.faction:
return Icons.groups;
case SettingType.creature:
return Icons.pets;
case SettingType.magicSystem:
return Icons.auto_fix_high;
case SettingType.technology:
return Icons.science;
case SettingType.culture:
return Icons.emoji_people;
case SettingType.history:
return Icons.history;
case SettingType.organization:
return Icons.apartment;
case SettingType.worldview:
return Icons.public;
case SettingType.pleasurePoint:
return Icons.whatshot;
case SettingType.anticipationHook:
return Icons.bolt;
case SettingType.theme:
return Icons.category;
case SettingType.tone:
return Icons.tonality;
case SettingType.style:
return Icons.brush;
case SettingType.trope:
return Icons.theater_comedy;
case SettingType.plotDevice:
return Icons.schema;
case SettingType.powerSystem:
return Icons.flash_on;
case SettingType.timeline:
return Icons.timeline;
case SettingType.religion:
return Icons.account_balance;
case SettingType.politics:
return Icons.gavel;
case SettingType.economy:
return Icons.attach_money;
case SettingType.geography:
return Icons.map;
default:
return Icons.article;
}
}
/// 获取设定类型显示名称
String _getTypeDisplayName() {
if (_settingItem?.type == null) return '其他';
return SettingType.fromValue(_settingItem!.type!).displayName;
}
/// 处理标题点击
void _handleTitleTap() {
AppLogger.d(_tag, '点击设定标题,打开详情卡片: ${_settingItem?.name}');
// 关闭当前预览卡片
_close();
// 延迟一小段时间后打开详情卡片,确保预览卡片完全关闭
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted && _settingItem != null) {
FloatingNovelSettingDetail.show(
context: context,
itemId: _settingItem!.id,
novelId: widget.novelId,
groupId: _settingGroup?.id,
isEditing: false,
onSave: (item, groupId) {
// 保存成功后可以做一些处理
AppLogger.i(_tag, '设定详情保存成功: ${item.name}');
},
onCancel: () {
// 取消操作
AppLogger.d(_tag, '设定详情编辑取消');
},
);
}
});
}
/// 关闭卡片
void _close() {
_animationController.reverse().then((_) {
widget.onClose?.call();
});
}
@override
Widget build(BuildContext context) {
final screenSize = MediaQuery.of(context).size;
final isDark = WebTheme.isDarkMode(context);
// 计算卡片位置,确保不超出屏幕边界
const cardWidth = 320.0;
const cardHeight = 200.0;
double left = widget.position.dx;
double top = widget.position.dy;
// 调整水平位置
if (left + cardWidth > screenSize.width) {
left = screenSize.width - cardWidth - 16;
}
if (left < 16) {
left = 16;
}
// 调整垂直位置
if (top + cardHeight > screenSize.height) {
top = widget.position.dy - cardHeight - 10; // 显示在鼠标上方
}
if (top < 16) {
top = 16;
}
return Positioned(
left: left,
top: top,
child: AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Opacity(
opacity: _opacityAnimation.value,
child: Material(
elevation: 12,
borderRadius: BorderRadius.circular(12),
color: Colors.transparent,
shadowColor: Theme.of(context).colorScheme.shadow.withOpacity(0.3),
child: Container(
width: cardWidth,
constraints: const BoxConstraints(
maxHeight: cardHeight,
),
decoration: BoxDecoration(
color: WebTheme.getSurfaceColor(context),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isDark ? WebTheme.darkGrey700 : WebTheme.grey300,
width: 1.5,
),
),
child: _buildCardContent(isDark),
),
),
),
);
},
),
);
}
/// 构建卡片内容
Widget _buildCardContent(bool isDark) {
if (_isLoading) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: CircularProgressIndicator(),
),
);
}
if (_settingItem == null) {
return Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
size: 32,
color: WebTheme.getSecondaryTextColor(context),
),
const SizedBox(height: 8),
Text(
'设定不存在',
style: TextStyle(
fontSize: 14,
color: WebTheme.getSecondaryTextColor(context),
),
),
],
),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// 头部区域
_buildHeader(isDark),
// 分隔线
Container(
height: 1,
color: isDark ? WebTheme.darkGrey800 : WebTheme.grey200,
),
// 内容区域
Flexible(
child: _buildContent(isDark),
),
],
);
}
/// 构建头部区域
Widget _buildHeader(bool isDark) {
return Container(
padding: const EdgeInsets.all(16),
child: Row(
children: [
// 设定图片或类型图标
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: isDark ? WebTheme.darkGrey800 : WebTheme.grey100,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isDark ? WebTheme.darkGrey700 : WebTheme.grey300,
width: 1,
),
),
child: _settingItem!.imageUrl != null && _settingItem!.imageUrl!.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(7),
child: Image.network(
_settingItem!.imageUrl!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Icon(
_getTypeIcon(),
size: 24,
color: WebTheme.getTextColor(context),
);
},
),
)
: Icon(
_getTypeIcon(),
size: 24,
color: WebTheme.getTextColor(context),
),
),
const SizedBox(width: 12),
// 设定信息
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 设定名称(可点击)
GestureDetector(
onTap: _handleTitleTap,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Text(
_settingItem!.name,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: WebTheme.getTextColor(context),
decoration: TextDecoration.underline,
decorationColor: WebTheme.getTextColor(context).withOpacity(0.3),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
const SizedBox(height: 4),
// 类型和设定组
Row(
children: [
// 设定类型
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: WebTheme.getTextColor(context).withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
_getTypeDisplayName(),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: WebTheme.getTextColor(context),
),
),
),
if (_settingGroup != null) ...[
const SizedBox(width: 8),
// 设定组
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: WebTheme.getSecondaryTextColor(context).withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
_settingGroup!.name,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: WebTheme.getSecondaryTextColor(context),
),
),
),
],
],
),
],
),
),
// 关闭按钮
GestureDetector(
onTap: _close,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(4),
),
child: Icon(
Icons.close,
size: 16,
color: WebTheme.getSecondaryTextColor(context),
),
),
),
),
],
),
);
}
/// 构建内容区域
Widget _buildContent(bool isDark) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// 描述内容
if (_settingItem!.description != null && _settingItem!.description!.isNotEmpty) ...[
Text(
'描述',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: WebTheme.getTextColor(context),
),
),
const SizedBox(height: 6),
Flexible(
child: Text(
_settingItem!.description!,
style: TextStyle(
fontSize: 13,
height: 1.4,
color: WebTheme.getSecondaryTextColor(context),
),
maxLines: 4,
overflow: TextOverflow.ellipsis,
),
),
] else if (_settingItem!.content != null && _settingItem!.content!.isNotEmpty) ...[
Text(
'内容',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: WebTheme.getTextColor(context),
),
),
const SizedBox(height: 6),
Flexible(
child: Text(
_settingItem!.content!,
style: TextStyle(
fontSize: 13,
height: 1.4,
color: WebTheme.getSecondaryTextColor(context),
),
maxLines: 4,
overflow: TextOverflow.ellipsis,
),
),
] else ...[
Center(
child: Text(
'暂无描述',
style: TextStyle(
fontSize: 13,
color: WebTheme.getSecondaryTextColor(context).withOpacity(0.6),
fontStyle: FontStyle.italic,
),
),
),
],
const SizedBox(height: 8),
// 提示文本
Text(
'点击标题查看详情',
style: TextStyle(
fontSize: 11,
color: WebTheme.getSecondaryTextColor(context).withOpacity(0.7),
fontStyle: FontStyle.italic,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,162 @@
import 'package:flutter/material.dart';
import 'package:ainoval/utils/web_theme.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter_quill/flutter_quill.dart';
import 'package:ainoval/utils/logger.dart';
import 'package:ainoval/utils/setting_reference_processor.dart';
/// 🎯 简化版设定引用悬停状态管理器
/// 使用TextStyle.backgroundColor实现悬停效果比复杂的位置计算更简单高效
class SettingReferenceHoverManager extends ChangeNotifier {
static final SettingReferenceHoverManager _instance = SettingReferenceHoverManager._internal();
factory SettingReferenceHoverManager() => _instance;
SettingReferenceHoverManager._internal();
String? _hoveredSettingId;
String? get hoveredSettingId => _hoveredSettingId;
/// 设置悬停的设定引用ID
void setHoveredSetting(String? settingId) {
if (_hoveredSettingId != settingId) {
_hoveredSettingId = settingId;
notifyListeners();
AppLogger.d('SettingReferenceHoverManager',
_hoveredSettingId != null
? '🖱️ 设定引用悬停开始: $_hoveredSettingId'
: '🖱️ 设定引用悬停结束');
}
}
/// 清除悬停状态
void clearHover() {
setHoveredSetting(null);
}
}
/// 设定引用交互混入 - 为 SceneEditor 提供设定引用交互功能
mixin SettingReferenceInteractionMixin {
/// 🎯 获取支持悬停效果的设定引用样式构建器
/// 这是最核心的方法直接在customStyleBuilder中处理悬停效果
static TextStyle Function(Attribute) getCustomStyleBuilderWithHover({
required String? hoveredSettingId,
}) {
return (Attribute attribute) {
// 处理设定引用的样式标记
if (attribute.key == SettingReferenceProcessor.settingStyleAttr &&
attribute.value == 'reference') {
// 🎯 关键使用TextStyle.backgroundColor实现悬停效果
return const TextStyle(
decoration: TextDecoration.underline,
decorationStyle: TextDecorationStyle.dotted,
decorationColor: WebTheme.grey400,
decorationThickness: 1.5,
// 🎯 核心直接使用TextStyle的backgroundColor属性
backgroundColor: Color(0x00FFF3CD),
).copyWith(
backgroundColor: hoveredSettingId != null ? const Color(0xFFFFF3CD) : null,
);
}
return const TextStyle();
};
}
/// 获取设定引用的自定义手势识别器构建器
static GestureRecognizer? Function(Attribute, Node) getCustomRecognizerBuilder({
required Function(String settingId)? onSettingReferenceClicked,
required Function(String settingId)? onSettingReferenceHovered,
required VoidCallback? onSettingReferenceHoverEnd,
}) {
return (Attribute attribute, Node node) {
// 检查是否是设定引用属性
if (attribute.key == SettingReferenceProcessor.settingReferenceAttr ) {
final settingId = attribute.value as String?;
if (settingId != null && settingId.isNotEmpty) {
//AppLogger.d('SettingReferenceInteraction', '🎯 创建设定引用手势识别器: $settingId');
// 创建支持点击和悬停的手势识别器
final tapRecognizer = TapGestureRecognizer()
..onTap = () {
AppLogger.i('SettingReferenceInteraction', '🖱️ 设定引用被点击: $settingId');
onSettingReferenceClicked?.call(settingId);
};
return tapRecognizer;
}
}
return null;
};
}
/// 获取设定引用的自定义样式构建器(基础版本)
static TextStyle Function(Attribute) getCustomStyleBuilder() {
return (Attribute attribute) {
// 处理设定引用的样式标记
if (attribute.key == SettingReferenceProcessor.settingStyleAttr &&
attribute.value == 'reference') {
return const TextStyle(
decoration: TextDecoration.underline,
decorationStyle: TextDecorationStyle.dotted,
decorationColor: WebTheme.grey400,
decorationThickness: 1.5,
);
}
return const TextStyle();
};
}
}
/// 🎯 设定引用鼠标悬停检测器Widget
/// 使用MouseRegion包装编辑器检测鼠标悬停并更新状态
class SettingReferenceMouseDetector extends StatefulWidget {
final Widget child;
final QuillController controller;
final String? novelId;
const SettingReferenceMouseDetector({
Key? key,
required this.child,
required this.controller,
this.novelId,
}) : super(key: key);
@override
State<SettingReferenceMouseDetector> createState() => _SettingReferenceMouseDetectorState();
}
class _SettingReferenceMouseDetectorState extends State<SettingReferenceMouseDetector> {
final _hoverManager = SettingReferenceHoverManager();
@override
Widget build(BuildContext context) {
return MouseRegion(
onHover: _handleMouseMove,
onExit: (_) => _hoverManager.clearHover(),
child: widget.child,
);
}
void _handleMouseMove(PointerHoverEvent event) {
// 🎯 这里可以实现基于鼠标位置的设定引用检测
// 为了简化,暂时先处理基本的悬停状态
try {
// TODO: 实现更精确的位置检测逻辑
// 目前先简化处理,后续可以根据需要优化
// 暂时用一个简单的方式来模拟检测
// 实际项目中可能需要更复杂的位置计算
AppLogger.v('SettingReferenceMouseDetector', '🖱️ 鼠标移动: ${event.localPosition}');
} catch (e) {
AppLogger.w('SettingReferenceMouseDetector', '检测设定引用悬停失败', e);
}
}
}

View File

@@ -0,0 +1,697 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
import 'package:ainoval/models/novel_snippet.dart';
import 'package:ainoval/services/api_service/repositories/novel_snippet_repository.dart';
import 'package:ainoval/utils/web_theme.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/event_bus.dart';
/// 浮动片段编辑卡片管理器
class FloatingSnippetEditor {
static bool _isShowing = false;
/// 显示浮动编辑卡片
static void show({
required BuildContext context,
required NovelSnippet snippet,
Function(NovelSnippet)? onSaved,
Function(String)? onDeleted,
}) {
if (_isShowing) {
hide();
}
// 在创建 Overlay 前获取布局信息
final layoutManager = Provider.of<EditorLayoutManager>(context, listen: false);
final sidebarWidth = layoutManager.isEditorSidebarVisible ? layoutManager.editorSidebarWidth : 0.0;
AppLogger.d('FloatingSnippetEditor', '显示浮动卡片,侧边栏宽度: $sidebarWidth, 是否可见: ${layoutManager.isEditorSidebarVisible}');
// 计算卡片大小(保持原有逻辑)
final screenSize = MediaQuery.of(context).size;
final cardWidth = (screenSize.width * 0.2).clamp(500.0, 800.0);
final cardHeight = (screenSize.height * 0.2).clamp(300.0, 500.0);
FloatingCard.show(
context: context,
position: FloatingCardPosition(
left: sidebarWidth + 16.0, // 与侧边栏保持16px间隙
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, // 自定义内容的padding
),
child: _SnippetEditContent(
snippet: snippet,
onSaved: (updatedSnippet) {
onSaved?.call(updatedSnippet);
hide();
},
onDeleted: (snippetId) {
onDeleted?.call(snippetId);
hide();
},
onClose: hide,
),
onClose: hide,
);
_isShowing = true;
}
/// 隐藏浮动编辑卡片
static void hide() {
if (_isShowing) {
FloatingCard.hide();
_isShowing = false;
}
}
/// 检查是否正在显示
static bool get isShowing => _isShowing;
}
/// 片段编辑内容组件
class _SnippetEditContent extends StatefulWidget {
final NovelSnippet snippet;
final Function(NovelSnippet)? onSaved;
final Function(String)? onDeleted;
final VoidCallback? onClose;
const _SnippetEditContent({
required this.snippet,
this.onSaved,
this.onDeleted,
this.onClose,
});
@override
State<_SnippetEditContent> createState() => _SnippetEditContentState();
}
class _SnippetEditContentState extends State<_SnippetEditContent> {
late TextEditingController _titleController;
late TextEditingController _contentController;
bool _isLoading = false;
bool _isFavorite = false;
late NovelSnippetRepository _snippetRepository;
@override
void initState() {
super.initState();
// 初始化数据
_snippetRepository = context.read<NovelSnippetRepository>();
_titleController = TextEditingController(text: widget.snippet.title);
_contentController = TextEditingController(text: widget.snippet.content);
_isFavorite = widget.snippet.isFavorite;
}
@override
void dispose() {
_titleController.dispose();
_contentController.dispose();
super.dispose();
}
Future<void> _saveSnippet() async {
if (_isLoading) return;
setState(() {
_isLoading = true;
});
try {
// 检查是否为创建模式ID为空
if (widget.snippet.id.isEmpty) {
// 创建新片段
final createRequest = CreateSnippetRequest(
novelId: widget.snippet.novelId,
title: _titleController.text,
content: _contentController.text,
notes: null,
);
final newSnippet = await _snippetRepository.createSnippet(createRequest);
// 如果需要更新收藏状态,创建包含收藏状态的最终片段
NovelSnippet finalSnippet = newSnippet;
if (_isFavorite) {
final favoriteRequest = UpdateSnippetFavoriteRequest(
snippetId: newSnippet.id,
isFavorite: _isFavorite,
);
await _snippetRepository.updateSnippetFavorite(favoriteRequest);
// 更新本地片段数据的收藏状态
finalSnippet = newSnippet.copyWith(isFavorite: _isFavorite);
}
setState(() {
_isLoading = false;
});
widget.onSaved?.call(finalSnippet);
// 触发事件总线,通知片段列表刷新
EventBus.instance.fire(SnippetCreatedEvent(snippet: finalSnippet));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('片段创建成功', style: WebTheme.bodyMedium.copyWith(color: WebTheme.white)),
backgroundColor: WebTheme.success,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
);
}
} else {
// 更新现有片段
// 更新标题
if (_titleController.text != widget.snippet.title) {
final titleRequest = UpdateSnippetTitleRequest(
snippetId: widget.snippet.id,
title: _titleController.text,
changeDescription: '更新标题',
);
await _snippetRepository.updateSnippetTitle(titleRequest);
}
// 更新内容
if (_contentController.text != widget.snippet.content) {
final contentRequest = UpdateSnippetContentRequest(
snippetId: widget.snippet.id,
content: _contentController.text,
changeDescription: '更新内容',
);
await _snippetRepository.updateSnippetContent(contentRequest);
}
// 更新收藏状态
if (_isFavorite != widget.snippet.isFavorite) {
final favoriteRequest = UpdateSnippetFavoriteRequest(
snippetId: widget.snippet.id,
isFavorite: _isFavorite,
);
await _snippetRepository.updateSnippetFavorite(favoriteRequest);
}
// 获取最新的片段数据
final updatedSnippet = await _snippetRepository.getSnippetDetail(widget.snippet.id);
setState(() {
_isLoading = false;
});
widget.onSaved?.call(updatedSnippet);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('片段保存成功', style: WebTheme.bodyMedium.copyWith(color: WebTheme.white)),
backgroundColor: WebTheme.success,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
);
}
}
} catch (e) {
AppLogger.e('FloatingSnippetEditor', '保存片段失败', e);
setState(() {
_isLoading = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('保存失败: $e', style: WebTheme.bodyMedium.copyWith(color: WebTheme.white)),
backgroundColor: WebTheme.error,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
);
}
}
}
Future<void> _deleteSnippet() async {
final confirmed = await _showDeleteConfirmDialog();
if (!confirmed) return;
setState(() {
_isLoading = true;
});
try {
await _snippetRepository.deleteSnippet(widget.snippet.id);
setState(() {
_isLoading = false;
});
widget.onDeleted?.call(widget.snippet.id);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('片段删除成功', style: WebTheme.bodyMedium.copyWith(color: WebTheme.white)),
backgroundColor: WebTheme.success,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
);
}
} catch (e) {
AppLogger.e('FloatingSnippetEditor', '删除片段失败', e);
setState(() {
_isLoading = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('删除失败: $e', style: WebTheme.bodyMedium.copyWith(color: WebTheme.white)),
backgroundColor: WebTheme.error,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
);
}
}
}
Future<bool> _showDeleteConfirmDialog() async {
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
backgroundColor: WebTheme.isDarkMode(context) ? WebTheme.darkCard : WebTheme.lightCard,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: Text(
'确认删除',
style: WebTheme.titleMedium.copyWith(
color: WebTheme.isDarkMode(context) ? WebTheme.darkGrey900 : WebTheme.grey900,
),
),
content: Text(
'确定要删除片段"${widget.snippet.title}"吗?此操作无法撤销。',
style: WebTheme.bodyMedium.copyWith(
color: WebTheme.isDarkMode(context) ? WebTheme.darkGrey700 : WebTheme.grey700,
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(
'取消',
style: WebTheme.labelMedium.copyWith(
color: WebTheme.isDarkMode(context) ? WebTheme.darkGrey600 : WebTheme.grey600,
),
),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(foregroundColor: WebTheme.error),
child: Text(
'删除',
style: WebTheme.labelMedium.copyWith(color: WebTheme.error),
),
),
],
),
);
return result ?? false;
}
@override
Widget build(BuildContext context) {
final isDark = WebTheme.isDarkMode(context);
return Container(
decoration: BoxDecoration(
color: WebTheme.getSurfaceColor(context), // 🚀 修复:使用动态表面色
borderRadius: BorderRadius.circular(12),
border: WebTheme.isDarkMode(context)
? Border.all(color: WebTheme.darkGrey300, width: 1)
: Border.all(color: WebTheme.grey300, width: 1),
boxShadow: [
BoxShadow(
color: WebTheme.getShadowColor(context, opacity: 0.2),
offset: const Offset(0, 8),
blurRadius: 32,
spreadRadius: 0,
),
],
),
child: Column(
children: [
// 头部:标题输入框和操作按钮
_buildHeader(),
// 内容区域
Expanded(
child: _buildContent(),
),
],
),
);
}
Widget _buildHeader() {
final isDark = WebTheme.isDarkMode(context);
return Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: WebTheme.getSurfaceColor(context), // 🚀 修复:使用动态表面色
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
boxShadow: [
BoxShadow(
color: WebTheme.getShadowColor(context, opacity: 0.05),
offset: const Offset(0, 1),
blurRadius: 2,
),
],
),
child: Row(
children: [
// 标题输入框
Expanded(
child: Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(6),
),
child: TextField(
controller: _titleController,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: WebTheme.getTextColor(context),
),
decoration: InputDecoration(
border: InputBorder.none,
hintText: 'Name your snippet...',
hintStyle: TextStyle(
fontSize: 14,
color: WebTheme.getSecondaryTextColor(context),
),
contentPadding: EdgeInsets.zero,
),
),
),
),
// 收藏按钮
_buildIconButton(
icon: _isFavorite ? Icons.star : Icons.star_border,
onPressed: () => setState(() => _isFavorite = !_isFavorite),
color: _isFavorite ? Theme.of(context).colorScheme.tertiary : WebTheme.getSecondaryTextColor(context),
),
// 更多操作按钮
_buildIconButton(
icon: Icons.more_vert,
onPressed: _showMoreOptions,
color: WebTheme.getSecondaryTextColor(context),
),
],
),
);
}
Widget _buildIconButton({
required IconData icon,
required VoidCallback onPressed,
Color? color,
}) {
final isDark = WebTheme.isDarkMode(context);
return Container(
width: 36,
height: 36,
margin: const EdgeInsets.only(left: 6),
child: IconButton(
onPressed: onPressed,
icon: Icon(
icon,
size: 20,
color: color ?? WebTheme.getSecondaryTextColor(context),
),
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
backgroundColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
),
),
);
}
void _showMoreOptions() {
// 显示更多选项菜单
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => Container(
margin: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: WebTheme.getSurfaceColor(context), // 🚀 修复:使用动态表面色
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.snippet.id.isNotEmpty)
ListTile(
leading: Icon(Icons.delete_outline, color: Theme.of(context).colorScheme.error),
title: const Text('删除片段'),
onTap: () {
Navigator.pop(context);
_deleteSnippet();
},
),
ListTile(
leading: const Icon(Icons.close),
title: const Text('关闭'),
onTap: () {
Navigator.pop(context);
widget.onClose?.call();
},
),
],
),
),
);
}
Widget _buildContent() {
final isDark = WebTheme.isDarkMode(context);
return Column(
children: [
// 内容编辑区域
Expanded(
child: Container(
margin: const EdgeInsets.all(12),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.transparent,
border: Border.all(
color: WebTheme.isDarkMode(context) ? WebTheme.darkGrey300 : WebTheme.grey300,
width: 1,
),
borderRadius: BorderRadius.circular(6),
),
child: TextField(
controller: _contentController,
maxLines: null,
expands: true,
style: TextStyle(
fontSize: 14,
height: 1.6,
color: WebTheme.getTextColor(context),
),
decoration: InputDecoration(
border: InputBorder.none,
hintText: '请输入内容...',
hintStyle: TextStyle(
fontSize: 14,
color: WebTheme.getSecondaryTextColor(context),
),
contentPadding: EdgeInsets.zero,
),
),
),
),
// 底部状态栏
_buildFooter(),
],
);
}
Widget _buildFooter() {
final isDark = WebTheme.isDarkMode(context);
final wordCount = _contentController.text.split(RegExp(r'\s+')).where((word) => word.isNotEmpty).length;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
// 字数统计
Text(
'$wordCount Words',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: WebTheme.getSecondaryTextColor(context),
),
),
const Spacer(),
// 功能按钮
_buildFooterButton(
icon: Icons.history,
label: 'History',
onPressed: () {
// TODO: 实现历史记录功能
},
),
const SizedBox(width: 8),
_buildFooterButton(
icon: Icons.content_copy,
label: 'Copy',
onPressed: () {
// TODO: 实现复制功能
},
),
const SizedBox(width: 8),
// 保存按钮
_buildSaveButton(),
],
),
);
}
Widget _buildFooterButton({
required IconData icon,
required String label,
required VoidCallback onPressed,
}) {
final isDark = WebTheme.isDarkMode(context);
return InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(4),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 14,
color: WebTheme.getSecondaryTextColor(context),
),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: WebTheme.getSecondaryTextColor(context),
),
),
],
),
),
);
}
Widget _buildSaveButton() {
if (_isLoading) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
return InkWell(
onTap: _saveSnippet,
borderRadius: BorderRadius.circular(4),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
widget.snippet.id.isEmpty ? Icons.add : Icons.save,
size: 14,
color: WebTheme.getPrimaryColor(context),
),
const SizedBox(width: 4),
Text(
widget.snippet.id.isEmpty ? 'Create' : 'Save',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: WebTheme.getPrimaryColor(context),
),
),
],
),
),
);
}
}
// 兼容性:保留原有的 SnippetEditForm 类,避免破坏现有代码
@Deprecated('请使用 FloatingSnippetEditor.show() 代替')
class SnippetEditForm extends StatelessWidget {
final NovelSnippet snippet;
final VoidCallback? onClose;
final Function(NovelSnippet)? onSaved;
final Function(String)? onDeleted;
const SnippetEditForm({
super.key,
required this.snippet,
this.onClose,
this.onSaved,
this.onDeleted,
});
@override
Widget build(BuildContext context) {
// 直接返回一个空容器,因为现在使用 FloatingSnippetEditor
return const SizedBox.shrink();
}
}

View File

@@ -0,0 +1,470 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:ainoval/models/novel_snippet.dart';
import 'package:ainoval/models/novel_summary.dart';
import 'package:ainoval/services/api_service/repositories/novel_snippet_repository.dart';
import 'package:ainoval/utils/web_theme.dart';
import 'package:ainoval/utils/logger.dart';
import 'package:ainoval/widgets/common/loading_indicator.dart';
import 'package:ainoval/widgets/common/empty_state_placeholder.dart';
import 'package:ainoval/widgets/common/search_action_bar.dart';
import 'package:ainoval/utils/event_bus.dart';
import 'dart:async';
/// 片段列表标签页
class SnippetListTab extends StatefulWidget {
final NovelSummary novel;
final Function(NovelSnippet)? onSnippetTap;
final Function(VoidCallback)? onRefreshCallbackChanged;
final Function(Function(NovelSnippet))? onAddSnippetCallbackChanged;
final Function(Function(NovelSnippet))? onUpdateSnippetCallbackChanged;
final Function(Function(String))? onRemoveSnippetCallbackChanged;
const SnippetListTab({
super.key,
required this.novel,
this.onSnippetTap,
this.onRefreshCallbackChanged,
this.onAddSnippetCallbackChanged,
this.onUpdateSnippetCallbackChanged,
this.onRemoveSnippetCallbackChanged,
});
@override
State<SnippetListTab> createState() => _SnippetListTabState();
}
class _SnippetListTabState extends State<SnippetListTab>
with AutomaticKeepAliveClientMixin<SnippetListTab> {
final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController();
List<NovelSnippet> _snippets = [];
bool _isLoading = false;
bool _hasMore = true;
int _currentPage = 0;
String _searchText = '';
late NovelSnippetRepository _snippetRepository;
// 事件订阅
StreamSubscription<SnippetCreatedEvent>? _snippetCreatedSubscription;
@override
bool get wantKeepAlive => true; // 🚀 保持页面存活状态
@override
void initState() {
super.initState();
_snippetRepository = context.read<NovelSnippetRepository>();
_scrollController.addListener(_onScroll);
_loadSnippets();
// 通知父组件各种回调方法
widget.onRefreshCallbackChanged?.call(refreshSnippets);
widget.onAddSnippetCallbackChanged?.call(addSnippet);
widget.onUpdateSnippetCallbackChanged?.call(updateSnippet);
widget.onRemoveSnippetCallbackChanged?.call(removeSnippet);
// 订阅片段创建事件
_snippetCreatedSubscription = EventBus.instance
.on<SnippetCreatedEvent>()
.listen((event) {
if (event.snippet.novelId == widget.novel.id) {
addSnippet(event.snippet);
}
});
}
@override
void dispose() {
_scrollController.dispose();
_searchController.dispose();
_snippetCreatedSubscription?.cancel();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels == _scrollController.position.maxScrollExtent) {
if (!_isLoading && _hasMore) {
_loadMoreSnippets();
}
}
}
Future<void> _loadSnippets() async {
if (_isLoading) return;
setState(() {
_isLoading = true;
_currentPage = 0;
_snippets.clear();
});
try {
late SnippetPageResult<NovelSnippet> result;
if (_searchText.isNotEmpty) {
result = await _snippetRepository.searchSnippets(
widget.novel.id,
_searchText,
page: _currentPage,
size: 20,
);
} else {
result = await _snippetRepository.getSnippetsByNovelId(
widget.novel.id,
page: _currentPage,
size: 20,
);
}
setState(() {
_snippets = result.content;
_hasMore = result.hasNext;
_isLoading = false;
});
} catch (e) {
AppLogger.e('SnippetListTab', '加载片段失败', e);
setState(() {
_isLoading = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('加载片段失败: $e')),
);
}
}
}
Future<void> _loadMoreSnippets() async {
if (_isLoading || !_hasMore) return;
setState(() {
_isLoading = true;
});
try {
late SnippetPageResult<NovelSnippet> result;
if (_searchText.isNotEmpty) {
result = await _snippetRepository.searchSnippets(
widget.novel.id,
_searchText,
page: _currentPage + 1,
size: 20,
);
} else {
result = await _snippetRepository.getSnippetsByNovelId(
widget.novel.id,
page: _currentPage + 1,
size: 20,
);
}
setState(() {
_snippets.addAll(result.content);
_hasMore = result.hasNext;
_currentPage++;
_isLoading = false;
});
} catch (e) {
AppLogger.e('SnippetListTab', '加载更多片段失败', e);
setState(() {
_isLoading = false;
});
}
}
void _onSearchChanged(String value) {
if (_searchText != value) {
_searchText = value;
_loadSnippets();
}
}
/// 刷新片段列表(公共方法)
void refreshSnippets() {
_loadSnippets();
}
/// 添加新片段到列表顶部(公共方法)
void addSnippet(NovelSnippet snippet) {
setState(() {
// 避免重复添加
_snippets.removeWhere((s) => s.id == snippet.id);
_snippets.insert(0, snippet); // 添加到列表顶部
});
}
/// 更新现有片段(公共方法)
void updateSnippet(NovelSnippet updatedSnippet) {
setState(() {
final index = _snippets.indexWhere((s) => s.id == updatedSnippet.id);
if (index != -1) {
_snippets[index] = updatedSnippet;
}
});
}
/// 删除片段(公共方法)
void removeSnippet(String snippetId) {
setState(() {
_snippets.removeWhere((s) => s.id == snippetId);
});
}
@override
Widget build(BuildContext context) {
super.build(context); // 🚀 必须调用父类的build方法
final isDark = WebTheme.isDarkMode(context);
return Container(
color: WebTheme.getBackgroundColor(context), // 🚀 修复:使用背景色而不是表面色
child: Column(
children: [
// 搜索和操作栏
SearchActionBar(
searchController: _searchController,
searchHint: '搜索片段...',
newButtonText: '创建片段',
onSearchChanged: _onSearchChanged,
onFilterPressed: _showFilterDialog,
onNewPressed: _showCreateSnippetDialog,
onSettingsPressed: _showSnippetSettings,
showFilterButton: true,
showNewButton: true,
showSettingsButton: true,
),
// 片段统计信息
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Text(
'${_snippets.length} 个片段',
style: TextStyle(
fontSize: 12,
color: isDark ? WebTheme.darkGrey600 : WebTheme.grey600,
),
),
],
),
),
// 片段列表
Expanded(
child: _buildSnippetList(),
),
],
),
);
}
Widget _buildSnippetList() {
if (_isLoading && _snippets.isEmpty) {
return const Center(
child: LoadingIndicator(
message: '正在加载片段...',
size: 32,
),
);
}
if (_snippets.isEmpty) {
return EmptyStatePlaceholder(
icon: Icons.bookmark_border,
title: '暂无片段',
message: _searchText.isNotEmpty ? '未找到匹配的片段' : '还没有创建任何片段\n点击上方"创建片段"按钮创建第一个片段',
);
}
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16),
itemCount: _snippets.length + (_hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index == _snippets.length) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: LoadingIndicator(size: 24),
),
);
}
final snippet = _snippets[index];
return _buildSnippetItem(snippet);
},
);
}
Widget _buildSnippetItem(NovelSnippet snippet) {
final isDark = WebTheme.isDarkMode(context);
return Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: WebTheme.getSurfaceColor(context), // 🚀 修复:使用动态表面色
border: Border.all(
color: isDark ? WebTheme.darkGrey200 : WebTheme.grey200,
width: 1,
),
borderRadius: BorderRadius.circular(8),
),
child: InkWell(
onTap: () => widget.onSnippetTap?.call(snippet),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题行
Row(
children: [
Expanded(
child: Text(
snippet.title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isDark ? WebTheme.darkGrey900 : WebTheme.grey900,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (snippet.isFavorite)
Icon(
Icons.star,
size: 16,
color: WebTheme.isDarkMode(context) ? WebTheme.darkGrey600 : WebTheme.grey600,
),
],
),
const SizedBox(height: 8),
// 内容预览
Text(
snippet.content,
style: TextStyle(
fontSize: 12,
color: isDark ? WebTheme.darkGrey600 : WebTheme.grey600,
height: 1.4,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 12),
// 元数据
Row(
children: [
Icon(
Icons.text_fields,
size: 12,
color: isDark ? WebTheme.darkGrey500 : WebTheme.grey500,
),
const SizedBox(width: 4),
Text(
'${snippet.metadata.wordCount}',
style: TextStyle(
fontSize: 11,
color: isDark ? WebTheme.darkGrey500 : WebTheme.grey500,
),
),
const SizedBox(width: 16),
Icon(
Icons.access_time,
size: 12,
color: isDark ? WebTheme.darkGrey500 : WebTheme.grey500,
),
const SizedBox(width: 4),
Text(
_formatDate(snippet.updatedAt),
style: TextStyle(
fontSize: 11,
color: isDark ? WebTheme.darkGrey500 : WebTheme.grey500,
),
),
if (snippet.tags?.isNotEmpty == true) ...[
const SizedBox(width: 16),
Icon(
Icons.local_offer,
size: 12,
color: isDark ? WebTheme.darkGrey500 : WebTheme.grey500,
),
const SizedBox(width: 4),
Text(
snippet.tags!.first,
style: TextStyle(
fontSize: 11,
color: isDark ? WebTheme.darkGrey500 : WebTheme.grey500,
),
),
],
],
),
],
),
),
),
);
}
String _formatDate(DateTime date) {
final now = DateTime.now();
final difference = now.difference(date);
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 _showCreateSnippetDialog() {
// 创建一个新的空片段用于创建模式
final newSnippet = NovelSnippet(
id: '', // 空ID表示创建模式
userId: '',
novelId: widget.novel.id,
title: '',
content: '',
metadata: const SnippetMetadata(
wordCount: 0,
characterCount: 0,
viewCount: 0,
sortWeight: 0,
),
isFavorite: false,
status: 'draft',
version: 1,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
// 使用FloatingSnippetEditor显示表单
widget.onSnippetTap?.call(newSnippet);
}
void _showFilterDialog() {
// TODO: 实现过滤器对话框
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('过滤器功能待实现')),
);
}
void _showSnippetSettings() {
// TODO: 实现片段设置
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('片段设置功能待实现')),
);
}
}

View File

@@ -0,0 +1,118 @@
import 'package:ainoval/utils/word_count_analyzer.dart';
import 'package:flutter/material.dart';
import 'package:flutter_quill/flutter_quill.dart';
class WordCountDisplay extends StatefulWidget {
const WordCountDisplay({
super.key,
required this.controller,
});
final QuillController controller;
@override
State<WordCountDisplay> createState() => _WordCountDisplayState();
}
class _WordCountDisplayState extends State<WordCountDisplay> {
WordCountStats _stats = const WordCountStats(
words: 0,
charactersWithSpaces: 0,
charactersNoSpaces: 0,
paragraphs: 0,
readTimeMinutes: 0,
);
@override
void initState() {
super.initState();
_updateStats();
// 监听内容变化
widget.controller.document.changes.listen((_) {
_updateStats();
});
}
void _updateStats() {
final text = widget.controller.document.toPlainText();
final stats = WordCountAnalyzer.analyze(text);
setState(() {
_stats = stats;
});
}
@override
Widget build(BuildContext context) {
// 使用 Material 增加背景色和圆角
return Material(
color:
Theme.of(context).chipTheme.backgroundColor ?? Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(8), // 增加圆角
child: InkWell(
onTap: () => _showStatsDialog(context),
borderRadius: BorderRadius.circular(8), // 保持与 Material 一致
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Text(
'${_stats.words}',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Theme.of(context).textTheme.bodySmall?.color,
),
),
),
),
);
}
// 显示详细统计信息对话框
void _showStatsDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(
// 为对话框添加圆角
borderRadius: BorderRadius.circular(16),
),
title: const Text('字数统计'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildStatRow('总字数', '${_stats.words}'),
_buildStatRow('字符数(含空格)', '${_stats.charactersWithSpaces}'),
_buildStatRow('字符数(不含空格)', '${_stats.charactersNoSpaces}'),
_buildStatRow('段落数', '${_stats.paragraphs}'),
_buildStatRow('预计阅读时间', '${_stats.readTimeMinutes}分钟'),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('关闭'),
),
],
),
);
}
// 构建统计行
Widget _buildStatRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
Text(value),
],
),
);
}
}