Files
MaliangAINovalWriter/AINoval/lib/screens/editor/components/plan_view.dart
2025-09-10 00:07:52 +08:00

1527 lines
48 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:ainoval/blocs/editor/editor_bloc.dart' as editor;
import 'package:ainoval/models/novel_structure.dart' as novel_models;
import 'package:ainoval/utils/logger.dart';
import 'package:ainoval/utils/web_theme.dart';
import 'package:ainoval/components/editable_title.dart';
import 'package:ainoval/screens/editor/widgets/menu_builder.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
/// 大纲视图组件 - 显示小说的整体结构和各场景摘要
/// 支持Act、Chapter、Scene的层级管理和编辑功能
/// 🚀 重构现在使用EditorBloc统一管理数据提供无感刷新功能
class PlanView extends StatefulWidget {
const PlanView({
super.key,
required this.novelId,
required this.editorBloc, // 🚀 修改使用EditorBloc替代PlanBloc
this.onSwitchToWrite,
});
final String novelId;
final editor.EditorBloc editorBloc; // 🚀 修改改为EditorBloc
final VoidCallback? onSwitchToWrite;
@override
State<PlanView> createState() => _PlanViewState();
}
class _PlanViewState extends State<PlanView> {
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
// 🚀 修改使用EditorBloc的事件
widget.editorBloc.add(const editor.SwitchToPlanView());
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return WebTheme.getMaterialWrapper(
child: BlocBuilder<editor.EditorBloc, editor.EditorState>(
bloc: widget.editorBloc,
builder: (context, state) {
// 🚀 修改处理EditorState而不是PlanState
if (state is! editor.EditorLoaded) {
return Center(
child: CircularProgressIndicator(color: WebTheme.getPrimaryColor(context)),
);
}
final editorState = state;
// 显示错误信息
if (editorState.errorMessage != null) {
return Center(
child: Text(
'加载失败: ${editorState.errorMessage}',
style: TextStyle(color: WebTheme.getTextColor(context)),
),
);
}
final novel = editorState.novel;
return Container(
// 使用动态背景色,兼容明暗主题
color: WebTheme.getSurfaceColor(context),
child: Column(
children: [
// 主要内容区 - 使用完全虚拟化的滚动
Expanded(
child: _VirtualizedPlanView(
novel: novel,
novelId: widget.novelId,
editorBloc: widget.editorBloc,
onSwitchToWrite: widget.onSwitchToWrite,
scrollController: _scrollController,
),
),
// 底部工具栏
_PlanToolbar(editorBloc: widget.editorBloc), // 🚀 修改传递EditorBloc
],
),
);
},
),
);
}
}
// 已弃用_ActSection被虚拟化布局替代
/// Act标题头部组件
class _ActHeader extends StatelessWidget {
const _ActHeader({
required this.act,
required this.novelId,
required this.editorBloc, // 🚀 修改使用EditorBloc
});
final novel_models.Act act;
final String novelId;
final editor.EditorBloc editorBloc; // 🚀 修改改为EditorBloc
@override
Widget build(BuildContext context) {
return Row(
children: [
// 折叠按钮
IconButton(
icon: Icon(Icons.keyboard_arrow_down, size: 18, color: WebTheme.getSecondaryTextColor(context)),
onPressed: () {
// TODO(plan): 实现折叠功能
},
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 24, minHeight: 24),
),
// Act标题可编辑
Expanded(
child: EditableTitle(
initialText: act.title,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: WebTheme.getTextColor(context),
),
// 仅在提交(回车或失焦)时派发更新
onSubmitted: (value) {
editorBloc.add(editor.UpdateActTitle(
actId: act.id,
title: value,
));
},
),
),
// 添加章节按钮
_SmallIconButton(
icon: Icons.add,
tooltip: '添加章节',
onPressed: () {
// 🚀 修改使用EditorBloc事件
editorBloc.add(editor.AddNewChapter(
novelId: novelId,
actId: act.id,
));
},
),
const SizedBox(width: 4),
// 更多操作菜单(统一下拉样式)
MenuBuilder.buildActMenu(
context: context,
editorBloc: editorBloc,
actId: act.id,
onRenamePressed: null,
width: 220,
align: 'right',
),
],
);
}
}
/// 章节卡片组件 - 自适应高度显示章节及其场景
// 已弃用_ChapterCard使用 _OptimizedChapterCard 取代)
/// 章节标题头部
class _ChapterHeader extends StatelessWidget {
const _ChapterHeader({
required this.actId,
required this.chapter,
required this.editorBloc,
});
final String actId;
final novel_models.Chapter chapter;
final editor.EditorBloc editorBloc;
@override
Widget build(BuildContext context) {
// 计算总字数
final totalWords = chapter.scenes.fold<int>(
0,
(sum, scene) => sum + (scene.content.length),
);
return Container(
height: 30, // 🚀 修改:设置固定高度,章节头部缩短为原来的三分之一
padding: const EdgeInsets.fromLTRB(8, 0, 4, 0), // 🚀 修改:去掉垂直内边距,使用固定高度
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: WebTheme.grey200)),
),
child: Row(
children: [
// 拖拽手柄
Icon(Icons.drag_indicator, size: 14, color: WebTheme.getSecondaryTextColor(context)),
const SizedBox(width: 6),
// 章节标题(可编辑)
Expanded(
child: EditableTitle(
initialText: chapter.title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: WebTheme.getTextColor(context),
),
// 仅在提交(回车或失焦)时派发更新
onSubmitted: (value) {
editorBloc.add(editor.UpdateChapterTitle(
actId: actId,
chapterId: chapter.id,
title: value,
));
},
),
),
// 字数统计
if (totalWords > 0) ...[
Text(
'$totalWords Words',
style: TextStyle(
fontSize: 11,
color: WebTheme.getSecondaryTextColor(context),
),
),
const SizedBox(width: 8),
],
// 编辑按钮
_SmallIconButton(
icon: Icons.edit,
tooltip: '编辑章节',
onPressed: () {
_showEditDialog(
context: context,
title: '编辑章节标题',
initialValue: chapter.title,
onSave: (newTitle) {
editorBloc.add(editor.UpdateChapterTitle(
actId: actId,
chapterId: chapter.id,
title: newTitle,
));
},
);
},
),
// 更多操作(统一下拉样式)
MenuBuilder.buildChapterMenu(
context: context,
editorBloc: editorBloc,
actId: actId,
chapterId: chapter.id,
onRenamePressed: null,
width: 220,
align: 'right',
),
],
),
);
}
}
/// 场景项组件 - 单个场景的显示和交互
class _SceneItem extends StatefulWidget {
const _SceneItem({
required this.actId,
required this.chapterId,
required this.scene,
required this.sceneNumber,
required this.novelId,
required this.editorBloc,
});
final String actId;
final String chapterId;
final novel_models.Scene scene;
final int sceneNumber;
final String novelId;
final editor.EditorBloc editorBloc;
@override
State<_SceneItem> createState() => _SceneItemState();
}
class _SceneItemState extends State<_SceneItem> {
late TextEditingController _summaryController;
bool _isEditing = true;
bool _hasUnsavedChanges = false;
@override
void initState() {
super.initState();
_summaryController = TextEditingController(text: widget.scene.summary.content);
_summaryController.addListener(_onSummaryChanged);
}
@override
void dispose() {
_summaryController.dispose();
super.dispose();
}
void _onSummaryChanged() {
final hasChanges = _summaryController.text != widget.scene.summary.content;
if (hasChanges != _hasUnsavedChanges) {
setState(() {
_hasUnsavedChanges = hasChanges;
});
}
}
void _saveSummary() {
if (_hasUnsavedChanges) {
// 🚀 修改使用EditorBloc的UpdateSummary事件
widget.editorBloc.add(editor.UpdateSummary(
novelId: widget.novelId,
actId: widget.actId,
chapterId: widget.chapterId,
sceneId: widget.scene.id,
summary: _summaryController.text,
));
setState(() {
_hasUnsavedChanges = false;
_isEditing = false;
});
}
}
void _navigateToScene() {
AppLogger.i('PlanView', '准备跳转到场景: ${widget.actId} - ${widget.chapterId} - ${widget.scene.id}');
// 🚀 修改使用EditorBloc的NavigateToSceneFromPlan事件
widget.editorBloc.add(editor.NavigateToSceneFromPlan(
actId: widget.actId,
chapterId: widget.chapterId,
sceneId: widget.scene.id,
));
Future.delayed(const Duration(milliseconds: 300), () {
// 跳转后可在外部触发切换
});
}
@override
Widget build(BuildContext context) {
final hasContent = widget.scene.summary.content.isNotEmpty;
final wordCount = widget.scene.content.length;
return Container(
margin: const EdgeInsets.only(bottom: 6),
decoration: BoxDecoration(
color: WebTheme.getSurfaceColor(context),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: WebTheme.isDarkMode(context) ? WebTheme.darkGrey300 : WebTheme.grey200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// 工具栏区域 - 动态背景
Container(
height: 27, // 🚀 修改:设置固定高度,场景头部比章节头部稍小
decoration: BoxDecoration(
color: WebTheme.isDarkMode(context) ? WebTheme.darkGrey100 : WebTheme.grey50,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(6),
topRight: Radius.circular(6),
),
),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0), // 🚀 修改:去掉垂直内边距,使用固定高度
child: Row(
children: [
// 拖拽手柄
Icon(Icons.drag_indicator, size: 12, color: WebTheme.getSecondaryTextColor(context)),
const SizedBox(width: 4),
// 场景标签
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: WebTheme.getSurfaceColor(context),
borderRadius: BorderRadius.circular(3),
),
child: Text(
'Scene ${widget.sceneNumber}',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: WebTheme.getSecondaryTextColor(context),
),
),
),
const SizedBox(width: 8),
// 字数统计(如果有)
if (wordCount > 0) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: WebTheme.getPrimaryColor(context).withOpacity(0.05),
borderRadius: BorderRadius.circular(2),
border: Border.all(color: WebTheme.getPrimaryColor(context).withOpacity(0.2), width: 0.5),
),
child: Text(
'$wordCount Words',
style: TextStyle(
fontSize: 9,
color: WebTheme.getPrimaryColor(context),
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 8),
],
const Spacer(),
// 保存指示器
if (_hasUnsavedChanges) ...[
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: WebTheme.warning,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
GestureDetector(
onTap: _saveSummary,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: WebTheme.success,
borderRadius: BorderRadius.circular(3),
),
child: Text(
'保存',
style: TextStyle(
fontSize: 9,
color: WebTheme.isDarkMode(context) ? Colors.white : Colors.white,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(width: 8),
],
// 跳转按钮
_SmallIconButton(
icon: Icons.launch,
size: 12,
tooltip: '跳转到场景',
onPressed: _navigateToScene,
),
const SizedBox(width: 4),
// 编辑切换按钮
_SmallIconButton(
icon: _isEditing ? Icons.visibility : Icons.edit,
size: 12,
tooltip: _isEditing ? '预览模式' : '编辑模式',
onPressed: () {
setState(() {
_isEditing = !_isEditing;
});
},
),
const SizedBox(width: 4),
// 更多操作菜单
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, size: 12, color: Colors.black54),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 16, minHeight: 16),
offset: const Offset(-40, 16),
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
itemBuilder: (context) => [
const PopupMenuItem(
value: 'delete',
height: 30,
child: Row(
children: [
Icon(Icons.delete, size: 12, color: Colors.red),
SizedBox(width: 6),
Text('删除场景', style: TextStyle(fontSize: 11, color: Colors.red)),
],
),
),
],
onSelected: (value) {
if (value == 'delete') {
_showDeleteDialog(
context: context,
title: '删除场景',
content: '确定要删除此场景吗?',
onConfirm: () {
widget.editorBloc.add(editor.DeleteScene(
novelId: widget.novelId,
actId: widget.actId,
chapterId: widget.chapterId,
sceneId: widget.scene.id,
));
},
);
}
},
),
],
),
),
// 摘要内容区域 - 动态背景,支持直接编辑
Container(
width: double.infinity,
constraints: const BoxConstraints(
minHeight: 220,
),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: WebTheme.getSurfaceColor(context),
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(6),
bottomRight: Radius.circular(6),
),
),
child: _isEditing
? WebTheme.getMaterialWrapper(
child: TextField(
controller: _summaryController,
decoration: WebTheme.getBorderlessInputDecoration(
hintText: '输入场景摘要...',
context: context,
),
style: TextStyle(
fontSize: 18,
color: WebTheme.getTextColor(context),
height: 1.8,
),
maxLines: null,
minLines: 5,
onSubmitted: (_) => _saveSummary(),
),
)
: GestureDetector(
onTap: () {
setState(() {
_isEditing = true;
});
},
child: Container(
width: double.infinity,
child: hasContent
? Text(
widget.scene.summary.content,
style: TextStyle(
fontSize: 18,
color: WebTheme.getTextColor(context),
height: 1.8,
),
)
: Text(
'点击这里添加场景描述...',
style: TextStyle(
fontSize: 18,
color: WebTheme.getSecondaryTextColor(context),
fontStyle: FontStyle.italic,
height: 1.8,
),
),
),
),
),
// 底部按钮区域 - 浅灰色背景
Container(
decoration: BoxDecoration(
color: WebTheme.getSurfaceColor(context),
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(6),
bottomRight: Radius.circular(6),
),
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
_SmallButton(
icon: Icons.add,
label: 'Codex',
onPressed: () {
// TODO(plan): 添加Codex功能
},
),
const SizedBox(width: 8),
_SmallButton(
icon: Icons.label,
label: 'Label',
onPressed: () {
// TODO(plan): 添加标签功能
},
),
],
),
),
],
),
);
}
}
/// 添加场景按钮
class _AddSceneButton extends StatelessWidget {
const _AddSceneButton({
required this.actId,
required this.chapterId,
required this.editorBloc,
});
final String actId;
final String chapterId;
final editor.EditorBloc editorBloc;
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
icon: Icon(Icons.add, size: 14, color: WebTheme.getSecondaryTextColor(context)),
label: Text(
'New Scene',
style: TextStyle(fontSize: 12, color: WebTheme.getSecondaryTextColor(context)),
),
style: OutlinedButton.styleFrom(
side: BorderSide(color: WebTheme.isDarkMode(context) ? WebTheme.darkGrey300 : Colors.grey.shade300),
padding: const EdgeInsets.symmetric(vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
),
onPressed: () {
editorBloc.add(editor.AddNewScene(
novelId: '',
actId: actId,
chapterId: chapterId,
sceneId: 'scene_${DateTime.now().millisecondsSinceEpoch}',
));
},
),
);
}
}
/// 添加章节卡片
class _AddChapterCard extends StatelessWidget {
const _AddChapterCard({
required this.actId,
required this.editorBloc,
});
final String actId;
final editor.EditorBloc editorBloc;
@override
Widget build(BuildContext context) {
return Container(
height: 200,
decoration: BoxDecoration(
color: WebTheme.getSurfaceColor(context),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: WebTheme.isDarkMode(context) ? WebTheme.darkGrey300 : Colors.grey.shade300,
style: BorderStyle.solid,
),
),
child: Material(
color: WebTheme.getSurfaceColor(context),
borderRadius: BorderRadius.circular(8),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () {
editorBloc.add(editor.AddNewChapter(
novelId: '',
actId: actId,
));
},
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add_circle_outline, size: 28, color: WebTheme.getSecondaryTextColor(context)),
const SizedBox(height: 8),
Text(
'新章节',
style: TextStyle(fontSize: 13, color: WebTheme.getSecondaryTextColor(context)),
),
],
),
),
),
),
);
}
}
// 已弃用_LazyChapterGrid被虚拟化布局替代
// 已弃用_LazyWrapLayout被虚拟化布局替代
/// 添加Act按钮
class _AddActButton extends StatelessWidget {
const _AddActButton({required this.editorBloc});
final editor.EditorBloc editorBloc;
@override
Widget build(BuildContext context) {
return Center(
child: OutlinedButton.icon(
icon: Icon(Icons.add, color: WebTheme.getSecondaryTextColor(context)),
label: Text(
'添加新Act',
style: TextStyle(color: WebTheme.getSecondaryTextColor(context)),
),
style: OutlinedButton.styleFrom(
side: BorderSide(color: WebTheme.isDarkMode(context) ? WebTheme.darkGrey400 : Colors.grey.shade400),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
onPressed: () {
editorBloc.add(const editor.AddNewAct());
},
),
);
}
}
/// 底部工具栏
class _PlanToolbar extends StatelessWidget {
const _PlanToolbar({required this.editorBloc});
final editor.EditorBloc editorBloc;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
// 使用动态背景色,兼容暗黑 / 亮色
color: WebTheme.getSurfaceColor(context),
border: Border(
top: BorderSide(
color: WebTheme.isDarkMode(context) ? WebTheme.darkGrey300 : Colors.grey.shade200,
),
),
),
child: Row(
children: [
_ToolbarButton(
icon: Icons.add_box_outlined,
label: '添加Act',
onPressed: () => editorBloc.add(const editor.AddNewAct()),
),
const SizedBox(width: 12),
_ToolbarButton(
icon: Icons.format_list_numbered,
label: '大纲设置',
onPressed: () {
// TODO(plan): 实现大纲设置
},
),
const SizedBox(width: 12),
_ToolbarButton(
icon: Icons.filter_alt_outlined,
label: '筛选',
onPressed: () {
// TODO(plan): 实现筛选功能
},
),
const SizedBox(width: 12),
_ToolbarButton(
icon: Icons.settings_outlined,
label: '选项',
onPressed: () {
// TODO(plan): 实现选项功能
},
),
],
),
);
}
}
/// 通用小图标按钮组件
class _SmallIconButton extends StatelessWidget {
const _SmallIconButton({
required this.icon,
required this.onPressed,
this.tooltip,
this.size = 14,
});
final IconData icon;
final VoidCallback onPressed;
final String? tooltip;
final double size;
@override
Widget build(BuildContext context) {
final button = IconButton(
icon: Icon(icon, size: size, color: WebTheme.getSecondaryTextColor(context)),
onPressed: onPressed,
padding: EdgeInsets.zero,
constraints: BoxConstraints(minWidth: size + 8, minHeight: size + 8),
);
return tooltip != null
? Tooltip(message: tooltip!, child: button)
: button;
}
}
/// 通用小按钮组件
class _SmallButton extends StatelessWidget {
const _SmallButton({
required this.icon,
required this.label,
required this.onPressed,
});
final IconData icon;
final String label;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return OutlinedButton.icon(
icon: Icon(icon, size: 12, color: WebTheme.getSecondaryTextColor(context)),
label: Text(
label,
style: TextStyle(fontSize: 10, color: WebTheme.getSecondaryTextColor(context)),
),
style: OutlinedButton.styleFrom(
side: BorderSide(color: WebTheme.isDarkMode(context) ? WebTheme.darkGrey300 : Colors.grey.shade300),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
minimumSize: const Size(0, 24),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
),
onPressed: onPressed,
);
}
}
/// 工具栏按钮组件
class _ToolbarButton extends StatelessWidget {
const _ToolbarButton({
required this.icon,
required this.label,
required this.onPressed,
});
final IconData icon;
final String label;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return TextButton.icon(
icon: Icon(icon, size: 16, color: WebTheme.getSecondaryTextColor(context)),
label: Text(
label,
style: TextStyle(fontSize: 13, color: WebTheme.getSecondaryTextColor(context)),
),
onPressed: onPressed,
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
);
}
}
/// 显示编辑对话框的通用函数
void _showEditDialog({
required BuildContext context,
required String title,
required String initialValue,
required Function(String) onSave,
}) {
final controller = TextEditingController(text: initialValue);
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: WebTheme.getSurfaceColor(context),
title: Text(title, style: const TextStyle(fontSize: 16)),
content: TextField(
controller: controller,
decoration: InputDecoration(
border: OutlineInputBorder(
borderSide: BorderSide(color: Theme.of(context).colorScheme.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: WebTheme.getPrimaryColor(context)),
),
),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('取消', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)),
),
ElevatedButton(
onPressed: () {
if (controller.text.trim().isNotEmpty) {
onSave(controller.text.trim());
Navigator.pop(context);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: WebTheme.getPrimaryColor(context),
foregroundColor: WebTheme.white,
),
child: const Text('保存'),
),
],
),
);
}
/// 显示删除确认对话框的通用函数
void _showDeleteDialog({
required BuildContext context,
required String title,
required String content,
required VoidCallback onConfirm,
}) {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: WebTheme.getSurfaceColor(context),
title: Text(title, style: const TextStyle(fontSize: 16)),
content: Text(content, style: const TextStyle(fontSize: 14)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('取消', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
onConfirm();
},
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
child: const Text('删除'),
),
],
),
);
}
/// 完全虚拟化的Plan视图 - 极致性能优化
class _VirtualizedPlanView extends StatelessWidget {
const _VirtualizedPlanView({
required this.novel,
required this.novelId,
required this.editorBloc,
this.onSwitchToWrite,
required this.scrollController,
});
final novel_models.Novel novel;
final String novelId;
final editor.EditorBloc editorBloc;
final VoidCallback? onSwitchToWrite;
final ScrollController scrollController;
@override
Widget build(BuildContext context) {
// 将所有内容展平为一个线性列表,实现真正的虚拟化滚动
final List<_PlanItem> items = _buildFlatItemList();
return CustomScrollView(
controller: scrollController,
cacheExtent: 200.0, // 减少缓存范围,提高性能
slivers: [
SliverPadding(
padding: const EdgeInsets.all(16.0),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index >= items.length) return null;
final item = items[index];
return _buildItemWidget(context, item);
},
childCount: items.length,
),
),
),
],
);
}
/// 构建展平的项目列表
List<_PlanItem> _buildFlatItemList() {
final List<_PlanItem> items = [];
for (int actIndex = 0; actIndex < novel.acts.length; actIndex++) {
final act = novel.acts[actIndex];
// 添加Act标题项
items.add(_PlanItem(
type: _PlanItemType.actHeader,
act: act,
actIndex: actIndex,
));
// 添加章节项分批处理每批最多10个章节
const int batchSize = 10;
for (int batchStart = 0; batchStart < act.chapters.length; batchStart += batchSize) {
final batchEnd = (batchStart + batchSize).clamp(0, act.chapters.length);
final batchChapters = act.chapters.sublist(batchStart, batchEnd);
items.add(_PlanItem(
type: _PlanItemType.chapterBatch,
act: act,
chapters: batchChapters,
batchStart: batchStart,
));
}
// 添加"添加章节"按钮
items.add(_PlanItem(
type: _PlanItemType.addChapter,
act: act,
));
}
// 添加"添加Act"按钮
items.add(_PlanItem(
type: _PlanItemType.addAct,
));
return items;
}
/// 构建单个项目的Widget
Widget _buildItemWidget(BuildContext context, _PlanItem item) {
switch (item.type) {
case _PlanItemType.actHeader:
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: _ActHeader(
act: item.act!,
novelId: novelId,
editorBloc: editorBloc,
),
);
case _PlanItemType.chapterBatch:
return Padding(
padding: const EdgeInsets.only(bottom: 16.0, left: 16.0),
child: _ChapterBatchWidget(
act: item.act!,
chapters: item.chapters!,
novelId: novelId,
editorBloc: editorBloc,
),
);
case _PlanItemType.addChapter:
return Padding(
padding: const EdgeInsets.only(bottom: 16.0, left: 16.0),
child: SizedBox(
width: 450,
child: _AddChapterCard(
actId: item.act!.id,
editorBloc: editorBloc,
),
),
);
case _PlanItemType.addAct:
return Padding(
padding: const EdgeInsets.only(top: 16.0),
child: _AddActButton(editorBloc: editorBloc),
);
}
}
}
/// 章节批次Widget - 一次显示一批章节
class _ChapterBatchWidget extends StatelessWidget {
const _ChapterBatchWidget({
required this.act,
required this.chapters,
required this.novelId,
required this.editorBloc,
});
final novel_models.Act act;
final List<novel_models.Chapter> chapters;
final String novelId;
final editor.EditorBloc editorBloc;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
// 计算每行可以放多少个卡片
const itemWidth = 450.0;
const spacing = 16.0;
final availableWidth = constraints.maxWidth;
final itemsPerRow = ((availableWidth + spacing) / (itemWidth + spacing)).floor().clamp(1, 10);
// 计算行数
final totalRows = (chapters.length / itemsPerRow).ceil();
return Column(
children: List.generate(totalRows, (rowIndex) {
final startIndex = rowIndex * itemsPerRow;
final endIndex = (startIndex + itemsPerRow).clamp(0, chapters.length);
return Padding(
padding: EdgeInsets.only(bottom: rowIndex < totalRows - 1 ? 16.0 : 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (int i = startIndex; i < endIndex; i++) ...[
if (i > startIndex) const SizedBox(width: 16.0),
SizedBox(
width: 450,
child: _OptimizedChapterCard(
actId: act.id,
chapter: chapters[i],
novelId: novelId,
editorBloc: editorBloc,
),
),
],
const Spacer(),
],
),
);
}),
);
},
);
}
}
/// 优化的章节卡片 - 保持原有功能但提升性能
class _OptimizedChapterCard extends StatelessWidget {
const _OptimizedChapterCard({
required this.actId,
required this.chapter,
required this.novelId,
required this.editorBloc,
});
final String actId;
final novel_models.Chapter chapter;
final String novelId;
final editor.EditorBloc editorBloc;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: WebTheme.getSurfaceColor(context),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: WebTheme.isDarkMode(context) ? WebTheme.darkGrey300 : Colors.grey.shade300,
style: BorderStyle.solid,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.04),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
// 章节标题栏
_ChapterHeader(
actId: actId,
chapter: chapter,
editorBloc: editorBloc,
),
// 场景列表 - 优化版本,限制显示数量
Container(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 场景列表 - 限制最多显示5个场景以提升性能
...chapter.scenes.take(5).toList().asMap().entries.map((entry) =>
_OptimizedSceneItem(
actId: actId,
chapterId: chapter.id,
scene: entry.value,
sceneNumber: entry.key + 1,
novelId: novelId,
editorBloc: editorBloc,
),
),
// 如果有更多场景,显示省略提示
if (chapter.scenes.length > 5) ...[
Container(
margin: const EdgeInsets.only(top: 6),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: WebTheme.isDarkMode(context) ? WebTheme.darkGrey100 : WebTheme.grey100,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'还有 ${chapter.scenes.length - 5} 个场景...',
style: TextStyle(
fontSize: 11,
color: WebTheme.getSecondaryTextColor(context),
fontStyle: FontStyle.italic,
),
),
),
],
const SizedBox(height: 8),
_AddSceneButton(
actId: actId,
chapterId: chapter.id,
editorBloc: editorBloc,
),
],
),
),
],
),
);
}
}
/// 优化的场景项组件 - 简化版本但保持核心功能
class _OptimizedSceneItem extends StatefulWidget {
const _OptimizedSceneItem({
required this.actId,
required this.chapterId,
required this.scene,
required this.sceneNumber,
required this.novelId,
required this.editorBloc,
});
final String actId;
final String chapterId;
final novel_models.Scene scene;
final int sceneNumber;
final String novelId;
final editor.EditorBloc editorBloc;
@override
State<_OptimizedSceneItem> createState() => _OptimizedSceneItemState();
}
class _OptimizedSceneItemState extends State<_OptimizedSceneItem> {
late TextEditingController _summaryController;
bool _isEditing = true;
bool _hasUnsavedChanges = false;
@override
void initState() {
super.initState();
_summaryController = TextEditingController(text: widget.scene.summary.content);
_summaryController.addListener(_onSummaryChanged);
}
@override
void dispose() {
_summaryController.dispose();
super.dispose();
}
void _onSummaryChanged() {
final hasChanges = _summaryController.text != widget.scene.summary.content;
if (hasChanges != _hasUnsavedChanges) {
setState(() {
_hasUnsavedChanges = hasChanges;
});
}
}
void _saveSummary() {
if (_hasUnsavedChanges) {
widget.editorBloc.add(editor.UpdateSummary(
novelId: widget.novelId,
actId: widget.actId,
chapterId: widget.chapterId,
sceneId: widget.scene.id,
summary: _summaryController.text,
));
setState(() {
_hasUnsavedChanges = false;
_isEditing = false;
});
}
}
void _navigateToScene() {
widget.editorBloc.add(editor.NavigateToSceneFromPlan(
actId: widget.actId,
chapterId: widget.chapterId,
sceneId: widget.scene.id,
));
Future.delayed(const Duration(milliseconds: 300), () {
// 跳转后可在外部触发切换
});
}
@override
Widget build(BuildContext context) {
final hasContent = widget.scene.summary.content.isNotEmpty;
final wordCount = widget.scene.content.length;
return Container(
margin: const EdgeInsets.only(bottom: 6),
decoration: BoxDecoration(
color: WebTheme.getSurfaceColor(context),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: WebTheme.isDarkMode(context) ? WebTheme.darkGrey300 : WebTheme.grey200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// 工具栏区域 - 简化版
Container(
height: 24, // 减少高度
decoration: BoxDecoration(
color: WebTheme.isDarkMode(context) ? WebTheme.darkGrey100 : WebTheme.grey50,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(6),
topRight: Radius.circular(6),
),
),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 0),
child: Row(
children: [
// 场景标签
Container(
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(2),
),
child: Text(
'S${widget.sceneNumber}',
style: const TextStyle(
fontSize: 9,
fontWeight: FontWeight.w600,
color: Colors.black54,
),
),
),
const SizedBox(width: 6),
// 字数统计(如果有)
if (wordCount > 0) ...[
Text(
'${wordCount}w',
style: TextStyle(
fontSize: 9,
color: Colors.blue.shade600,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 6),
],
const Spacer(),
// 保存指示器
if (_hasUnsavedChanges) ...[
GestureDetector(
onTap: _saveSummary,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: Colors.green.shade600,
borderRadius: BorderRadius.circular(2),
),
child: const Text(
'保存',
style: TextStyle(
fontSize: 8,
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(width: 4),
],
// 跳转按钮
_SmallIconButton(
icon: Icons.launch,
size: 10,
onPressed: _navigateToScene,
),
// 编辑切换按钮
_SmallIconButton(
icon: _isEditing ? Icons.visibility : Icons.edit,
size: 10,
onPressed: () {
setState(() {
_isEditing = !_isEditing;
});
},
),
],
),
),
// 摘要内容区域 - 放大版
Container(
width: double.infinity,
constraints: const BoxConstraints(
minHeight: 200, // 再放大
),
padding: const EdgeInsets.all(8),
child: _isEditing
? TextField(
controller: _summaryController,
decoration: InputDecoration(
hintText: '输入场景摘要...',
border: InputBorder.none,
hintStyle: TextStyle(
fontSize: 18,
color: WebTheme.getSecondaryTextColor(context),
),
),
style: TextStyle(
fontSize: 18,
color: WebTheme.getTextColor(context),
height: 1.8,
),
maxLines: null,
minLines: 5,
onSubmitted: (_) => _saveSummary(),
)
: GestureDetector(
onTap: () {
setState(() {
_isEditing = true;
});
},
child: Container(
width: double.infinity,
child: hasContent
? Text(
widget.scene.summary.content,
style: TextStyle(
fontSize: 18,
color: WebTheme.getTextColor(context),
height: 1.8,
),
// 自适应高度,不再省略
)
: Text(
'点击添加场景描述...',
style: TextStyle(
fontSize: 18,
color: WebTheme.getSecondaryTextColor(context),
fontStyle: FontStyle.italic,
height: 1.8,
),
),
),
),
),
],
),
);
}
}
/// Plan项目类型枚举
enum _PlanItemType {
actHeader,
chapterBatch,
addChapter,
addAct,
}
/// Plan项目数据类
class _PlanItem {
const _PlanItem({
required this.type,
this.act,
this.chapters,
this.actIndex,
this.batchStart,
});
final _PlanItemType type;
final novel_models.Act? act;
final List<novel_models.Chapter>? chapters;
final int? actIndex;
final int? batchStart;
}