马良AI写作初始化仓库
This commit is contained in:
65
AINoval/lib/screens/editor/widgets/ai_chat_button.dart
Normal file
65
AINoval/lib/screens/editor/widgets/ai_chat_button.dart
Normal file
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../blocs/chat/chat_bloc.dart';
|
||||
import '../../../blocs/chat/chat_event.dart';
|
||||
import '../../../blocs/chat/chat_state.dart';
|
||||
|
||||
/// AI聊天按钮,用于在编辑器中打开AI聊天侧边栏
|
||||
class AIChatButton extends StatelessWidget {
|
||||
const AIChatButton({
|
||||
Key? key,
|
||||
required this.novelId,
|
||||
this.chapterId,
|
||||
required this.onPressed,
|
||||
this.isActive = false,
|
||||
}) : super(key: key);
|
||||
|
||||
final String novelId;
|
||||
final String? chapterId;
|
||||
final VoidCallback onPressed;
|
||||
final bool isActive;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<ChatBloc, ChatState>(
|
||||
builder: (context, state) {
|
||||
return IconButton(
|
||||
icon: Stack(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_outlined,
|
||||
color: isActive ? Colors.blue : Colors.black54,
|
||||
),
|
||||
if (state is ChatSessionActive && state.isGenerating)
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.green,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
tooltip: '打开AI聊天',
|
||||
onPressed: () {
|
||||
// 如果没有活动会话,创建一个新会话
|
||||
if (state is! ChatSessionActive) {
|
||||
context.read<ChatBloc>().add(CreateChatSession(
|
||||
title: 'New Chat',
|
||||
novelId: novelId,
|
||||
chapterId: chapterId,
|
||||
));
|
||||
}
|
||||
onPressed();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
1346
AINoval/lib/screens/editor/widgets/ai_generation_panel.dart
Normal file
1346
AINoval/lib/screens/editor/widgets/ai_generation_panel.dart
Normal file
File diff suppressed because it is too large
Load Diff
382
AINoval/lib/screens/editor/widgets/ai_generation_toolbar.dart
Normal file
382
AINoval/lib/screens/editor/widgets/ai_generation_toolbar.dart
Normal file
@@ -0,0 +1,382 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// AI生成工具栏
|
||||
/// 在流式输出文本时显示,提供Apply、Retry、Discard、Section等操作
|
||||
class AIGenerationToolbar extends StatefulWidget {
|
||||
const AIGenerationToolbar({
|
||||
super.key,
|
||||
required this.layerLink,
|
||||
required this.onApply,
|
||||
required this.onRetry,
|
||||
required this.onDiscard,
|
||||
required this.onSection,
|
||||
required this.wordCount,
|
||||
required this.modelName,
|
||||
this.isGenerating = false,
|
||||
this.onClosed,
|
||||
this.showAbove = false,
|
||||
this.onStop,
|
||||
this.offsetAbove = -60.0,
|
||||
this.offsetBelow = 30.0,
|
||||
});
|
||||
|
||||
/// 用于定位工具栏的层链接
|
||||
final LayerLink layerLink;
|
||||
|
||||
/// 应用生成的文本
|
||||
final VoidCallback onApply;
|
||||
|
||||
/// 重新生成
|
||||
final VoidCallback onRetry;
|
||||
|
||||
/// 丢弃生成的文本
|
||||
final VoidCallback onDiscard;
|
||||
|
||||
/// 分段功能
|
||||
final VoidCallback onSection;
|
||||
|
||||
/// 停止生成
|
||||
final VoidCallback? onStop;
|
||||
|
||||
/// 生成文本的字数
|
||||
final int wordCount;
|
||||
|
||||
/// 使用的模型名称
|
||||
final String modelName;
|
||||
|
||||
/// 是否正在生成中
|
||||
final bool isGenerating;
|
||||
|
||||
/// 工具栏关闭回调
|
||||
final VoidCallback? onClosed;
|
||||
|
||||
/// 是否显示在上方
|
||||
final bool showAbove;
|
||||
|
||||
/// 上方显示时的Y偏移量
|
||||
final double offsetAbove;
|
||||
|
||||
/// 下方显示时的Y偏移量
|
||||
final double offsetBelow;
|
||||
|
||||
@override
|
||||
State<AIGenerationToolbar> createState() => _AIGenerationToolbarState();
|
||||
}
|
||||
|
||||
class _AIGenerationToolbarState extends State<AIGenerationToolbar> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final isLight = !isDark;
|
||||
|
||||
return CompositedTransformFollower(
|
||||
link: widget.layerLink,
|
||||
offset: widget.showAbove ? Offset(0, widget.offsetAbove) : Offset(0, widget.offsetBelow),
|
||||
followerAnchor: Alignment.topCenter,
|
||||
targetAnchor: Alignment.topCenter,
|
||||
showWhenUnlinked: false,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
opaque: true,
|
||||
hitTestBehavior: HitTestBehavior.opaque,
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 600),
|
||||
child: _buildToolbarContainer(isLightTheme: isLight),
|
||||
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建工具栏容器
|
||||
Widget _buildToolbarContainer({required bool isLightTheme}) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
// 统一使用 WebTheme 色系
|
||||
color: isLightTheme ? WebTheme.black : WebTheme.getSurfaceColor(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: WebTheme.getShadowColor(context, opacity: isLightTheme ? 0.3 : 0.1),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
border: Border.all(
|
||||
color: WebTheme.getSecondaryBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// 估算内容总宽度
|
||||
final contentWidth = _estimateContentWidth();
|
||||
|
||||
// 如果空间不足,使用垂直布局
|
||||
if (contentWidth > constraints.maxWidth && constraints.maxWidth > 0) {
|
||||
return _buildVerticalLayout(isLightTheme);
|
||||
} else {
|
||||
return _buildHorizontalLayout(isLightTheme);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建水平布局
|
||||
Widget _buildHorizontalLayout(bool isLightTheme) {
|
||||
return IntrinsicWidth(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 操作按钮区域
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildActionButton(
|
||||
icon: Icons.check,
|
||||
label: 'Apply',
|
||||
tooltip: '应用生成的文本',
|
||||
onPressed: widget.isGenerating ? null : widget.onApply,
|
||||
),
|
||||
if (widget.isGenerating && widget.onStop != null)
|
||||
_buildActionButton(
|
||||
icon: Icons.stop,
|
||||
label: 'Stop',
|
||||
tooltip: '停止生成',
|
||||
onPressed: widget.onStop,
|
||||
)
|
||||
else
|
||||
_buildActionButton(
|
||||
icon: Icons.refresh,
|
||||
label: 'Retry',
|
||||
tooltip: '重新生成',
|
||||
onPressed: widget.isGenerating ? null : widget.onRetry,
|
||||
),
|
||||
_buildActionButton(
|
||||
icon: Icons.close,
|
||||
label: 'Discard',
|
||||
tooltip: widget.isGenerating ? '停止并丢弃生成的文本' : '丢弃生成的文本',
|
||||
onPressed: widget.onDiscard,
|
||||
),
|
||||
_buildActionButton(
|
||||
icon: Icons.crop_free,
|
||||
label: 'Section',
|
||||
tooltip: '分段处理',
|
||||
onPressed: widget.isGenerating ? null : widget.onSection,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 分隔线
|
||||
Container(
|
||||
width: 1,
|
||||
height: 32,
|
||||
color: WebTheme.getSecondaryBorderColor(context),
|
||||
),
|
||||
// 信息区域
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: _buildInfoContent(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建垂直布局(当空间不足时)
|
||||
Widget _buildVerticalLayout(bool isLightTheme) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 操作按钮区域
|
||||
Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildActionButton(
|
||||
icon: Icons.check,
|
||||
label: 'Apply',
|
||||
tooltip: '应用生成的文本',
|
||||
onPressed: widget.isGenerating ? null : widget.onApply,
|
||||
),
|
||||
if (widget.isGenerating && widget.onStop != null)
|
||||
_buildActionButton(
|
||||
icon: Icons.stop,
|
||||
label: 'Stop',
|
||||
tooltip: '停止生成',
|
||||
onPressed: widget.onStop,
|
||||
)
|
||||
else
|
||||
_buildActionButton(
|
||||
icon: Icons.refresh,
|
||||
label: 'Retry',
|
||||
tooltip: '重新生成',
|
||||
onPressed: widget.isGenerating ? null : widget.onRetry,
|
||||
),
|
||||
_buildActionButton(
|
||||
icon: Icons.close,
|
||||
label: 'Discard',
|
||||
tooltip: widget.isGenerating ? '停止并丢弃生成的文本' : '丢弃生成的文本',
|
||||
onPressed: widget.onDiscard,
|
||||
),
|
||||
_buildActionButton(
|
||||
icon: Icons.crop_free,
|
||||
label: 'Section',
|
||||
tooltip: '分段处理',
|
||||
onPressed: widget.isGenerating ? null : widget.onSection,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// 分隔线
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 1,
|
||||
color: WebTheme.getSecondaryBorderColor(context),
|
||||
),
|
||||
// 信息区域
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: _buildInfoContent(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建信息内容
|
||||
Widget _buildInfoContent() {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.isGenerating) ...[
|
||||
const SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1.5,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(WebTheme.white),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'生成中...',
|
||||
style: const TextStyle(
|
||||
color: WebTheme.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Flexible(
|
||||
child: Text(
|
||||
'${widget.wordCount} Words',
|
||||
style: const TextStyle(
|
||||
color: WebTheme.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
', ',
|
||||
style: TextStyle(
|
||||
color: WebTheme.white,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.modelName,
|
||||
style: const TextStyle(
|
||||
color: WebTheme.white,
|
||||
fontSize: 12,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 估算内容总宽度
|
||||
double _estimateContentWidth() {
|
||||
// 操作按钮: 4个按钮 * 80px ≈ 320px
|
||||
// 分隔线: 1px
|
||||
// 信息区域: 约150px
|
||||
// 内边距: 约30px
|
||||
return 320 + 1 + 150 + 30; // ≈ 501px
|
||||
}
|
||||
|
||||
/// 构建操作按钮
|
||||
Widget _buildActionButton({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required String tooltip,
|
||||
required VoidCallback? onPressed,
|
||||
}) {
|
||||
final isEnabled = onPressed != null;
|
||||
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: MouseRegion(
|
||||
cursor: isEnabled ? SystemMouseCursors.click : SystemMouseCursors.forbidden,
|
||||
opaque: true,
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 14,
|
||||
color: isEnabled
|
||||
? WebTheme.white
|
||||
: WebTheme.white,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isEnabled
|
||||
? WebTheme.white
|
||||
: WebTheme.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:ainoval/blocs/editor/editor_bloc.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
|
||||
/// AI场景生成侧边栏,用于显示从摘要生成的场景内容
|
||||
class AISceneGenerationSidePanel extends StatefulWidget {
|
||||
const AISceneGenerationSidePanel({
|
||||
Key? key,
|
||||
required this.onClose,
|
||||
required this.onInsert,
|
||||
}) : super(key: key);
|
||||
|
||||
/// 关闭面板时的回调
|
||||
final VoidCallback onClose;
|
||||
|
||||
/// 插入内容到编辑器的回调
|
||||
final Function(String content) onInsert;
|
||||
|
||||
@override
|
||||
State<AISceneGenerationSidePanel> createState() => _AISceneGenerationSidePanelState();
|
||||
}
|
||||
|
||||
class _AISceneGenerationSidePanelState extends State<AISceneGenerationSidePanel> {
|
||||
/// 编辑器控制器
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
|
||||
/// 滚动控制器
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
/// 是否已滚动到底部
|
||||
bool _isScrolledToBottom = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// 监听滚动事件,判断是否在底部
|
||||
_scrollController.addListener(_scrollListener);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_scrollController.removeListener(_scrollListener);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 滚动监听器,判断是否在底部
|
||||
void _scrollListener() {
|
||||
if (_scrollController.hasClients) {
|
||||
final isBottom = _scrollController.position.pixels >=
|
||||
_scrollController.position.maxScrollExtent - 50;
|
||||
if (isBottom != _isScrolledToBottom) {
|
||||
setState(() {
|
||||
_isScrolledToBottom = isBottom;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 复制内容到剪贴板
|
||||
void _copyToClipboard() {
|
||||
Clipboard.setData(ClipboardData(text: _controller.text)).then((_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('内容已复制到剪贴板')),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocConsumer<EditorBloc, EditorState>(
|
||||
listener: (context, state) {
|
||||
if (state is EditorLoaded && state.generatedSceneContent != null) {
|
||||
// 更新编辑器内容
|
||||
_controller.text = state.generatedSceneContent!;
|
||||
|
||||
// 如果用户滚动在底部,自动滚动到最新内容
|
||||
if (_isScrolledToBottom && _scrollController.hasClients) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state is! EditorLoaded) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final editorState = state as EditorLoaded;
|
||||
final isGenerating = editorState.aiSceneGenerationStatus == AIGenerationStatus.generating;
|
||||
final isCompleted = editorState.aiSceneGenerationStatus == AIGenerationStatus.completed;
|
||||
final isFailed = editorState.aiSceneGenerationStatus == AIGenerationStatus.failed;
|
||||
|
||||
return Container(
|
||||
width: 350, // 固定宽度
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(-2, 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题栏
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getPrimaryColor(context).withOpacity(0.1),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'AI 生成的场景',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const Spacer(),
|
||||
// 状态显示
|
||||
if (isGenerating)
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
WebTheme.getPrimaryColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'正在生成...',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
],
|
||||
)
|
||||
else if (isCompleted)
|
||||
const Text(
|
||||
'已完成',
|
||||
style: TextStyle(fontSize: 12, color: Colors.green),
|
||||
)
|
||||
else if (isFailed)
|
||||
const Text(
|
||||
'生成失败',
|
||||
style: TextStyle(fontSize: 12, color: Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 内容区域
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
// 文本编辑器
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
scrollController: _scrollController,
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
decoration: const InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText: '生成的内容将显示在这里...',
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 错误信息
|
||||
if (isFailed && editorState.aiGenerationError != null)
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.red.shade200),
|
||||
),
|
||||
child: Text(
|
||||
'错误: ${editorState.aiGenerationError}',
|
||||
style: TextStyle(
|
||||
color: Colors.red.shade800,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 操作栏
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
// 复制按钮
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy),
|
||||
tooltip: '复制内容',
|
||||
onPressed: _controller.text.isNotEmpty
|
||||
? _copyToClipboard
|
||||
: null,
|
||||
),
|
||||
// 插入原文按钮
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
tooltip: '插入到编辑器',
|
||||
onPressed: (isCompleted || !isGenerating) && _controller.text.isNotEmpty
|
||||
? () => widget.onInsert(_controller.text)
|
||||
: null,
|
||||
),
|
||||
// 停止生成按钮
|
||||
if (isGenerating)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.stop_circle_outlined),
|
||||
tooltip: '停止生成',
|
||||
onPressed: () {
|
||||
context.read<EditorBloc>().add(const StopSceneGeneration());
|
||||
},
|
||||
),
|
||||
// 关闭按钮
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: '关闭',
|
||||
onPressed: widget.onClose,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,787 @@
|
||||
// import 'dart:math'; // Added for min function
|
||||
import 'package:ainoval/screens/editor/widgets/floating_setting_dialogs.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:ainoval/models/novel_setting_item.dart';
|
||||
import 'package:ainoval/models/setting_type.dart'; // Your SettingType enum
|
||||
import 'package:ainoval/blocs/ai_setting_generation/ai_setting_generation_bloc.dart'; // Correct BLoC import
|
||||
import 'package:ainoval/models/novel_structure.dart'; // Import for Chapter model
|
||||
import 'package:ainoval/services/api_service/repositories/editor_repository.dart'; // Import EditorRepository
|
||||
import 'package:ainoval/services/api_service/repositories/novel_ai_repository.dart'; // Needed for BLoC creation
|
||||
import 'package:ainoval/blocs/setting/setting_bloc.dart';
|
||||
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
|
||||
// Removed placeholder BLoC, State, and Event definitions
|
||||
|
||||
class AISettingGenerationPanel extends StatelessWidget {
|
||||
final String novelId;
|
||||
final VoidCallback onClose;
|
||||
final bool isCardMode;
|
||||
final EditorRepository editorRepository; // Added
|
||||
final NovelAIRepository novelAIRepository; // Added
|
||||
|
||||
const AISettingGenerationPanel({
|
||||
Key? key,
|
||||
required this.novelId,
|
||||
required this.onClose,
|
||||
required this.editorRepository, // Added
|
||||
required this.novelAIRepository, // Added
|
||||
this.isCardMode = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<AISettingGenerationBloc>(
|
||||
create: (context) => AISettingGenerationBloc(
|
||||
editorRepository: editorRepository, // Changed from context.read
|
||||
novelAIRepository: novelAIRepository, // Changed from context.read
|
||||
)..add(LoadInitialDataForAISettingPanel(novelId)),
|
||||
child: AISettingGenerationView(novelId: novelId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AISettingGenerationView extends StatefulWidget {
|
||||
final String novelId;
|
||||
const AISettingGenerationView({Key? key, required this.novelId}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<AISettingGenerationView> createState() => _AISettingGenerationViewState();
|
||||
}
|
||||
|
||||
// 章节选择项数据模型
|
||||
class ChapterOption {
|
||||
final String id;
|
||||
final String title;
|
||||
final int order;
|
||||
final int globalOrder; // 全局排序序号
|
||||
final String actTitle;
|
||||
final int actOrder;
|
||||
|
||||
ChapterOption({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.order,
|
||||
required this.globalOrder,
|
||||
required this.actTitle,
|
||||
required this.actOrder,
|
||||
});
|
||||
|
||||
String get displayTitle {
|
||||
final chapterTitle = title.isNotEmpty ? title : '无标题章节';
|
||||
return '第${globalOrder}章 $chapterTitle';
|
||||
}
|
||||
|
||||
String get actDisplayTitle {
|
||||
return actTitle.isNotEmpty ? actTitle : '第${actOrder}卷';
|
||||
}
|
||||
}
|
||||
|
||||
class _AISettingGenerationViewState extends State<AISettingGenerationView> {
|
||||
String? _selectedStartChapterId;
|
||||
String? _selectedEndChapterId;
|
||||
final List<SettingTypeOption> _settingTypeOptions =
|
||||
SettingType.values.map((type) => SettingTypeOption(type)).toList();
|
||||
final _maxSettingsController = TextEditingController(text: '3');
|
||||
final _instructionsController = TextEditingController();
|
||||
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// 生成排序后的章节选项列表
|
||||
List<ChapterOption> _generateChapterOptions(List<Chapter> chapters, Novel? novel) {
|
||||
List<ChapterOption> options = [];
|
||||
int globalOrder = 1;
|
||||
|
||||
if (novel == null) {
|
||||
// 回退方案:没有Novel信息时,简单排序
|
||||
chapters.sort((a, b) => a.order.compareTo(b.order));
|
||||
for (final chapter in chapters) {
|
||||
options.add(ChapterOption(
|
||||
id: chapter.id,
|
||||
title: chapter.title,
|
||||
order: chapter.order,
|
||||
globalOrder: globalOrder++,
|
||||
actTitle: '',
|
||||
actOrder: 1,
|
||||
));
|
||||
}
|
||||
} else {
|
||||
// 有Novel信息时,按Act和章节顺序正确排序
|
||||
final sortedActs = novel.acts..sort((a, b) => a.order.compareTo(b.order));
|
||||
|
||||
for (final act in sortedActs) {
|
||||
final sortedChapters = act.chapters..sort((a, b) => a.order.compareTo(b.order));
|
||||
|
||||
for (final chapter in sortedChapters) {
|
||||
// 只处理在chapters列表中的章节(可能有过滤)
|
||||
if (chapters.any((c) => c.id == chapter.id)) {
|
||||
options.add(ChapterOption(
|
||||
id: chapter.id,
|
||||
title: chapter.title,
|
||||
order: chapter.order,
|
||||
globalOrder: globalOrder++,
|
||||
actTitle: act.title,
|
||||
actOrder: act.order,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_maxSettingsController.dispose();
|
||||
_instructionsController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 0), // Changed from 24, assuming MultiAIPanelView handles top padding for header
|
||||
child: Column(
|
||||
children: [
|
||||
_buildConfigurationArea(context, theme),
|
||||
const Divider(height: 1, thickness: 1),
|
||||
Expanded(child: _buildResultsArea(context, theme)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConfigurationArea(BuildContext context, ThemeData theme) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
BlocBuilder<AISettingGenerationBloc, AISettingGenerationState>(
|
||||
builder: (context, state) {
|
||||
List<Chapter> chapters = [];
|
||||
Novel? novel;
|
||||
bool isLoadingChapters = true;
|
||||
String? chapterLoadingError;
|
||||
|
||||
if (state is AISettingGenerationDataLoaded) {
|
||||
chapters = state.chapters;
|
||||
novel = state.novel;
|
||||
isLoadingChapters = false;
|
||||
} else if (state is AISettingGenerationSuccess) {
|
||||
chapters = state.chapters;
|
||||
novel = state.novel;
|
||||
isLoadingChapters = false;
|
||||
} else if (state is AISettingGenerationFailure) {
|
||||
chapters = state.chapters; // Might still have chapters from a previous successful load
|
||||
novel = state.novel;
|
||||
isLoadingChapters = false;
|
||||
if(chapters.isEmpty) chapterLoadingError = state.error; // Only show error if no chapters displayed
|
||||
} else if (state is AISettingGenerationLoadingChapters || state is AISettingGenerationInitial) {
|
||||
isLoadingChapters = true;
|
||||
} else {
|
||||
isLoadingChapters = false;
|
||||
}
|
||||
|
||||
if (isLoadingChapters) {
|
||||
return const Center(child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24.0),
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
));
|
||||
}
|
||||
if (chapterLoadingError != null) {
|
||||
return Center(child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: Text('加载章节失败: $chapterLoadingError', style: TextStyle(color: theme.colorScheme.error)),
|
||||
));
|
||||
}
|
||||
if (chapters.isEmpty) {
|
||||
return const Center(child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24.0),
|
||||
child: Text('没有可用的章节。'),
|
||||
));
|
||||
}
|
||||
|
||||
final chapterOptions = _generateChapterOptions(chapters, novel);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_buildChapterDropdown(
|
||||
context: context,
|
||||
theme: theme,
|
||||
label: '起始章节',
|
||||
value: _selectedStartChapterId,
|
||||
options: chapterOptions,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedStartChapterId = value;
|
||||
if (_selectedEndChapterId != null && _selectedStartChapterId != null) {
|
||||
final startOption = chapterOptions.firstWhere((opt) => opt.id == _selectedStartChapterId);
|
||||
final endOption = chapterOptions.firstWhere((opt) => opt.id == _selectedEndChapterId);
|
||||
if (endOption.globalOrder < startOption.globalOrder) {
|
||||
_selectedEndChapterId = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
validator: (value) => value == null ? '请选择起始章节' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildChapterDropdown(
|
||||
context: context,
|
||||
theme: theme,
|
||||
label: '结束章节 (可选)',
|
||||
value: _selectedEndChapterId,
|
||||
options: chapterOptions.where((option) {
|
||||
if (_selectedStartChapterId == null) return true;
|
||||
final startOption = chapterOptions.firstWhere((opt) => opt.id == _selectedStartChapterId);
|
||||
return option.globalOrder >= startOption.globalOrder;
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedEndChapterId = value;
|
||||
});
|
||||
},
|
||||
hasDefaultOption: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('希望生成的设定类型:', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8.0,
|
||||
runSpacing: 4.0,
|
||||
children: _settingTypeOptions.map((option) {
|
||||
return FilterChip(
|
||||
label: Text(option.type.displayName, style: const TextStyle(fontSize: 12)),
|
||||
selected: option.isSelected,
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
option.isSelected = selected;
|
||||
});
|
||||
},
|
||||
checkmarkColor: option.isSelected ? theme.colorScheme.onPrimary : null,
|
||||
selectedColor: WebTheme.getPrimaryColor(context),
|
||||
labelStyle: TextStyle(
|
||||
color: option.isSelected ? theme.colorScheme.onPrimary : theme.textTheme.bodySmall?.color,
|
||||
fontWeight: option.isSelected ? FontWeight.bold : FontWeight.normal),
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: option.isSelected ? WebTheme.getPrimaryColor(context) : theme.colorScheme.outline,
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _maxSettingsController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '每类生成数量 (1-5)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) return '请输入数量';
|
||||
final num = int.tryParse(value);
|
||||
if (num == null || num < 1 || num > 5) return '请输入1到5之间的数字';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _instructionsController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '其他说明或风格引导 (可选)',
|
||||
hintText: '例如:希望角色更神秘,或侧重描写地点的历史感',
|
||||
border: OutlineInputBorder(),
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
maxLines: 2,
|
||||
maxLength: 200,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Center(
|
||||
child: BlocBuilder<AISettingGenerationBloc, AISettingGenerationState>(
|
||||
builder: (context, state) {
|
||||
bool isLoading = state is AISettingGenerationInProgress;
|
||||
return ElevatedButton.icon(
|
||||
icon: isLoading
|
||||
? const SizedBox(width:16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||
: const Icon(Icons.auto_awesome_outlined, size: 18),
|
||||
label: Text(isLoading ? '生成中...' : '开始生成设定'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
textStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)
|
||||
),
|
||||
onPressed: isLoading ? null : () {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
final selectedTypes = _settingTypeOptions
|
||||
.where((opt) => opt.isSelected)
|
||||
.map((opt) => opt.type.value)
|
||||
.toList();
|
||||
if (selectedTypes.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('请至少选择一个设定类型'), backgroundColor: Colors.orange)
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (_selectedStartChapterId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('请选择起始章节'), backgroundColor: Colors.orange)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
context.read<AISettingGenerationBloc>().add(GenerateSettingsRequested(
|
||||
novelId: widget.novelId,
|
||||
startChapterId: _selectedStartChapterId!,
|
||||
endChapterId: _selectedEndChapterId,
|
||||
settingTypes: selectedTypes,
|
||||
maxSettingsPerType: int.parse(_maxSettingsController.text),
|
||||
additionalInstructions: _instructionsController.text,
|
||||
));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12), // Add some bottom padding
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChapterDropdown({
|
||||
required BuildContext context,
|
||||
required ThemeData theme,
|
||||
required String label,
|
||||
required String? value,
|
||||
required List<ChapterOption> options,
|
||||
required ValueChanged<String?> onChanged,
|
||||
String? Function(String?)? validator,
|
||||
bool hasDefaultOption = false,
|
||||
}) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: theme.dividerColor),
|
||||
),
|
||||
child: DropdownButtonFormField<String>(
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
labelStyle: TextStyle(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
value: value,
|
||||
isExpanded: true, // 确保下拉框内容完全显示
|
||||
icon: Icon(Icons.keyboard_arrow_down, color: theme.colorScheme.onSurfaceVariant),
|
||||
items: [
|
||||
if (hasDefaultOption)
|
||||
DropdownMenuItem<String>(
|
||||
value: null,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.auto_awesome, size: 18, color: WebTheme.getPrimaryColor(context)),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'到最新章节 (默认)',
|
||||
style: TextStyle(
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
...options.map((option) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: option.id,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
option.displayTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
if (option.actTitle.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
option.actDisplayTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
onChanged: onChanged,
|
||||
validator: validator,
|
||||
selectedItemBuilder: (BuildContext context) {
|
||||
return [
|
||||
if (hasDefaultOption)
|
||||
Text(
|
||||
'到最新章节 (默认)',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurface,
|
||||
fontSize: 14,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
...options.map((option) {
|
||||
return Text(
|
||||
option.displayTitle,
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurface,
|
||||
fontSize: 14,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
}).toList(),
|
||||
];
|
||||
},
|
||||
dropdownColor: theme.cardColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
elevation: 8,
|
||||
menuMaxHeight: 300, // 限制下拉菜单最大高度
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResultsArea(BuildContext context, ThemeData theme) {
|
||||
return BlocBuilder<AISettingGenerationBloc, AISettingGenerationState>(
|
||||
builder: (context, state) {
|
||||
if (state is AISettingGenerationInProgress) {
|
||||
return const Center(child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('正在分析章节并生成设定,请稍候...')
|
||||
],
|
||||
));
|
||||
}
|
||||
if (state is AISettingGenerationSuccess) {
|
||||
if (state.generatedSettings.isEmpty) {
|
||||
return const Center(child: Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Text('AI未能根据您的选择生成任何设定,请尝试调整选项或章节内容后再试。', textAlign: TextAlign.center,)
|
||||
));
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
itemCount: state.generatedSettings.length,
|
||||
itemBuilder: (context, index) {
|
||||
return NovelSettingItemCard(
|
||||
settingItem: state.generatedSettings[index],
|
||||
novelId: widget.novelId,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
if (state is AISettingGenerationFailure) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: theme.colorScheme.error, size: 48),
|
||||
const SizedBox(height:16),
|
||||
Text('生成设定时出错:', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height:8),
|
||||
Text(state.error, style: TextStyle(color: theme.colorScheme.error), textAlign: TextAlign.center,),
|
||||
const SizedBox(height:16),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
label: const Text('重试'),
|
||||
onPressed: (){
|
||||
if (_formKey.currentState!.validate()) {
|
||||
final selectedTypes = _settingTypeOptions
|
||||
.where((opt) => opt.isSelected)
|
||||
.map((opt) => opt.type.value)
|
||||
.toList();
|
||||
if (selectedTypes.isEmpty || _selectedStartChapterId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('请确保已选择起始章节和至少一个设定类型再重试。'), backgroundColor: Colors.orange)
|
||||
);
|
||||
return;
|
||||
}
|
||||
context.read<AISettingGenerationBloc>().add(GenerateSettingsRequested(
|
||||
novelId: widget.novelId,
|
||||
startChapterId: _selectedStartChapterId!,
|
||||
endChapterId: _selectedEndChapterId,
|
||||
settingTypes: selectedTypes,
|
||||
maxSettingsPerType: int.parse(_maxSettingsController.text),
|
||||
additionalInstructions: _instructionsController.text,
|
||||
));
|
||||
}
|
||||
}
|
||||
)
|
||||
],
|
||||
)
|
||||
),
|
||||
);
|
||||
}
|
||||
// Initial or other states
|
||||
return const Center(child: Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Text('请选择起始章节和希望生成的设定类型,然后点击"开始生成设定"按钮。', textAlign: TextAlign.center,)
|
||||
));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NovelSettingItemCard extends StatefulWidget {
|
||||
final NovelSettingItem settingItem;
|
||||
final String novelId;
|
||||
|
||||
const NovelSettingItemCard({
|
||||
Key? key,
|
||||
required this.settingItem,
|
||||
required this.novelId,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<NovelSettingItemCard> createState() => _NovelSettingItemCardState();
|
||||
}
|
||||
|
||||
class _NovelSettingItemCardState extends State<NovelSettingItemCard> {
|
||||
bool _isExpanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final typeEnum = SettingType.fromValue(widget.settingItem.type ?? 'OTHER');
|
||||
final itemAttributes = widget.settingItem.attributes; // Store in a local variable
|
||||
final itemTags = widget.settingItem.tags; // Store in a local variable
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 6.0),
|
||||
elevation: 1.5,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)), // Softer corners
|
||||
clipBehavior: Clip.antiAlias, // Ensures content respects border radius
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start, // Align items to the top
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.settingItem.name,
|
||||
style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold, fontSize: 15),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Chip(
|
||||
label: Text(typeEnum.displayName, style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w500)),
|
||||
backgroundColor: _getTypeColor(typeEnum).withOpacity(0.15),
|
||||
labelStyle: TextStyle(color: _getTypeColor(typeEnum)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: const VisualDensity(horizontal: 0.0, vertical: -2), // Compact chip
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
widget.settingItem.description ?? '无描述',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant, fontSize: 13, height: 1.4),
|
||||
maxLines: _isExpanded ? null : 3, // Show a bit more before expanding
|
||||
overflow: _isExpanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
),
|
||||
if ((widget.settingItem.description?.length ?? 0) > 120) // Show expand if description is somewhat long
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
style: TextButton.styleFrom(padding: EdgeInsets.zero, minimumSize: const Size(50,30), visualDensity: VisualDensity.compact),
|
||||
child: Text(_isExpanded ? '收起' : '展开', style: TextStyle(fontSize: 12, color: WebTheme.getPrimaryColor(context))),
|
||||
onPressed: () => setState(() => _isExpanded = !_isExpanded)),
|
||||
),
|
||||
|
||||
if ((itemAttributes?.isNotEmpty ?? false) || (itemTags?.isNotEmpty ?? false)) ...[
|
||||
const SizedBox(height: 6),
|
||||
Divider(thickness: 0.5, color: theme.dividerColor.withOpacity(0.5)),
|
||||
const SizedBox(height: 6),
|
||||
if (itemAttributes?.isNotEmpty ?? false)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: itemAttributes!.entries.map((e) => Chip(
|
||||
label: Text('${e.key}: ${e.value}', style: const TextStyle(fontSize: 10)),
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
backgroundColor: theme.colorScheme.surfaceVariant.withOpacity(0.7),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
||||
)).toList(),
|
||||
),
|
||||
),
|
||||
if (itemTags?.isNotEmpty ?? false)
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: itemTags!.map((tag) => Chip(
|
||||
label: Text(tag, style: const TextStyle(fontSize: 10)),
|
||||
backgroundColor: theme.colorScheme.secondaryContainer.withOpacity(0.6),
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
||||
)).toList(),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.add_circle_outline, size: 16),
|
||||
label: const Text('采纳到设定组', style: TextStyle(fontSize: 12)),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: WebTheme.getPrimaryColor(context),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
onPressed: () {
|
||||
_showAdoptDialog(context, widget.settingItem, widget.novelId);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getTypeColor(SettingType type) {
|
||||
switch (type) {
|
||||
case SettingType.character: return Colors.blue.shade600;
|
||||
case SettingType.location: return Colors.green.shade600;
|
||||
case SettingType.item: return Colors.orange.shade700;
|
||||
case SettingType.lore: return Colors.purple.shade600;
|
||||
case SettingType.event: return Colors.red.shade600;
|
||||
case SettingType.concept: return Colors.teal.shade600;
|
||||
case SettingType.faction: return Colors.indigo.shade600;
|
||||
case SettingType.creature: return Colors.brown.shade600;
|
||||
case SettingType.magicSystem: return Colors.cyan.shade600;
|
||||
case SettingType.technology: return Colors.blueGrey.shade600;
|
||||
case SettingType.culture: return Colors.deepOrange.shade600;
|
||||
case SettingType.history: return Colors.brown.shade600;
|
||||
case SettingType.organization: return Colors.indigo.shade600;
|
||||
case SettingType.worldview: return Colors.purple.shade600;
|
||||
case SettingType.pleasurePoint: return Colors.redAccent.shade200;
|
||||
case SettingType.anticipationHook: return Colors.teal.shade400;
|
||||
case SettingType.theme: return Colors.blueGrey.shade500;
|
||||
case SettingType.tone: return Colors.amber.shade700;
|
||||
case SettingType.style: return Colors.cyan.shade700;
|
||||
case SettingType.trope: return Colors.pink.shade400;
|
||||
case SettingType.plotDevice: return Colors.green.shade600;
|
||||
case SettingType.powerSystem: return Colors.orange.shade700;
|
||||
case SettingType.timeline: return Colors.blue.shade600;
|
||||
case SettingType.religion: return Colors.deepPurple.shade600;
|
||||
case SettingType.politics: return Colors.red.shade700;
|
||||
case SettingType.economy: return Colors.lightGreen.shade700;
|
||||
case SettingType.geography: return Colors.lightBlue.shade700;
|
||||
default: return Colors.grey.shade600;
|
||||
}
|
||||
}
|
||||
|
||||
void _showAdoptDialog(BuildContext context, NovelSettingItem itemToAdopt, String novelId) {
|
||||
final settingBloc = context.read<SettingBloc>();
|
||||
|
||||
AppLogger.i("AISettingGenerationPanel", "准备采纳设定: ${itemToAdopt.name}, 描述长度: ${itemToAdopt.description?.length ?? 0}, 标签数量: ${itemToAdopt.tags?.length ?? 0}, 属性数量: ${itemToAdopt.attributes?.length ?? 0}");
|
||||
|
||||
FloatingSettingDialogs.showSettingGroupSelection(
|
||||
context: context,
|
||||
novelId: novelId,
|
||||
onGroupSelected: (groupId, groupName) {
|
||||
// 显示操作提示
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('正在将 "${itemToAdopt.name}" 添加到 "$groupName"...'))
|
||||
);
|
||||
|
||||
// 确保类型值使用正确的枚举value值
|
||||
final typeValue = itemToAdopt.type;
|
||||
|
||||
// 准备创建的设定条目
|
||||
NovelSettingItem itemForCreation = itemToAdopt.copyWith(
|
||||
id: null,
|
||||
isAiSuggestion: false,
|
||||
status: 'ACTIVE',
|
||||
type: typeValue, // 确保使用原始的value值
|
||||
// 明确设置content和description,确保不会丢失
|
||||
content: "", // 不再使用content字段
|
||||
description: itemToAdopt.description, // 保留description作为主要描述字段
|
||||
attributes: itemToAdopt.attributes, // 确保属性被保留
|
||||
tags: itemToAdopt.tags, // 确保标签被保留
|
||||
generatedBy: "AI设定生成器" // 明确标记生成来源
|
||||
);
|
||||
|
||||
// 在安全的上下文环境中创建并添加到组
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
settingBloc.add(CreateSettingItemAndAddToGroup(
|
||||
novelId: novelId,
|
||||
item: itemForCreation,
|
||||
groupId: groupId,
|
||||
));
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,653 @@
|
||||
import 'package:ainoval/blocs/editor/editor_bloc.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'dart:async';
|
||||
|
||||
/// AI流式生成内容显示组件
|
||||
/// 在编辑器右侧面板中展示流式生成的内容,使用打字机效果
|
||||
class AIStreamGenerationDisplay extends StatefulWidget {
|
||||
const AIStreamGenerationDisplay({
|
||||
Key? key,
|
||||
required this.onClose,
|
||||
this.onOpenInEditor,
|
||||
}) : super(key: key);
|
||||
|
||||
/// 关闭面板的回调
|
||||
final VoidCallback onClose;
|
||||
|
||||
/// 在编辑器中打开内容的回调
|
||||
final Function(String content)? onOpenInEditor;
|
||||
|
||||
@override
|
||||
State<AIStreamGenerationDisplay> createState() => _AIStreamGenerationDisplayState();
|
||||
}
|
||||
|
||||
class _AIStreamGenerationDisplayState extends State<AIStreamGenerationDisplay> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
Timer? _autoScrollTimer;
|
||||
final TextEditingController _summaryController = TextEditingController();
|
||||
final TextEditingController _styleController = TextEditingController();
|
||||
bool _userScrolled = false;
|
||||
bool _showGeneratePanel = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// 初始化时检查是否有正在进行的生成,如有则自动滚动
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final state = context.read<EditorBloc>().state;
|
||||
if (state is EditorLoaded &&
|
||||
state.aiSceneGenerationStatus == AIGenerationStatus.generating &&
|
||||
state.generatedSceneContent != null &&
|
||||
state.generatedSceneContent!.isNotEmpty) {
|
||||
_scrollToBottom();
|
||||
AppLogger.i('AIStreamGenerationDisplay', '初始化时检测到生成内容,自动滚动到底部');
|
||||
}
|
||||
});
|
||||
|
||||
// 启动定期滚动更新
|
||||
_startAutoScrollTimer();
|
||||
|
||||
// 监听滚动事件,检测用户是否主动滚动
|
||||
_scrollController.addListener(_handleUserScroll);
|
||||
}
|
||||
|
||||
void _handleUserScroll() {
|
||||
if (_scrollController.hasClients) {
|
||||
// 如果用户向上滚动(滚动位置不在底部),标记为用户滚动
|
||||
if (_scrollController.position.pixels <
|
||||
_scrollController.position.maxScrollExtent - 50) {
|
||||
_userScrolled = true;
|
||||
}
|
||||
|
||||
// 如果用户滚动到底部,重置标记
|
||||
if (_scrollController.position.pixels >=
|
||||
_scrollController.position.maxScrollExtent - 10) {
|
||||
_userScrolled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _startAutoScrollTimer() {
|
||||
// 每500毫秒检查一次是否需要滚动
|
||||
_autoScrollTimer = Timer.periodic(const Duration(milliseconds: 500), (_) {
|
||||
final state = context.read<EditorBloc>().state;
|
||||
if (state is EditorLoaded &&
|
||||
state.isStreamingGeneration &&
|
||||
state.aiSceneGenerationStatus == AIGenerationStatus.generating &&
|
||||
!_userScrolled) { // 只有在用户没有主动滚动时自动滚动
|
||||
_scrollToBottom();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_autoScrollTimer?.cancel();
|
||||
_scrollController.removeListener(_handleUserScroll);
|
||||
_scrollController.dispose();
|
||||
_summaryController.dispose();
|
||||
_styleController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 自动滚动到底部
|
||||
void _scrollToBottom() {
|
||||
if (!_scrollController.hasClients) {
|
||||
AppLogger.d('AIStreamGenerationDisplay', '滚动控制器还没有客户端,延迟滚动');
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollToBottom();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
AppLogger.d('AIStreamGenerationDisplay', '执行滚动到底部');
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
} catch (e) {
|
||||
AppLogger.e('AIStreamGenerationDisplay', '滚动到底部失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 复制内容到剪贴板
|
||||
void _copyToClipboard(String content) {
|
||||
Clipboard.setData(ClipboardData(text: content)).then((_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('内容已复制到剪贴板')),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// 生成场景
|
||||
void _generateScene(BuildContext context) {
|
||||
if (_summaryController.text.isEmpty) return;
|
||||
|
||||
try {
|
||||
final state = context.read<EditorBloc>().state;
|
||||
if (state is! EditorLoaded) return;
|
||||
|
||||
// 触发场景生成请求
|
||||
context.read<EditorBloc>().add(
|
||||
GenerateSceneFromSummaryRequested(
|
||||
novelId: state.novel.id,
|
||||
summary: _summaryController.text,
|
||||
chapterId: state.activeChapterId,
|
||||
styleInstructions: _styleController.text.isNotEmpty
|
||||
? _styleController.text
|
||||
: null,
|
||||
useStreamingMode: true,
|
||||
),
|
||||
);
|
||||
|
||||
// 隐藏生成面板
|
||||
setState(() {
|
||||
_showGeneratePanel = false;
|
||||
});
|
||||
|
||||
// 重置用户滚动标记
|
||||
_userScrolled = false;
|
||||
|
||||
} catch (e) {
|
||||
AppLogger.e('AIStreamGenerationDisplay', '生成场景错误', e);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('启动AI生成时出错: ${e.toString()}')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocConsumer<EditorBloc, EditorState>(
|
||||
listener: (context, state) {
|
||||
if (state is EditorLoaded &&
|
||||
state.isStreamingGeneration &&
|
||||
state.generatedSceneContent != null &&
|
||||
state.generatedSceneContent!.isNotEmpty &&
|
||||
!_userScrolled) {
|
||||
_scrollToBottom();
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state is! EditorLoaded) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final isGenerating = state.aiSceneGenerationStatus == AIGenerationStatus.generating;
|
||||
final hasGenerated = state.aiSceneGenerationStatus == AIGenerationStatus.completed;
|
||||
final hasFailed = state.aiSceneGenerationStatus == AIGenerationStatus.failed;
|
||||
final content = state.generatedSceneContent ?? '';
|
||||
|
||||
return Container(
|
||||
width: 350, // 固定宽度
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(-2, 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 标题栏
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.7),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'AI 生成助手',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// 状态指示器
|
||||
if (isGenerating)
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
WebTheme.getPrimaryColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'正在流式生成...',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else if (hasGenerated)
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
size: 14,
|
||||
color: Colors.green.shade600,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'生成完成',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.green.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else if (hasFailed)
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error,
|
||||
size: 14,
|
||||
color: Colors.red.shade600,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'生成失败',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.red.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
padding: const EdgeInsets.all(4),
|
||||
onPressed: widget.onClose,
|
||||
tooltip: '关闭',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 内容标签
|
||||
if (!_showGeneratePanel)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
TabPageSelector(
|
||||
selectedColor: WebTheme.getPrimaryColor(context),
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
controller: TabController(
|
||||
initialIndex: 0,
|
||||
length: 2,
|
||||
vsync: const _TickerProviderImpl(),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// 添加生成场景按钮
|
||||
if (!isGenerating) // 只在不生成时显示
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showGeneratePanel = true;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('生成新场景'),
|
||||
style: TextButton.styleFrom(
|
||||
textStyle: const TextStyle(fontSize: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 生成面板 (新增)
|
||||
if (_showGeneratePanel)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'创建新场景',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _summaryController,
|
||||
maxLines: 4,
|
||||
decoration: InputDecoration(
|
||||
labelText: '场景摘要/大纲',
|
||||
hintText: '请输入场景大纲或摘要...',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _styleController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '风格指令(可选)',
|
||||
hintText: '多对话,少描写,悬疑风格...',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: (_summaryController.text.isNotEmpty || content.isNotEmpty)
|
||||
? () => _generateScene(context)
|
||||
: null,
|
||||
icon: const Icon(Icons.auto_awesome, size: 16),
|
||||
label: const Text('开始生成'),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showGeneratePanel = false;
|
||||
});
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 内容区域
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
if (content.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(), // 允许滚动
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
content,
|
||||
style: TextStyle(
|
||||
height: 1.8,
|
||||
fontSize: 15,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
// 底部空间
|
||||
if (isGenerating)
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (!isGenerating && !hasFailed)
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.description_outlined,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'生成的内容将显示在这里',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (isGenerating && content.isEmpty)
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'正在准备内容...',
|
||||
style: TextStyle(
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 生成指示器 (流式生成时在底部显示小提示)
|
||||
if (isGenerating && content.isNotEmpty)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
left: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Theme.of(context).colorScheme.surface.withOpacity(0),
|
||||
Theme.of(context).colorScheme.surface,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'正在生成中...',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 错误信息
|
||||
if (hasFailed && state.aiGenerationError != null)
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.red.shade200),
|
||||
),
|
||||
child: Text(
|
||||
'错误: ${state.aiGenerationError}',
|
||||
style: TextStyle(
|
||||
color: Colors.red.shade800,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 底部操作栏
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5),
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// 左侧按钮
|
||||
if (isGenerating)
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
context.read<EditorBloc>().add(StopSceneGeneration());
|
||||
},
|
||||
icon: const Icon(Icons.stop, size: 16),
|
||||
label: const Text('停止生成'),
|
||||
style: TextButton.styleFrom(
|
||||
textStyle: const TextStyle(fontSize: 13),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
)
|
||||
else
|
||||
FilledButton.icon(
|
||||
onPressed: hasGenerated && content.isNotEmpty
|
||||
? () {
|
||||
// 创建新场景并使用生成的内容
|
||||
if (widget.onOpenInEditor != null) {
|
||||
widget.onOpenInEditor!(content);
|
||||
AppLogger.i('AIStreamGenerationDisplay', '在编辑器中打开生成内容');
|
||||
widget.onClose();
|
||||
}
|
||||
}
|
||||
: null,
|
||||
icon: const Icon(Icons.save, size: 16),
|
||||
label: const Text('保存为场景'),
|
||||
style: FilledButton.styleFrom(
|
||||
textStyle: const TextStyle(fontSize: 13),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
),
|
||||
|
||||
// 右侧按钮
|
||||
Row(
|
||||
children: [
|
||||
if (!isGenerating && hasGenerated)
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showGeneratePanel = true;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
tooltip: '重新生成',
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
padding: const EdgeInsets.all(8),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: hasGenerated && content.isNotEmpty
|
||||
? () => _copyToClipboard(content)
|
||||
: null,
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
tooltip: '复制全部内容',
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
padding: const EdgeInsets.all(8),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 简单的TickerProvider实现,用于TabController
|
||||
class _TickerProviderImpl extends TickerProvider {
|
||||
const _TickerProviderImpl();
|
||||
|
||||
@override
|
||||
Ticker createTicker(TickerCallback onTick) => Ticker(onTick);
|
||||
}
|
||||
973
AINoval/lib/screens/editor/widgets/ai_summary_panel.dart
Normal file
973
AINoval/lib/screens/editor/widgets/ai_summary_panel.dart
Normal file
@@ -0,0 +1,973 @@
|
||||
import 'package:ainoval/blocs/editor/editor_bloc.dart';
|
||||
import 'package:ainoval/blocs/universal_ai/universal_ai_bloc.dart';
|
||||
import 'package:ainoval/blocs/universal_ai/universal_ai_event.dart';
|
||||
import 'package:ainoval/blocs/universal_ai/universal_ai_state.dart';
|
||||
import 'package:ainoval/blocs/ai_config/ai_config_bloc.dart';
|
||||
import 'package:ainoval/blocs/public_models/public_models_bloc.dart';
|
||||
import 'package:ainoval/models/novel_structure.dart';
|
||||
import 'package:ainoval/models/unified_ai_model.dart';
|
||||
import 'package:ainoval/models/ai_request_models.dart';
|
||||
// import 'package:ainoval/screens/editor/managers/editor_layout_manager.dart';
|
||||
import 'package:ainoval/widgets/common/unified_ai_model_dropdown.dart';
|
||||
import 'package:ainoval/models/context_selection_models.dart';
|
||||
import 'package:ainoval/widgets/common/form_dialog_template.dart';
|
||||
import 'package:ainoval/screens/editor/components/ai_dialog_common_logic.dart';
|
||||
import 'package:ainoval/widgets/common/scene_selector.dart';
|
||||
import 'package:ainoval/config/app_config.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:ainoval/utils/quill_helper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:ainoval/widgets/common/top_toast.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// AI摘要生成面板,提供根据场景内容生成摘要的功能
|
||||
class AISummaryPanel extends StatefulWidget {
|
||||
const AISummaryPanel({
|
||||
Key? key,
|
||||
required this.novelId,
|
||||
required this.onClose,
|
||||
this.isCardMode = false,
|
||||
}) : super(key: key);
|
||||
|
||||
final String novelId;
|
||||
final VoidCallback onClose;
|
||||
final bool isCardMode; // 是否以卡片模式显示
|
||||
|
||||
@override
|
||||
State<AISummaryPanel> createState() => _AISummaryPanelState();
|
||||
}
|
||||
|
||||
class _AISummaryPanelState extends State<AISummaryPanel> with AIDialogCommonLogic {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final TextEditingController _summaryController = TextEditingController();
|
||||
final LayerLink _layerLink = LayerLink();
|
||||
|
||||
UnifiedAIModel? _selectedModel;
|
||||
bool _enableSmartContext = true;
|
||||
// bool _userScrolled = false; // 未使用,先注释避免警告
|
||||
// bool _contentEdited = false; // 未使用,先注释避免警告
|
||||
bool _isGenerating = false;
|
||||
bool _thisInstanceIsGenerating = false; // 标记是否是当前实例发起的生成请求
|
||||
late ContextSelectionData _contextSelectionData;
|
||||
String? _selectedPromptTemplateId;
|
||||
// 临时自定义提示词
|
||||
String? _customSystemPrompt;
|
||||
String? _customUserPrompt;
|
||||
bool _contextInitialized = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// _contentEdited = false;
|
||||
|
||||
// 监听滚动事件,检测用户是否主动滚动
|
||||
_scrollController.addListener(_handleUserScroll);
|
||||
|
||||
// 初始化默认模型配置
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_initializeDefaultModel();
|
||||
_initializeContextData();
|
||||
});
|
||||
}
|
||||
|
||||
void _initializeDefaultModel() {
|
||||
final aiConfigState = context.read<AiConfigBloc>().state;
|
||||
final publicModelsState = context.read<PublicModelsBloc>().state;
|
||||
|
||||
// 合并私有模型和公共模型
|
||||
final allModels = _combineModels(aiConfigState, publicModelsState);
|
||||
|
||||
if (allModels.isNotEmpty && _selectedModel == null) {
|
||||
// 优先选择默认配置
|
||||
UnifiedAIModel? defaultModel;
|
||||
|
||||
// 首先查找私有模型中的默认配置
|
||||
for (final model in allModels) {
|
||||
if (!model.isPublic && (model as PrivateAIModel).userConfig.isDefault) {
|
||||
defaultModel = model;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有默认私有模型,选择第一个公共模型
|
||||
defaultModel ??= allModels.firstWhere(
|
||||
(model) => model.isPublic,
|
||||
orElse: () => allModels.first,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_selectedModel = defaultModel;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 合并私有模型和公共模型
|
||||
List<UnifiedAIModel> _combineModels(AiConfigState aiState, PublicModelsState publicState) {
|
||||
final List<UnifiedAIModel> allModels = [];
|
||||
|
||||
// 添加已验证的私有模型
|
||||
final validatedConfigs = aiState.validatedConfigs;
|
||||
for (final config in validatedConfigs) {
|
||||
allModels.add(PrivateAIModel(config));
|
||||
}
|
||||
|
||||
// 添加公共模型
|
||||
if (publicState is PublicModelsLoaded) {
|
||||
for (final publicModel in publicState.models) {
|
||||
allModels.add(PublicAIModel(publicModel));
|
||||
}
|
||||
}
|
||||
|
||||
return allModels;
|
||||
}
|
||||
|
||||
void _initializeContextData() {
|
||||
if (_contextInitialized) return;
|
||||
final editorState = context.read<EditorBloc>().state;
|
||||
if (editorState is EditorLoaded) {
|
||||
_contextSelectionData = ContextSelectionDataBuilder.fromNovel(editorState.novel);
|
||||
_contextInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_handleUserScroll);
|
||||
_scrollController.dispose();
|
||||
_summaryController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleUserScroll() {}
|
||||
|
||||
/// 复制内容到剪贴板
|
||||
void _copyToClipboard(String content) {
|
||||
Clipboard.setData(ClipboardData(text: content)).then((_) {
|
||||
TopToast.success(context, '摘要已复制到剪贴板');
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<EditorBloc, EditorState>(
|
||||
builder: (context, editorState) {
|
||||
if (editorState is! EditorLoaded) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return BlocConsumer<UniversalAIBloc, UniversalAIState>(
|
||||
listener: (context, state) {
|
||||
// 只处理摘要生成相关的状态变化
|
||||
if (state is UniversalAIStreaming) {
|
||||
// 检查是否是摘要生成请求
|
||||
if (_isSummaryRequest(state)) {
|
||||
setState(() {
|
||||
_isGenerating = true;
|
||||
_summaryController.text = state.partialResponse;
|
||||
// _contentEdited = false;
|
||||
});
|
||||
}
|
||||
} else if (state is UniversalAISuccess) {
|
||||
// 检查是否是摘要生成请求
|
||||
if (_isSummaryRequest(state)) {
|
||||
setState(() {
|
||||
_isGenerating = false;
|
||||
_thisInstanceIsGenerating = false; // 重置实例生成标记
|
||||
_summaryController.text = state.response.content;
|
||||
// _contentEdited = false;
|
||||
});
|
||||
}
|
||||
} else if (state is UniversalAIError) {
|
||||
// 检查是否是摘要生成请求
|
||||
if (_isSummaryRequest(state)) {
|
||||
setState(() {
|
||||
_isGenerating = false;
|
||||
_thisInstanceIsGenerating = false; // 重置实例生成标记
|
||||
});
|
||||
TopToast.error(context, '生成摘要失败: ${state.message}');
|
||||
}
|
||||
} else if (state is UniversalAILoading) {
|
||||
// 检查是否是摘要生成请求
|
||||
if (_isSummaryRequest(state)) {
|
||||
setState(() {
|
||||
_isGenerating = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
builder: (context, universalAIState) {
|
||||
return Column(
|
||||
children: [
|
||||
// 面板标题栏
|
||||
_buildHeader(context, editorState),
|
||||
|
||||
// 面板内容
|
||||
Expanded(
|
||||
child: _buildSummaryContentPanel(context, editorState),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context, EditorLoaded state) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 10.0),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getSurfaceColor(context),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: WebTheme.getSecondaryBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 标题行
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.summarize,
|
||||
size: 14,
|
||||
color: WebTheme.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'AI摘要助手',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
// 状态指示器
|
||||
if (_isGenerating) ...[
|
||||
const SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.black),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'正在生成...',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
|
||||
// 帮助按钮
|
||||
Tooltip(
|
||||
message: '使用说明',
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
Icons.help_outline,
|
||||
size: 16,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
padding: const EdgeInsets.all(4),
|
||||
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: WebTheme.getCardColor(context),
|
||||
surfaceTintColor: Colors.transparent,
|
||||
title: Text(
|
||||
'AI摘要生成说明',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
content: const SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'1. 选择要生成摘要的场景',
|
||||
style: TextStyle(fontSize: 12, color: Colors.black87),
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
Text(
|
||||
'2. 选择AI模型和配置',
|
||||
style: TextStyle(fontSize: 12, color: Colors.black87),
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
Text(
|
||||
'3. 点击"生成摘要"按钮',
|
||||
style: TextStyle(fontSize: 12, color: Colors.black87),
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
Text(
|
||||
'4. 生成完成后,可以直接编辑摘要内容',
|
||||
style: TextStyle(fontSize: 12, color: Colors.black87),
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
Text(
|
||||
'5. 点击"保存摘要"按钮将摘要保存到场景',
|
||||
style: TextStyle(fontSize: 12, color: Colors.black87),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: WebTheme.getPrimaryColor(context),
|
||||
foregroundColor: WebTheme.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
),
|
||||
child: const Text('了解了', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
IconButton(
|
||||
icon: Icon(Icons.close, size: 16, color: WebTheme.getSecondaryTextColor(context)),
|
||||
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
|
||||
padding: const EdgeInsets.all(4),
|
||||
onPressed: widget.onClose,
|
||||
tooltip: '关闭',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 当前场景信息行
|
||||
const SizedBox(height: 8),
|
||||
_buildCurrentSceneSelector(context, state),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCurrentSceneSelector(BuildContext context, EditorLoaded state) {
|
||||
return SceneSelector(
|
||||
novel: state.novel,
|
||||
activeSceneId: state.activeSceneId,
|
||||
onSceneSelected: (sceneId, actId, chapterId) {
|
||||
// 更新活跃场景
|
||||
context.read<EditorBloc>().add(SetActiveScene(
|
||||
actId: actId,
|
||||
chapterId: chapterId,
|
||||
sceneId: sceneId,
|
||||
));
|
||||
},
|
||||
onSummaryLoaded: (summary) {
|
||||
// 加载场景摘要到输入框
|
||||
setState(() {
|
||||
_summaryController.text = summary;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 构建摘要内容面板
|
||||
Widget _buildSummaryContentPanel(BuildContext context, EditorLoaded state) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(10.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 模型配置区域
|
||||
_buildModelConfigSection(context, state),
|
||||
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// 分割线
|
||||
Container(
|
||||
height: 1,
|
||||
color: WebTheme.getSecondaryBorderColor(context),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// 生成的摘要区域
|
||||
Expanded(
|
||||
child: _buildSummarySection(context, state),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildModelConfigSection(BuildContext context, EditorLoaded state) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getSurfaceColor(context),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: WebTheme.getSecondaryBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'模型设置',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 统一模型选择器
|
||||
_buildUnifiedModelSelector(context, state),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 智能上下文开关
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'智能上下文',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'启用后将自动检索相关的小说设定和背景信息',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Transform.scale(
|
||||
scale: 0.8,
|
||||
child: Switch(
|
||||
value: _enableSmartContext,
|
||||
activeColor: WebTheme.getPrimaryColor(context),
|
||||
activeTrackColor: WebTheme.getSecondaryBorderColor(context),
|
||||
inactiveThumbColor: WebTheme.getCardColor(context),
|
||||
inactiveTrackColor: WebTheme.getSecondaryBorderColor(context),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_enableSmartContext = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 上下文选择
|
||||
if (_contextInitialized)
|
||||
FormFieldFactory.createContextSelectionField(
|
||||
contextData: _contextSelectionData,
|
||||
onSelectionChanged: (newData) {
|
||||
setState(() {
|
||||
_contextSelectionData = newData;
|
||||
});
|
||||
},
|
||||
title: '附加上下文',
|
||||
description: '选择要包含在生成中的上下文信息',
|
||||
onReset: () {
|
||||
setState(() {
|
||||
_contextSelectionData = ContextSelectionDataBuilder.fromNovel(state.novel);
|
||||
});
|
||||
},
|
||||
dropdownWidth: 400,
|
||||
initialChapterId: state.activeChapterId,
|
||||
initialSceneId: state.activeSceneId,
|
||||
),
|
||||
|
||||
if (_contextInitialized) const SizedBox(height: 12),
|
||||
|
||||
// 关联提示词模板
|
||||
FormFieldFactory.createPromptTemplateSelectionField(
|
||||
selectedTemplateId: _selectedPromptTemplateId,
|
||||
onTemplateSelected: (templateId) {
|
||||
setState(() {
|
||||
_selectedPromptTemplateId = templateId;
|
||||
});
|
||||
},
|
||||
aiFeatureType: 'SCENE_TO_SUMMARY',
|
||||
title: '关联提示词模板',
|
||||
description: '可选,选择一个提示词模板优化摘要生成',
|
||||
onReset: () {
|
||||
setState(() {
|
||||
_selectedPromptTemplateId = null;
|
||||
});
|
||||
},
|
||||
onTemporaryPromptsSaved: (sys, user) {
|
||||
setState(() {
|
||||
_customSystemPrompt = sys.trim().isEmpty ? null : sys.trim();
|
||||
_customUserPrompt = user.trim().isEmpty ? null : user.trim();
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 生成按钮
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 36,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: (_getActiveScene(state) == null ||
|
||||
_getActiveScene(state)!.content.isEmpty ||
|
||||
_selectedModel == null ||
|
||||
_isGenerating)
|
||||
? null
|
||||
: () => _generateSummary(context, state),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.black,
|
||||
foregroundColor: Colors.white,
|
||||
disabledBackgroundColor: Colors.grey[300],
|
||||
disabledForegroundColor: Colors.grey[600],
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
icon: _isGenerating
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.auto_awesome, size: 14),
|
||||
label: Text(
|
||||
_isGenerating ? '生成中...' : '生成摘要',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建统一模型选择器
|
||||
Widget _buildUnifiedModelSelector(BuildContext context, EditorLoaded state) {
|
||||
return BlocBuilder<AiConfigBloc, AiConfigState>(
|
||||
builder: (context, aiState) {
|
||||
return BlocBuilder<PublicModelsBloc, PublicModelsState>(
|
||||
builder: (context, publicState) {
|
||||
final allModels = _combineModels(aiState, publicState);
|
||||
|
||||
return CompositedTransformTarget(
|
||||
link: _layerLink,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
_showModelDropdown(context, state, allModels);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: Colors.grey[300]!, width: 1),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _selectedModel != null
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_selectedModel!.displayName,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: _selectedModel!.isPublic ? Colors.green[50] : Colors.blue[50],
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
border: Border.all(
|
||||
color: _selectedModel!.isPublic ? Colors.green[200]! : Colors.blue[200]!,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_selectedModel!.isPublic ? '系统' : '私有',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: _selectedModel!.isPublic ? Colors.green[700] : Colors.blue[700],
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
_selectedModel!.provider,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
: const Text(
|
||||
'选择AI模型',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.black54,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.arrow_drop_down,
|
||||
color: Colors.black54,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示模型选择下拉菜单
|
||||
void _showModelDropdown(BuildContext context, EditorLoaded state, List<UnifiedAIModel> allModels) {
|
||||
UnifiedAIModelDropdown.show(
|
||||
context: context,
|
||||
layerLink: _layerLink,
|
||||
selectedModel: _selectedModel,
|
||||
onModelSelected: (model) {
|
||||
setState(() {
|
||||
_selectedModel = model;
|
||||
});
|
||||
},
|
||||
showSettingsButton: false,
|
||||
maxHeight: 300,
|
||||
novel: state.novel,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummarySection(BuildContext context, EditorLoaded state) {
|
||||
final hasContent = _summaryController.text.isNotEmpty;
|
||||
final activeScene = _getActiveScene(state);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'生成的摘要',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
if (hasContent && !_isGenerating) ...[
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(color: Colors.grey[300]!),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.copy, size: 14, color: Colors.black),
|
||||
tooltip: '复制到剪贴板',
|
||||
constraints: const BoxConstraints(),
|
||||
padding: EdgeInsets.zero,
|
||||
onPressed: () {
|
||||
_copyToClipboard(_summaryController.text);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
if (activeScene != null) ...[
|
||||
SizedBox(
|
||||
height: 28,
|
||||
child: ElevatedButton(
|
||||
onPressed: _summaryController.text.trim().isEmpty
|
||||
? null
|
||||
: () => _saveSummary(context, state, activeScene),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
disabledBackgroundColor: Colors.grey[200],
|
||||
disabledForegroundColor: Colors.grey,
|
||||
side: BorderSide(color: Colors.grey[300]!),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'保存摘要',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: Colors.grey[300]!,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: _isGenerating && _summaryController.text.isEmpty
|
||||
? const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.black),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'正在生成摘要...',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: !hasContent && !_isGenerating
|
||||
? const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.summarize,
|
||||
color: Colors.grey,
|
||||
size: 32,
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'点击"生成摘要"按钮开始生成',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: TextField(
|
||||
controller: _summaryController,
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
decoration: const InputDecoration(
|
||||
contentPadding: EdgeInsets.all(12),
|
||||
border: InputBorder.none,
|
||||
hintText: '生成的摘要将显示在这里',
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.4,
|
||||
color: Colors.black,
|
||||
),
|
||||
onChanged: (_) {
|
||||
setState(() {
|
||||
// _contentEdited = true;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 检查是否是摘要生成请求
|
||||
bool _isSummaryRequest(UniversalAIState state) {
|
||||
// 对于流式响应状态,只有当前实例发起的请求才处理
|
||||
if (state is UniversalAIStreaming) {
|
||||
return _thisInstanceIsGenerating;
|
||||
}
|
||||
// 对于成功状态,检查请求类型
|
||||
else if (state is UniversalAISuccess) {
|
||||
return state.response.requestType == AIRequestType.sceneSummary;
|
||||
}
|
||||
// 对于错误和加载状态,检查当前实例是否有生成任务
|
||||
else if (state is UniversalAIError || state is UniversalAILoading) {
|
||||
return _thisInstanceIsGenerating;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 生成摘要
|
||||
void _generateSummary(BuildContext context, EditorLoaded state) {
|
||||
final activeScene = _getActiveScene(state);
|
||||
if (activeScene == null || _selectedModel == null) return;
|
||||
|
||||
// 清空现有内容
|
||||
_summaryController.clear();
|
||||
|
||||
AppLogger.i('AISummaryPanel', '开始生成摘要,场景ID: ${activeScene.id}');
|
||||
|
||||
// 使用公共逻辑创建模型配置(公共模型会被包装为临时配置)
|
||||
final modelConfig = createModelConfig(_selectedModel!);
|
||||
|
||||
// 构建AI请求(先将Quill内容转换为纯文本)
|
||||
final String plainSceneText = QuillHelper.deltaToText(activeScene.content);
|
||||
// 构建元数据(包含公共模型标识)
|
||||
final metadata = createModelMetadata(_selectedModel!, {
|
||||
'actId': state.activeActId,
|
||||
'chapterId': state.activeChapterId,
|
||||
'sceneId': state.activeSceneId,
|
||||
'sceneTitle': activeScene.title,
|
||||
'wordCount': activeScene.wordCount,
|
||||
'action': 'scene_summary',
|
||||
'source': 'ai_summary_panel',
|
||||
});
|
||||
final request = UniversalAIRequest(
|
||||
requestType: AIRequestType.sceneSummary,
|
||||
userId: AppConfig.userId ?? 'unknown',
|
||||
novelId: widget.novelId,
|
||||
modelConfig: modelConfig,
|
||||
selectedText: plainSceneText, // 使用纯文本作为输入
|
||||
instructions: '请为这个小说场景生成一个准确、简洁的摘要,突出关键情节和重要细节。',
|
||||
contextSelections: _contextSelectionData,
|
||||
enableSmartContext: _enableSmartContext,
|
||||
parameters: {
|
||||
'temperature': 0.7,
|
||||
'maxTokens': 500,
|
||||
'promptTemplateId': _selectedPromptTemplateId,
|
||||
if (_customSystemPrompt != null) 'customSystemPrompt': _customSystemPrompt,
|
||||
if (_customUserPrompt != null) 'customUserPrompt': _customUserPrompt,
|
||||
},
|
||||
metadata: metadata,
|
||||
);
|
||||
|
||||
// 公共模型预估积分并确认
|
||||
if (_selectedModel!.isPublic) {
|
||||
handlePublicModelCreditConfirmation(_selectedModel!, request).then((ok) {
|
||||
if (!ok) return;
|
||||
setState(() { _thisInstanceIsGenerating = true; });
|
||||
context.read<UniversalAIBloc>().add(SendAIStreamRequestEvent(request));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 发送流式请求(私有模型直接发送)
|
||||
setState(() { _thisInstanceIsGenerating = true; });
|
||||
context.read<UniversalAIBloc>().add(SendAIStreamRequestEvent(request));
|
||||
}
|
||||
|
||||
void _saveSummary(BuildContext context, EditorLoaded state, Scene activeScene) {
|
||||
final summary = _summaryController.text.trim();
|
||||
if (summary.isEmpty) return;
|
||||
|
||||
// 保存摘要到场景
|
||||
context.read<EditorBloc>().add(
|
||||
UpdateSummary(
|
||||
novelId: widget.novelId,
|
||||
actId: state.activeActId!,
|
||||
chapterId: state.activeChapterId!,
|
||||
sceneId: activeScene.id,
|
||||
summary: summary,
|
||||
),
|
||||
);
|
||||
|
||||
// 显示保存成功提示
|
||||
TopToast.success(context, '摘要已保存');
|
||||
|
||||
// 已移除未使用的编辑状态标记
|
||||
|
||||
AppLogger.i('AISummaryPanel', '摘要已保存: ${activeScene.id}');
|
||||
}
|
||||
|
||||
// 获取当前活动场景
|
||||
Scene? _getActiveScene(EditorLoaded state) {
|
||||
if (state.activeSceneId != null && state.activeActId != null && state.activeChapterId != null) {
|
||||
// 获取完整的场景对象而不仅仅是ID
|
||||
final scene = state.novel.getScene(state.activeActId!, state.activeChapterId!, sceneId: state.activeSceneId);
|
||||
return scene;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
408
AINoval/lib/screens/editor/widgets/continue_writing_form.dart
Normal file
408
AINoval/lib/screens/editor/widgets/continue_writing_form.dart
Normal file
@@ -0,0 +1,408 @@
|
||||
import 'package:ainoval/models/user_ai_model_config_model.dart';
|
||||
import 'package:ainoval/services/api_service/repositories/user_ai_model_config_repository.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:ainoval/widgets/common/top_toast.dart';
|
||||
|
||||
/// 自动续写表单组件
|
||||
class ContinueWritingForm extends StatefulWidget {
|
||||
const ContinueWritingForm({
|
||||
super.key,
|
||||
required this.novelId,
|
||||
required this.userId,
|
||||
required this.onCancel,
|
||||
required this.onSubmit,
|
||||
required this.userAiModelConfigRepository,
|
||||
});
|
||||
|
||||
final String novelId;
|
||||
final String userId;
|
||||
final VoidCallback onCancel;
|
||||
final Function(Map<String, dynamic> parameters) onSubmit;
|
||||
final UserAIModelConfigRepository userAiModelConfigRepository;
|
||||
|
||||
@override
|
||||
State<ContinueWritingForm> createState() => _ContinueWritingFormState();
|
||||
}
|
||||
|
||||
class _ContinueWritingFormState extends State<ContinueWritingForm> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _numberOfChaptersController = TextEditingController(text: '1');
|
||||
final _contextChapterCountController = TextEditingController(text: '3');
|
||||
final _customContextController = TextEditingController();
|
||||
final _writingStyleController = TextEditingController();
|
||||
|
||||
List<UserAIModelConfigModel> _aiConfigs = [];
|
||||
bool _isLoadingConfigs = true;
|
||||
bool _isSubmitting = false;
|
||||
|
||||
String? _selectedSummaryConfigId;
|
||||
String? _selectedContentConfigId;
|
||||
String _startContextMode = 'AUTO'; // 默认为自动模式
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadAiConfigs();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_numberOfChaptersController.dispose();
|
||||
_contextChapterCountController.dispose();
|
||||
_customContextController.dispose();
|
||||
_writingStyleController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadAiConfigs() async {
|
||||
setState(() {
|
||||
_isLoadingConfigs = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final configs = await widget.userAiModelConfigRepository.listConfigurations(
|
||||
userId: widget.userId,
|
||||
validatedOnly: true,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_aiConfigs = configs;
|
||||
_isLoadingConfigs = false;
|
||||
|
||||
// 如果有配置,预选第一个
|
||||
if (configs.isNotEmpty) {
|
||||
_selectedSummaryConfigId = configs.first.id;
|
||||
_selectedContentConfigId = configs.first.id;
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
AppLogger.e('ContinueWritingForm', '加载AI配置失败', e);
|
||||
setState(() {
|
||||
_isLoadingConfigs = false;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
TopToast.error(context, '加载AI配置失败: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _submitForm() async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isSubmitting = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final parameters = <String, dynamic>{
|
||||
'novelId': widget.novelId,
|
||||
'numberOfChapters': int.parse(_numberOfChaptersController.text),
|
||||
'aiConfigIdSummary': _selectedSummaryConfigId,
|
||||
'aiConfigIdContent': _selectedContentConfigId,
|
||||
'startContextMode': _startContextMode,
|
||||
};
|
||||
|
||||
// 根据上下文模式添加对应参数
|
||||
if (_startContextMode == 'LAST_N_CHAPTERS') {
|
||||
parameters['contextChapterCount'] = int.parse(_contextChapterCountController.text);
|
||||
} else if (_startContextMode == 'CUSTOM') {
|
||||
parameters['customContext'] = _customContextController.text;
|
||||
}
|
||||
|
||||
// 添加写作风格参数(如果有)
|
||||
if (_writingStyleController.text.isNotEmpty) {
|
||||
parameters['writingStyle'] = _writingStyleController.text;
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
widget.onSubmit(parameters);
|
||||
} catch (e) {
|
||||
AppLogger.e('ContinueWritingForm', '提交表单失败', e);
|
||||
if (mounted) {
|
||||
TopToast.error(context, '提交失败: ${e.toString()}');
|
||||
}
|
||||
} finally {
|
||||
setState(() {
|
||||
_isSubmitting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
elevation: 3,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'自动续写设置',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: widget.onCancel,
|
||||
splashRadius: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 表单
|
||||
Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 续写章节数
|
||||
TextFormField(
|
||||
controller: _numberOfChaptersController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '续写章节数',
|
||||
helperText: '设置要自动续写的章节数量',
|
||||
prefixIcon: Icon(Icons.book_outlined),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入续写章节数';
|
||||
}
|
||||
final number = int.tryParse(value);
|
||||
if (number == null || number <= 0) {
|
||||
return '请输入有效的章节数';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 摘要模型选择
|
||||
_isLoadingConfigs
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: DropdownButtonFormField<String>(
|
||||
decoration: const InputDecoration(
|
||||
labelText: '摘要生成模型',
|
||||
helperText: '选择用于生成章节摘要的AI模型',
|
||||
prefixIcon: Icon(Icons.summarize_outlined),
|
||||
),
|
||||
value: _selectedSummaryConfigId,
|
||||
items: _aiConfigs
|
||||
.map((config) => DropdownMenuItem<String>(
|
||||
value: config.id,
|
||||
child: Text(config.alias ?? config.modelName),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedSummaryConfigId = value;
|
||||
});
|
||||
},
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请选择摘要生成模型';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 内容模型选择
|
||||
_isLoadingConfigs
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: DropdownButtonFormField<String>(
|
||||
decoration: const InputDecoration(
|
||||
labelText: '内容生成模型',
|
||||
helperText: '选择用于生成章节内容的AI模型',
|
||||
prefixIcon: Icon(Icons.text_fields),
|
||||
),
|
||||
value: _selectedContentConfigId,
|
||||
items: _aiConfigs
|
||||
.map((config) => DropdownMenuItem<String>(
|
||||
value: config.id,
|
||||
child: Text(config.alias ?? config.modelName),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedContentConfigId = value;
|
||||
});
|
||||
},
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请选择内容生成模型';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 上下文模式选择
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'上下文模式',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 上下文模式单选组
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
_buildContextModeRadio('自动', 'AUTO'),
|
||||
_buildContextModeRadio('最近N章', 'LAST_N_CHAPTERS'),
|
||||
_buildContextModeRadio('自定义', 'CUSTOM'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'选择AI续写时使用的上下文模式',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 上下文章节数(仅当模式为LAST_N_CHAPTERS时显示)
|
||||
if (_startContextMode == 'LAST_N_CHAPTERS')
|
||||
TextFormField(
|
||||
controller: _contextChapterCountController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '上下文章节数',
|
||||
helperText: '设置AI生成时参考的最近章节数量',
|
||||
prefixIcon: Icon(Icons.format_list_numbered),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入上下文章节数';
|
||||
}
|
||||
final number = int.tryParse(value);
|
||||
if (number == null || number <= 0) {
|
||||
return '请输入有效的章节数';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
|
||||
// 自定义上下文(仅当模式为CUSTOM时显示)
|
||||
if (_startContextMode == 'CUSTOM')
|
||||
TextFormField(
|
||||
controller: _customContextController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '自定义上下文',
|
||||
helperText: '输入AI生成时参考的自定义上下文内容',
|
||||
prefixIcon: Icon(Icons.description_outlined),
|
||||
),
|
||||
maxLines: 3,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入自定义上下文';
|
||||
}
|
||||
if (value.length < 10) {
|
||||
return '上下文内容过短,请提供更详细的信息';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 写作风格(可选)
|
||||
TextFormField(
|
||||
controller: _writingStyleController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '写作风格提示 (可选)',
|
||||
helperText: '描述期望的写作风格,例如:悬疑、浪漫、幽默等',
|
||||
prefixIcon: Icon(Icons.style),
|
||||
),
|
||||
maxLines: 1,
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 提交按钮
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: _isSubmitting ? null : widget.onCancel,
|
||||
child: const Text('取消'),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _isSubmitting ? null : _submitForm,
|
||||
child: _isSubmitting
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: theme.colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text('提交中...'),
|
||||
],
|
||||
)
|
||||
: const Text('开始任务'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 构建上下文模式单选按钮
|
||||
Widget _buildContextModeRadio(String label, String value) {
|
||||
return FilterChip(
|
||||
label: Text(label),
|
||||
selected: _startContextMode == value,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setState(() {
|
||||
_startContextMode = value;
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
354
AINoval/lib/screens/editor/widgets/custom_dropdown.dart
Normal file
354
AINoval/lib/screens/editor/widgets/custom_dropdown.dart
Normal file
@@ -0,0 +1,354 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// 通用下拉菜单组件,用于替换项目中的三点水下拉菜单
|
||||
class CustomDropdown extends StatefulWidget {
|
||||
/// 触发下拉菜单的小部件
|
||||
final Widget trigger;
|
||||
|
||||
/// 下拉菜单内容
|
||||
final Widget child;
|
||||
|
||||
/// 下拉菜单宽度
|
||||
final double width;
|
||||
|
||||
/// 下拉菜单对齐方式 ('left' 或 'right')
|
||||
final String align;
|
||||
|
||||
/// 是否为暗色主题
|
||||
final bool isDarkTheme;
|
||||
|
||||
/// 菜单出现/消失的动画时长
|
||||
final Duration animationDuration;
|
||||
|
||||
const CustomDropdown({
|
||||
Key? key,
|
||||
required this.trigger,
|
||||
required this.child,
|
||||
this.width = 240,
|
||||
this.align = 'left',
|
||||
this.isDarkTheme = false,
|
||||
this.animationDuration = const Duration(milliseconds: 150),
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<CustomDropdown> createState() => _CustomDropdownState();
|
||||
}
|
||||
|
||||
class _CustomDropdownState extends State<CustomDropdown> {
|
||||
bool isOpen = false;
|
||||
final LayerLink _layerLink = LayerLink();
|
||||
OverlayEntry? _overlayEntry;
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode.addListener(_onFocusChange);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_removeOverlay();
|
||||
_focusNode.removeListener(_onFocusChange);
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onFocusChange() {
|
||||
if (!_focusNode.hasFocus && isOpen) {
|
||||
_closeDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleDropdown() {
|
||||
if (isOpen) {
|
||||
_closeDropdown();
|
||||
} else {
|
||||
_openDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
void _closeDropdown() {
|
||||
_removeOverlay();
|
||||
setState(() {
|
||||
isOpen = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _openDropdown() {
|
||||
_showOverlay();
|
||||
setState(() {
|
||||
isOpen = true;
|
||||
});
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
|
||||
void _removeOverlay() {
|
||||
_overlayEntry?.remove();
|
||||
_overlayEntry = null;
|
||||
}
|
||||
|
||||
void _showOverlay() {
|
||||
_overlayEntry = _createOverlayEntry();
|
||||
Overlay.of(context).insert(_overlayEntry!);
|
||||
}
|
||||
|
||||
OverlayEntry _createOverlayEntry() {
|
||||
RenderBox renderBox = context.findRenderObject() as RenderBox;
|
||||
var size = renderBox.size;
|
||||
var offset = renderBox.localToGlobal(Offset.zero);
|
||||
|
||||
return OverlayEntry(
|
||||
builder: (context) => GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: _closeDropdown,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: widget.align == 'left' ? offset.dx : null,
|
||||
right: widget.align == 'right' ? (MediaQuery.of(context).size.width - offset.dx - size.width) : null,
|
||||
top: offset.dy + size.height + 4,
|
||||
width: widget.width,
|
||||
child: CompositedTransformFollower(
|
||||
link: _layerLink,
|
||||
followerAnchor: widget.align == 'left' ? Alignment.topLeft : Alignment.topRight,
|
||||
targetAnchor: widget.align == 'left' ? Alignment.bottomLeft : Alignment.bottomRight,
|
||||
offset: const Offset(0, 4),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
duration: widget.animationDuration,
|
||||
curve: Curves.easeOutCubic,
|
||||
tween: Tween<double>(begin: 0.0, end: 1.0),
|
||||
builder: (context, value, child) => Transform.scale(
|
||||
scale: 0.95 + (0.05 * value),
|
||||
alignment: widget.align == 'left'
|
||||
? Alignment.topLeft
|
||||
: Alignment.topRight,
|
||||
child: Opacity(
|
||||
opacity: value,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
elevation: 8,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: widget.isDarkTheme ? Colors.grey[850] : Colors.white,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: _wrapChildWithCloseCallback(widget.child),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _wrapChildWithCloseCallback(Widget child) {
|
||||
if (child is Column) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: child.children.map((item) {
|
||||
if (item is DropdownItem) {
|
||||
return DropdownItem(
|
||||
icon: item.icon,
|
||||
label: item.label,
|
||||
onTap: item.onTap,
|
||||
hasSubmenu: item.hasSubmenu,
|
||||
disabled: item.disabled,
|
||||
isDarkTheme: item.isDarkTheme,
|
||||
isDangerous: item.isDangerous,
|
||||
onClose: _closeDropdown,
|
||||
);
|
||||
}
|
||||
if (item is DropdownSection) {
|
||||
return DropdownSection(
|
||||
title: item.title,
|
||||
children: item.children.map((sectionItem) {
|
||||
if (sectionItem is DropdownItem) {
|
||||
return DropdownItem(
|
||||
icon: sectionItem.icon,
|
||||
label: sectionItem.label,
|
||||
onTap: sectionItem.onTap,
|
||||
hasSubmenu: sectionItem.hasSubmenu,
|
||||
disabled: sectionItem.disabled,
|
||||
isDarkTheme: sectionItem.isDarkTheme,
|
||||
isDangerous: sectionItem.isDangerous,
|
||||
onClose: _closeDropdown,
|
||||
);
|
||||
}
|
||||
return sectionItem;
|
||||
}).toList(),
|
||||
isDarkTheme: item.isDarkTheme,
|
||||
dividerAtBottom: item.dividerAtBottom,
|
||||
);
|
||||
}
|
||||
return item;
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
return child;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return KeyboardListener(
|
||||
focusNode: _focusNode,
|
||||
onKeyEvent: (keyEvent) {
|
||||
if (keyEvent is KeyDownEvent && keyEvent.logicalKey == LogicalKeyboardKey.escape) {
|
||||
_closeDropdown();
|
||||
}
|
||||
},
|
||||
child: CompositedTransformTarget(
|
||||
link: _layerLink,
|
||||
child: GestureDetector(
|
||||
onTap: _toggleDropdown,
|
||||
child: widget.trigger,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 下拉菜单项
|
||||
class DropdownItem extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Future<void> Function()? onTap;
|
||||
final bool hasSubmenu;
|
||||
final bool disabled;
|
||||
final bool isDarkTheme;
|
||||
final bool isDangerous;
|
||||
final VoidCallback? onClose;
|
||||
|
||||
const DropdownItem({
|
||||
Key? key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
this.onTap,
|
||||
this.hasSubmenu = false,
|
||||
this.disabled = false,
|
||||
this.isDarkTheme = false,
|
||||
this.isDangerous = false,
|
||||
this.onClose,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: disabled
|
||||
? null
|
||||
: () async {
|
||||
if (onTap != null) {
|
||||
await onTap!();
|
||||
}
|
||||
onClose?.call();
|
||||
},
|
||||
child: Opacity(
|
||||
opacity: disabled ? 0.5 : 1.0,
|
||||
child: Container(
|
||||
height: 40,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: isDangerous
|
||||
? Colors.red.shade700
|
||||
: (isDarkTheme ? Colors.white70 : Colors.black87)
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: isDangerous
|
||||
? Colors.red.shade700
|
||||
: (isDarkTheme ? Colors.white : Colors.black87),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hasSubmenu)
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
size: 16,
|
||||
color: isDarkTheme ? Colors.white38 : Colors.black45,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 下拉菜单分区
|
||||
class DropdownSection extends StatelessWidget {
|
||||
final String? title;
|
||||
final List<Widget> children;
|
||||
final bool isDarkTheme;
|
||||
final bool dividerAtBottom;
|
||||
|
||||
const DropdownSection({
|
||||
Key? key,
|
||||
this.title,
|
||||
required this.children,
|
||||
this.isDarkTheme = false,
|
||||
this.dividerAtBottom = true,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (title != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: Text(
|
||||
title!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isDarkTheme ? Colors.white54 : Colors.black54,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
...children,
|
||||
if (dividerAtBottom)
|
||||
Divider(
|
||||
height: 8,
|
||||
thickness: 1,
|
||||
color: isDarkTheme ? Colors.white12 : Colors.black12,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 下拉菜单分隔线
|
||||
class DropdownDivider extends StatelessWidget {
|
||||
final bool isDarkTheme;
|
||||
|
||||
const DropdownDivider({
|
||||
Key? key,
|
||||
this.isDarkTheme = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Divider(
|
||||
height: 8,
|
||||
thickness: 1,
|
||||
color: isDarkTheme ? Colors.white12 : Colors.black12,
|
||||
);
|
||||
}
|
||||
}
|
||||
126
AINoval/lib/screens/editor/widgets/dialogs.dart
Normal file
126
AINoval/lib/screens/editor/widgets/dialogs.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 对话框工具类
|
||||
///
|
||||
/// 用于创建和显示各种常用对话框
|
||||
class DialogUtils {
|
||||
/// 显示确认对话框
|
||||
static Future<bool> showConfirmDialog({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required String message,
|
||||
String confirmText = '确认',
|
||||
String cancelText = '取消',
|
||||
bool isDangerous = false,
|
||||
}) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(cancelText),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(confirmText),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: isDangerous ? Colors.red : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
/// 显示危险操作确认对话框
|
||||
static Future<bool> showDangerousConfirmDialog({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required String message,
|
||||
String confirmText = '删除',
|
||||
String cancelText = '取消',
|
||||
}) async {
|
||||
return showConfirmDialog(
|
||||
context: context,
|
||||
title: title,
|
||||
message: message,
|
||||
confirmText: confirmText,
|
||||
cancelText: cancelText,
|
||||
isDangerous: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示删除确认对话框
|
||||
static Future<bool> showDeleteConfirmDialog({
|
||||
required BuildContext context,
|
||||
required String itemType,
|
||||
String? itemName,
|
||||
}) async {
|
||||
final title = '删除$itemType';
|
||||
final message = itemName != null
|
||||
? '确定要删除"$itemName"吗?此操作不可撤销。'
|
||||
: '确定要删除这个$itemType吗?此操作不可撤销。';
|
||||
|
||||
return showDangerousConfirmDialog(
|
||||
context: context,
|
||||
title: title,
|
||||
message: message,
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示输入对话框
|
||||
static Future<String?> showInputDialog({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
String? initialValue,
|
||||
String hintText = '',
|
||||
String confirmText = '确认',
|
||||
String cancelText = '取消',
|
||||
}) async {
|
||||
final controller = TextEditingController(text: initialValue);
|
||||
|
||||
final result = await showDialog<String?>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
),
|
||||
autofocus: true,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, null),
|
||||
child: Text(cancelText),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, controller.text),
|
||||
child: Text(confirmText),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 显示重命名对话框
|
||||
static Future<String?> showRenameDialog({
|
||||
required BuildContext context,
|
||||
required String itemType,
|
||||
required String currentName,
|
||||
}) async {
|
||||
return showInputDialog(
|
||||
context: context,
|
||||
title: '重命名$itemType',
|
||||
initialValue: currentName,
|
||||
hintText: '输入新的名称',
|
||||
);
|
||||
}
|
||||
}
|
||||
469
AINoval/lib/screens/editor/widgets/dropdown_manager.dart
Normal file
469
AINoval/lib/screens/editor/widgets/dropdown_manager.dart
Normal file
@@ -0,0 +1,469 @@
|
||||
import 'package:ainoval/blocs/editor/editor_bloc.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/custom_dropdown.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/menu_definitions.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/preset_menu_definitions.dart';
|
||||
import 'package:ainoval/services/ai_preset_service.dart';
|
||||
import 'package:ainoval/models/preset_models.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 下拉菜单管理器
|
||||
///
|
||||
/// 用于统一构建和管理所有下拉菜单,包括Act、Chapter、Scene和Model的菜单
|
||||
class DropdownManager {
|
||||
/// 菜单构建上下文
|
||||
final BuildContext context;
|
||||
|
||||
/// 编辑器状态管理(模型菜单时可为null)
|
||||
final EditorBloc? editorBloc;
|
||||
|
||||
/// 菜单显示设置
|
||||
final DropdownDisplaySettings displaySettings;
|
||||
|
||||
DropdownManager({
|
||||
required this.context,
|
||||
required this.editorBloc,
|
||||
this.displaySettings = const DropdownDisplaySettings(),
|
||||
});
|
||||
|
||||
/// 构建Act菜单
|
||||
Widget buildActMenu({
|
||||
required String actId,
|
||||
Function()? onRenamePressed,
|
||||
IconData? icon,
|
||||
String? tooltip,
|
||||
}) {
|
||||
return _buildMenu(
|
||||
menuItems: ActMenuDefinitions.getMenuItems(),
|
||||
id: actId,
|
||||
secondaryId: null,
|
||||
tertiaryId: null,
|
||||
onRenamePressed: onRenamePressed,
|
||||
icon: icon ?? Icons.more_vert,
|
||||
tooltip: tooltip ?? 'Act操作',
|
||||
width: displaySettings.actMenuWidth,
|
||||
align: displaySettings.actMenuAlign,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建Chapter菜单
|
||||
Widget buildChapterMenu({
|
||||
required String actId,
|
||||
required String chapterId,
|
||||
Function()? onRenamePressed,
|
||||
IconData? icon,
|
||||
String? tooltip,
|
||||
}) {
|
||||
// 动态统计该章节下的场景数量,用作菜单顶部信息
|
||||
int? sceneCount;
|
||||
try {
|
||||
final state = editorBloc?.state;
|
||||
if (state is EditorLoaded) {
|
||||
final novel = state.novel;
|
||||
for (final act in novel.acts) {
|
||||
if (act.id == actId) {
|
||||
for (final chapter in act.chapters) {
|
||||
if (chapter.id == chapterId) {
|
||||
sceneCount = chapter.scenes.length;
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// 构建带有“章节信息:共N个场景”的菜单项,放在最前面
|
||||
final List<dynamic> items = [];
|
||||
if (sceneCount != null) {
|
||||
items.add(MenuItemData(
|
||||
icon: Icons.info_outline,
|
||||
label: '共${sceneCount}个场景',
|
||||
onTap: null,
|
||||
disabled: true,
|
||||
));
|
||||
items.add("divider");
|
||||
}
|
||||
items.addAll(ChapterMenuDefinitions.getMenuItems());
|
||||
|
||||
return _buildMenu(
|
||||
menuItems: items,
|
||||
id: actId,
|
||||
secondaryId: chapterId,
|
||||
tertiaryId: null,
|
||||
onRenamePressed: onRenamePressed,
|
||||
icon: icon ?? Icons.more_vert,
|
||||
tooltip: tooltip ?? '章节操作',
|
||||
width: displaySettings.chapterMenuWidth,
|
||||
align: displaySettings.chapterMenuAlign,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建Scene菜单
|
||||
Widget buildSceneMenu({
|
||||
required String actId,
|
||||
required String chapterId,
|
||||
required String sceneId,
|
||||
IconData? icon,
|
||||
String? tooltip,
|
||||
}) {
|
||||
return _buildMenu(
|
||||
menuItems: SceneMenuDefinitions.getMenuItems(),
|
||||
id: actId,
|
||||
secondaryId: chapterId,
|
||||
tertiaryId: sceneId,
|
||||
icon: icon ?? Icons.more_horiz,
|
||||
tooltip: tooltip ?? '场景操作',
|
||||
width: displaySettings.sceneMenuWidth,
|
||||
align: displaySettings.sceneMenuAlign,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建Model菜单
|
||||
Widget buildModelMenu({
|
||||
required String configId,
|
||||
required bool isValidated,
|
||||
required bool isDefault,
|
||||
required Future<void> Function(String) onValidate,
|
||||
required Future<void> Function(String) onSetDefault,
|
||||
required Future<void> Function(String) onEdit,
|
||||
required Future<void> Function(String) onDelete,
|
||||
IconData? icon,
|
||||
String? tooltip,
|
||||
}) {
|
||||
final menuItems = ModelMenuDefinitions.getMenuItems(
|
||||
isValidated: isValidated,
|
||||
isDefault: isDefault,
|
||||
onValidate: onValidate,
|
||||
onSetDefault: onSetDefault,
|
||||
onEdit: onEdit,
|
||||
onDelete: onDelete,
|
||||
);
|
||||
|
||||
return _buildModelMenu(
|
||||
menuItems: menuItems,
|
||||
configId: configId,
|
||||
icon: icon ?? Icons.more_vert,
|
||||
tooltip: tooltip ?? '模型操作',
|
||||
width: displaySettings.modelMenuWidth,
|
||||
align: displaySettings.modelMenuAlign,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建预设菜单
|
||||
Widget buildPresetMenu({
|
||||
required String featureType,
|
||||
required Function() onCreatePreset,
|
||||
required Function() onManagePresets,
|
||||
required Function(AIPromptPreset preset) onPresetSelected,
|
||||
IconData? icon,
|
||||
String? tooltip,
|
||||
}) {
|
||||
return CustomDropdown(
|
||||
width: displaySettings.presetMenuWidth,
|
||||
align: displaySettings.presetMenuAlign,
|
||||
trigger: IconButton(
|
||||
icon: Icon(icon ?? Icons.bookmark_border, size: 18),
|
||||
onPressed: null, // 由CustomDropdown处理点击
|
||||
tooltip: tooltip ?? '预设管理',
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
splashRadius: 20,
|
||||
),
|
||||
child: FutureBuilder<List<dynamic>>(
|
||||
future: PresetMenuDefinitions.getDynamicMenuItems(
|
||||
featureType: featureType,
|
||||
onCreatePreset: onCreatePreset,
|
||||
onManagePresets: onManagePresets,
|
||||
onPresetSelected: onPresetSelected,
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final menuItems = snapshot.data ?? [];
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: _buildPresetMenuItemWidgets(
|
||||
menuItems,
|
||||
featureType,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 内部方法:构建通用菜单
|
||||
Widget _buildMenu({
|
||||
required List<dynamic> menuItems,
|
||||
required String id,
|
||||
String? secondaryId,
|
||||
String? tertiaryId,
|
||||
Function()? onRenamePressed,
|
||||
required IconData icon,
|
||||
required String tooltip,
|
||||
double width = 240,
|
||||
String align = 'left',
|
||||
}) {
|
||||
return CustomDropdown(
|
||||
width: width,
|
||||
align: align,
|
||||
trigger: IconButton(
|
||||
icon: Icon(icon, size: 20),
|
||||
onPressed: null, // 由CustomDropdown处理点击
|
||||
tooltip: tooltip,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
splashRadius: 20,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: _buildMenuItemWidgets(
|
||||
menuItems,
|
||||
id,
|
||||
secondaryId,
|
||||
tertiaryId,
|
||||
onRenamePressed,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 内部方法:构建模型菜单
|
||||
Widget _buildModelMenu({
|
||||
required List<dynamic> menuItems,
|
||||
required String configId,
|
||||
required IconData icon,
|
||||
required String tooltip,
|
||||
double width = 180,
|
||||
String align = 'right',
|
||||
}) {
|
||||
return CustomDropdown(
|
||||
width: width,
|
||||
align: align,
|
||||
trigger: IconButton(
|
||||
icon: Icon(icon, size: 16),
|
||||
onPressed: null, // 由CustomDropdown处理点击
|
||||
tooltip: tooltip,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
splashRadius: 20,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: _buildModelMenuItemWidgets(
|
||||
menuItems,
|
||||
configId,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建菜单项列表
|
||||
List<Widget> _buildMenuItemWidgets(
|
||||
List<dynamic> menuItems,
|
||||
String id,
|
||||
String? secondaryId,
|
||||
String? tertiaryId,
|
||||
Function()? onRenamePressed,
|
||||
) {
|
||||
final List<Widget> widgets = [];
|
||||
|
||||
for (final item in menuItems) {
|
||||
if (item is String && item == "divider") {
|
||||
widgets.add(const DropdownDivider());
|
||||
} else if (item is MenuSectionData) {
|
||||
widgets.add(
|
||||
DropdownSection(
|
||||
title: item.title,
|
||||
children: item.items.map((menuItem) {
|
||||
return _buildSingleMenuItem(
|
||||
menuItem,
|
||||
id,
|
||||
secondaryId,
|
||||
tertiaryId,
|
||||
onRenamePressed,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
} else if (item is MenuItemData) {
|
||||
widgets.add(
|
||||
_buildSingleMenuItem(
|
||||
item,
|
||||
id,
|
||||
secondaryId,
|
||||
tertiaryId,
|
||||
onRenamePressed,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return widgets;
|
||||
}
|
||||
|
||||
/// 构建模型菜单项列表
|
||||
List<Widget> _buildModelMenuItemWidgets(
|
||||
List<dynamic> menuItems,
|
||||
String configId,
|
||||
) {
|
||||
final List<Widget> widgets = [];
|
||||
|
||||
for (final item in menuItems) {
|
||||
if (item is String && item == "divider") {
|
||||
widgets.add(const DropdownDivider());
|
||||
} else if (item is ModelMenuSectionData) {
|
||||
widgets.add(
|
||||
DropdownSection(
|
||||
title: item.title,
|
||||
children: item.items.map((menuItem) {
|
||||
return _buildSingleModelMenuItem(menuItem, configId);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
} else if (item is ModelMenuItemData) {
|
||||
widgets.add(_buildSingleModelMenuItem(item, configId));
|
||||
}
|
||||
}
|
||||
|
||||
return widgets;
|
||||
}
|
||||
|
||||
/// 构建单个菜单项
|
||||
Widget _buildSingleMenuItem(
|
||||
MenuItemData item,
|
||||
String id,
|
||||
String? secondaryId,
|
||||
String? tertiaryId,
|
||||
Function()? onRenamePressed,
|
||||
) {
|
||||
// 特殊处理重命名操作,因为需要直接访问State
|
||||
Future<void> Function()? onTapHandler;
|
||||
if (item.label == '重命名Act' || item.label == '重命名章节') {
|
||||
onTapHandler = null;
|
||||
} else if (item.onTap != null) {
|
||||
onTapHandler = () async {
|
||||
await item.onTap!(context, editorBloc!, id, secondaryId, tertiaryId);
|
||||
};
|
||||
}
|
||||
|
||||
return DropdownItem(
|
||||
icon: item.icon,
|
||||
label: item.label,
|
||||
hasSubmenu: item.hasSubmenu,
|
||||
disabled: item.disabled,
|
||||
isDangerous: item.isDangerous,
|
||||
onTap: onTapHandler,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建单个模型菜单项
|
||||
Widget _buildSingleModelMenuItem(
|
||||
ModelMenuItemData item,
|
||||
String configId,
|
||||
) {
|
||||
Future<void> Function()? onTapHandler;
|
||||
if (item.onTap != null) {
|
||||
onTapHandler = () async {
|
||||
await item.onTap!(configId);
|
||||
};
|
||||
}
|
||||
|
||||
return DropdownItem(
|
||||
icon: item.icon,
|
||||
label: item.label,
|
||||
hasSubmenu: item.hasSubmenu,
|
||||
disabled: item.disabled,
|
||||
isDangerous: item.isDangerous,
|
||||
onTap: onTapHandler,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建预设菜单项列表
|
||||
List<Widget> _buildPresetMenuItemWidgets(
|
||||
List<dynamic> menuItems,
|
||||
String featureType,
|
||||
) {
|
||||
final List<Widget> widgets = [];
|
||||
final presetService = AIPresetService();
|
||||
|
||||
for (final item in menuItems) {
|
||||
if (item is String && item == "divider") {
|
||||
widgets.add(const DropdownDivider());
|
||||
} else if (item is PresetMenuSectionData) {
|
||||
widgets.add(
|
||||
DropdownSection(
|
||||
title: item.title,
|
||||
children: item.items.map((menuItem) {
|
||||
return _buildSinglePresetMenuItem(menuItem, presetService, featureType);
|
||||
}).toList(),
|
||||
dividerAtBottom: item.dividerAtBottom,
|
||||
),
|
||||
);
|
||||
} else if (item is PresetMenuItemData) {
|
||||
widgets.add(_buildSinglePresetMenuItem(item, presetService, featureType));
|
||||
}
|
||||
}
|
||||
|
||||
return widgets;
|
||||
}
|
||||
|
||||
/// 构建单个预设菜单项
|
||||
Widget _buildSinglePresetMenuItem(
|
||||
PresetMenuItemData item,
|
||||
AIPresetService presetService,
|
||||
String featureType,
|
||||
) {
|
||||
Future<void> Function()? onTapHandler;
|
||||
if (item.onTap != null) {
|
||||
onTapHandler = () async {
|
||||
await item.onTap!(context, presetService, featureType);
|
||||
};
|
||||
}
|
||||
|
||||
return DropdownItem(
|
||||
icon: item.icon,
|
||||
label: item.label,
|
||||
hasSubmenu: item.hasSubmenu,
|
||||
disabled: item.disabled,
|
||||
isDangerous: item.isDangerous,
|
||||
onTap: onTapHandler,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 下拉菜单显示设置
|
||||
class DropdownDisplaySettings {
|
||||
final double actMenuWidth;
|
||||
final double chapterMenuWidth;
|
||||
final double sceneMenuWidth;
|
||||
final double modelMenuWidth;
|
||||
final double presetMenuWidth;
|
||||
final String actMenuAlign;
|
||||
final String chapterMenuAlign;
|
||||
final String sceneMenuAlign;
|
||||
final String modelMenuAlign;
|
||||
final String presetMenuAlign;
|
||||
|
||||
const DropdownDisplaySettings({
|
||||
this.actMenuWidth = 240,
|
||||
this.chapterMenuWidth = 240,
|
||||
this.sceneMenuWidth = 240,
|
||||
this.modelMenuWidth = 180,
|
||||
this.presetMenuWidth = 280,
|
||||
this.actMenuAlign = 'left',
|
||||
this.chapterMenuAlign = 'right',
|
||||
this.sceneMenuAlign = 'right',
|
||||
this.modelMenuAlign = 'right',
|
||||
this.presetMenuAlign = 'right',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/models/novel_setting_item.dart';
|
||||
import 'package:ainoval/models/setting_group.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/novel_setting_detail.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/novel_setting_group_dialog.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/novel_setting_group_selection_dialog.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/novel_setting_relationship_dialog.dart';
|
||||
|
||||
/// 统一的浮动设定对话框管理器
|
||||
class FloatingSettingDialogs {
|
||||
|
||||
/// 显示设定详情编辑卡片
|
||||
static void showSettingDetail({
|
||||
required BuildContext context,
|
||||
String? itemId,
|
||||
required String novelId,
|
||||
String? groupId,
|
||||
bool isEditing = false,
|
||||
required Function(NovelSettingItem, String?) onSave,
|
||||
required VoidCallback onCancel,
|
||||
}) {
|
||||
// 使用浮动设定详情管理器
|
||||
FloatingNovelSettingDetail.show(
|
||||
context: context,
|
||||
itemId: itemId,
|
||||
novelId: novelId,
|
||||
groupId: groupId,
|
||||
isEditing: isEditing,
|
||||
onSave: onSave,
|
||||
onCancel: onCancel,
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示设定组管理卡片
|
||||
static void showSettingGroup({
|
||||
required BuildContext context,
|
||||
required String novelId,
|
||||
SettingGroup? group,
|
||||
required Function(SettingGroup) onSave,
|
||||
}) {
|
||||
// 使用浮动设定组管理器
|
||||
FloatingNovelSettingGroupDialog.show(
|
||||
context: context,
|
||||
novelId: novelId,
|
||||
group: group,
|
||||
onSave: onSave,
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示设定组选择卡片
|
||||
static void showSettingGroupSelection({
|
||||
required BuildContext context,
|
||||
required String novelId,
|
||||
required Function(String groupId, String groupName) onGroupSelected,
|
||||
}) {
|
||||
// 使用浮动设定组选择管理器
|
||||
FloatingNovelSettingGroupSelectionDialog.show(
|
||||
context: context,
|
||||
novelId: novelId,
|
||||
onGroupSelected: onGroupSelected,
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示设定关系创建卡片
|
||||
static void showSettingRelationship({
|
||||
required BuildContext context,
|
||||
required String novelId,
|
||||
required String sourceItemId,
|
||||
required String sourceName,
|
||||
required List<NovelSettingItem> availableTargets,
|
||||
required Function(String relationType, String targetItemId, String? description) onSave,
|
||||
}) {
|
||||
// 使用浮动设定关系管理器
|
||||
FloatingNovelSettingRelationshipDialog.show(
|
||||
context: context,
|
||||
novelId: novelId,
|
||||
sourceItemId: sourceItemId,
|
||||
sourceName: sourceName,
|
||||
availableTargets: availableTargets,
|
||||
onSave: onSave,
|
||||
);
|
||||
}
|
||||
}
|
||||
160
AINoval/lib/screens/editor/widgets/generate_scene_dialog.dart
Normal file
160
AINoval/lib/screens/editor/widgets/generate_scene_dialog.dart
Normal file
@@ -0,0 +1,160 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/models/novel_structure.dart';
|
||||
|
||||
/// 生成场景对话框结果
|
||||
class GenerateSceneDialogResult {
|
||||
final String summary;
|
||||
final String? chapterId;
|
||||
final String? styleInstructions;
|
||||
|
||||
GenerateSceneDialogResult({
|
||||
required this.summary,
|
||||
this.chapterId,
|
||||
this.styleInstructions,
|
||||
});
|
||||
}
|
||||
|
||||
/// 生成场景对话框,用于输入摘要/大纲,然后触发AI生成场景内容
|
||||
class GenerateSceneDialog extends StatefulWidget {
|
||||
const GenerateSceneDialog({
|
||||
Key? key,
|
||||
required this.novel,
|
||||
this.initialSummary = '',
|
||||
this.initialChapterId,
|
||||
}) : super(key: key);
|
||||
|
||||
/// 当前小说
|
||||
final Novel novel;
|
||||
|
||||
/// 初始摘要文本
|
||||
final String initialSummary;
|
||||
|
||||
/// 初始章节ID
|
||||
final String? initialChapterId;
|
||||
|
||||
@override
|
||||
State<GenerateSceneDialog> createState() => _GenerateSceneDialogState();
|
||||
}
|
||||
|
||||
class _GenerateSceneDialogState extends State<GenerateSceneDialog> {
|
||||
final TextEditingController _summaryController = TextEditingController();
|
||||
final TextEditingController _styleController = TextEditingController();
|
||||
String? _selectedChapterId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_summaryController.text = widget.initialSummary;
|
||||
_selectedChapterId = widget.initialChapterId;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_summaryController.dispose();
|
||||
_styleController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 准备章节列表,包含篇章>章节层级
|
||||
List<DropdownMenuItem<String>> _buildChapterItems() {
|
||||
final items = <DropdownMenuItem<String>>[];
|
||||
|
||||
// 空选项
|
||||
items.add(const DropdownMenuItem<String>(
|
||||
value: null,
|
||||
child: Text('(无指定章节)'),
|
||||
));
|
||||
|
||||
// 遍历篇章和章节
|
||||
for (final act in widget.novel.acts) {
|
||||
for (final chapter in act.chapters) {
|
||||
items.add(DropdownMenuItem<String>(
|
||||
value: chapter.id,
|
||||
child: Text('${act.title} > ${chapter.title}'),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('AI 生成场景内容'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 摘要/大纲输入
|
||||
TextField(
|
||||
controller: _summaryController,
|
||||
maxLines: 5,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '场景摘要/大纲 *',
|
||||
hintText: '请输入场景的摘要或大纲,AI将根据此内容生成详细场景',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 章节选择
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedChapterId,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '选择章节(可选)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: _buildChapterItems(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedChapterId = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 风格指令
|
||||
TextField(
|
||||
controller: _styleController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '风格指令(可选)',
|
||||
hintText: '例如:多对话,少描写,悬疑风格',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
// 取消按钮
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('取消'),
|
||||
),
|
||||
|
||||
// 生成按钮
|
||||
ElevatedButton(
|
||||
onPressed: _summaryController.text.trim().isEmpty
|
||||
? null
|
||||
: () {
|
||||
// 返回生成结果
|
||||
Navigator.of(context).pop(
|
||||
GenerateSceneDialogResult(
|
||||
summary: _summaryController.text.trim(),
|
||||
chapterId: _selectedChapterId,
|
||||
styleInstructions: _styleController.text.trim().isNotEmpty
|
||||
? _styleController.text.trim()
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('生成'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
116
AINoval/lib/screens/editor/widgets/menu_builder.dart
Normal file
116
AINoval/lib/screens/editor/widgets/menu_builder.dart
Normal file
@@ -0,0 +1,116 @@
|
||||
import 'package:ainoval/blocs/editor/editor_bloc.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/dropdown_manager.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 通用菜单构建器
|
||||
/// 用于构建Act、Chapter、Scene和Model的下拉菜单
|
||||
class MenuBuilder {
|
||||
/// 构建Act菜单
|
||||
static Widget buildActMenu({
|
||||
required BuildContext context,
|
||||
required EditorBloc editorBloc,
|
||||
required String actId,
|
||||
required Function()? onRenamePressed,
|
||||
double width = 240,
|
||||
String align = 'left',
|
||||
}) {
|
||||
final dropdownManager = DropdownManager(
|
||||
context: context,
|
||||
editorBloc: editorBloc,
|
||||
displaySettings: DropdownDisplaySettings(
|
||||
actMenuWidth: width,
|
||||
actMenuAlign: align,
|
||||
),
|
||||
);
|
||||
|
||||
return dropdownManager.buildActMenu(
|
||||
actId: actId,
|
||||
onRenamePressed: onRenamePressed,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建Chapter菜单
|
||||
static Widget buildChapterMenu({
|
||||
required BuildContext context,
|
||||
required EditorBloc editorBloc,
|
||||
required String actId,
|
||||
required String chapterId,
|
||||
required Function()? onRenamePressed,
|
||||
double width = 240,
|
||||
String align = 'right',
|
||||
}) {
|
||||
final dropdownManager = DropdownManager(
|
||||
context: context,
|
||||
editorBloc: editorBloc,
|
||||
displaySettings: DropdownDisplaySettings(
|
||||
chapterMenuWidth: width,
|
||||
chapterMenuAlign: align,
|
||||
),
|
||||
);
|
||||
|
||||
return dropdownManager.buildChapterMenu(
|
||||
actId: actId,
|
||||
chapterId: chapterId,
|
||||
onRenamePressed: onRenamePressed,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建Scene菜单
|
||||
static Widget buildSceneMenu({
|
||||
required BuildContext context,
|
||||
required EditorBloc editorBloc,
|
||||
required String actId,
|
||||
required String chapterId,
|
||||
required String sceneId,
|
||||
double width = 240,
|
||||
String align = 'right',
|
||||
}) {
|
||||
final dropdownManager = DropdownManager(
|
||||
context: context,
|
||||
editorBloc: editorBloc,
|
||||
displaySettings: DropdownDisplaySettings(
|
||||
sceneMenuWidth: width,
|
||||
sceneMenuAlign: align,
|
||||
),
|
||||
);
|
||||
|
||||
return dropdownManager.buildSceneMenu(
|
||||
actId: actId,
|
||||
chapterId: chapterId,
|
||||
sceneId: sceneId,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建Model菜单
|
||||
static Widget buildModelMenu({
|
||||
required BuildContext context,
|
||||
required String configId,
|
||||
required bool isValidated,
|
||||
required bool isDefault,
|
||||
required Future<void> Function(String) onValidate,
|
||||
required Future<void> Function(String) onSetDefault,
|
||||
required Future<void> Function(String) onEdit,
|
||||
required Future<void> Function(String) onDelete,
|
||||
double width = 180,
|
||||
String align = 'right',
|
||||
}) {
|
||||
final dropdownManager = DropdownManager(
|
||||
context: context,
|
||||
editorBloc: null, // 模型菜单不需要EditorBloc
|
||||
displaySettings: DropdownDisplaySettings(
|
||||
modelMenuWidth: width,
|
||||
modelMenuAlign: align,
|
||||
),
|
||||
);
|
||||
|
||||
return dropdownManager.buildModelMenu(
|
||||
configId: configId,
|
||||
isValidated: isValidated,
|
||||
isDefault: isDefault,
|
||||
onValidate: onValidate,
|
||||
onSetDefault: onSetDefault,
|
||||
onEdit: onEdit,
|
||||
onDelete: onDelete,
|
||||
);
|
||||
}
|
||||
}
|
||||
356
AINoval/lib/screens/editor/widgets/menu_definitions.dart
Normal file
356
AINoval/lib/screens/editor/widgets/menu_definitions.dart
Normal file
@@ -0,0 +1,356 @@
|
||||
import 'package:ainoval/blocs/editor/editor_bloc.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/dialogs.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:ainoval/screens/editor/controllers/editor_screen_controller.dart';
|
||||
|
||||
/// 通用菜单项数据模型
|
||||
class MenuItemData {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Future<void> Function(BuildContext, EditorBloc, String, String?, String?)? onTap;
|
||||
final bool hasSubmenu;
|
||||
final bool disabled;
|
||||
final bool isDangerous;
|
||||
|
||||
const MenuItemData({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
this.onTap,
|
||||
this.hasSubmenu = false,
|
||||
this.disabled = false,
|
||||
this.isDangerous = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// 模型菜单项数据模型(扩展用于模型操作)
|
||||
class ModelMenuItemData {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Future<void> Function(String configId)? onTap;
|
||||
final bool hasSubmenu;
|
||||
final bool disabled;
|
||||
final bool isDangerous;
|
||||
|
||||
const ModelMenuItemData({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
this.onTap,
|
||||
this.hasSubmenu = false,
|
||||
this.disabled = false,
|
||||
this.isDangerous = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// 菜单分区数据模型
|
||||
class MenuSectionData {
|
||||
final String title;
|
||||
final List<MenuItemData> items;
|
||||
|
||||
const MenuSectionData({
|
||||
required this.title,
|
||||
required this.items,
|
||||
});
|
||||
}
|
||||
|
||||
/// 模型菜单分区数据模型
|
||||
class ModelMenuSectionData {
|
||||
final String title;
|
||||
final List<ModelMenuItemData> items;
|
||||
|
||||
const ModelMenuSectionData({
|
||||
required this.title,
|
||||
required this.items,
|
||||
});
|
||||
}
|
||||
|
||||
/// Model菜单定义
|
||||
class ModelMenuDefinitions {
|
||||
static List<dynamic> getMenuItems({
|
||||
required bool isValidated,
|
||||
required bool isDefault,
|
||||
required Future<void> Function(String) onValidate,
|
||||
required Future<void> Function(String) onSetDefault,
|
||||
required Future<void> Function(String) onEdit,
|
||||
required Future<void> Function(String) onDelete,
|
||||
}) {
|
||||
return [
|
||||
// 验证操作
|
||||
ModelMenuItemData(
|
||||
icon: isValidated ? Icons.verified : Icons.wifi_protected_setup,
|
||||
label: isValidated ? '重新验证' : '验证连接',
|
||||
onTap: onValidate,
|
||||
),
|
||||
|
||||
// 设为默认(如果不是默认模型)
|
||||
if (!isDefault)
|
||||
ModelMenuItemData(
|
||||
icon: Icons.star,
|
||||
label: '设为默认',
|
||||
onTap: onSetDefault,
|
||||
),
|
||||
|
||||
// 编辑操作
|
||||
ModelMenuItemData(
|
||||
icon: Icons.edit,
|
||||
label: '编辑',
|
||||
onTap: onEdit,
|
||||
),
|
||||
|
||||
// 分隔线
|
||||
"divider",
|
||||
|
||||
// 危险操作
|
||||
ModelMenuItemData(
|
||||
icon: Icons.delete_outline,
|
||||
label: '删除',
|
||||
isDangerous: true,
|
||||
onTap: onDelete,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// Act菜单定义
|
||||
class ActMenuDefinitions {
|
||||
static List<dynamic> getMenuItems() {
|
||||
return [
|
||||
// 基本操作
|
||||
MenuItemData(
|
||||
icon: Icons.add,
|
||||
label: '添加新章节',
|
||||
onTap: (context, editorBloc, actId, _, __) async {
|
||||
editorBloc.add(AddNewChapter(
|
||||
novelId: editorBloc.novelId,
|
||||
actId: actId,
|
||||
title: '新章节 ${DateTime.now().millisecondsSinceEpoch % 100}',
|
||||
));
|
||||
},
|
||||
),
|
||||
MenuItemData(
|
||||
icon: Icons.edit,
|
||||
label: '重命名Act',
|
||||
onTap: null,
|
||||
),
|
||||
|
||||
// 导出选项
|
||||
MenuSectionData(
|
||||
title: '导出选项',
|
||||
items: [
|
||||
MenuItemData(
|
||||
icon: Icons.file_download,
|
||||
label: '导出为PDF',
|
||||
onTap: (context, editorBloc, actId, _, __) async {
|
||||
// 实现导出为PDF功能
|
||||
},
|
||||
),
|
||||
MenuItemData(
|
||||
icon: Icons.file_download,
|
||||
label: '导出为Word',
|
||||
onTap: (context, editorBloc, actId, _, __) async {
|
||||
// 实现导出为Word功能
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 危险操作
|
||||
MenuItemData(
|
||||
icon: Icons.delete_outline,
|
||||
label: '删除Act',
|
||||
isDangerous: true,
|
||||
onTap: (context, editorBloc, actId, _, __) async {
|
||||
final confirmed = await _confirmAndDelete(
|
||||
context,
|
||||
'删除Act',
|
||||
'确定要删除这个Act吗?此操作不可撤销。',
|
||||
);
|
||||
if (confirmed) {
|
||||
editorBloc.add(DeleteAct(
|
||||
novelId: editorBloc.novelId,
|
||||
actId: actId,
|
||||
));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('正在删除卷...'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// Chapter菜单定义
|
||||
class ChapterMenuDefinitions {
|
||||
static List<dynamic> getMenuItems() {
|
||||
return [
|
||||
// 基本操作
|
||||
MenuItemData(
|
||||
icon: Icons.add,
|
||||
label: '添加新场景',
|
||||
onTap: (context, editorBloc, actId, chapterId, _) async {
|
||||
_addNewScene(context, editorBloc, actId, chapterId!);
|
||||
},
|
||||
),
|
||||
MenuItemData(
|
||||
icon: Icons.edit,
|
||||
label: '重命名章节',
|
||||
onTap: null,
|
||||
),
|
||||
|
||||
// 分隔线
|
||||
"divider",
|
||||
|
||||
// 额外功能
|
||||
MenuItemData(
|
||||
icon: Icons.tag,
|
||||
label: '禁用编号',
|
||||
onTap: (context, editorBloc, actId, chapterId, _) async {
|
||||
// 实现禁用编号功能
|
||||
},
|
||||
),
|
||||
MenuItemData(
|
||||
icon: Icons.content_copy,
|
||||
label: '复制所有场景内容',
|
||||
onTap: (context, editorBloc, actId, chapterId, _) async {
|
||||
// 实现复制场景内容功能
|
||||
},
|
||||
),
|
||||
|
||||
// 分隔线
|
||||
"divider",
|
||||
|
||||
// 危险操作
|
||||
MenuItemData(
|
||||
icon: Icons.delete_outline,
|
||||
label: '删除章节',
|
||||
isDangerous: true,
|
||||
onTap: (context, editorBloc, actId, chapterId, _) async {
|
||||
final confirmed = await _confirmAndDelete(
|
||||
context,
|
||||
'删除章节',
|
||||
'确定要删除这个章节吗?此操作不可撤销,章节内的所有场景都将被删除。',
|
||||
);
|
||||
if (confirmed) {
|
||||
// 实现删除章节功能
|
||||
editorBloc.add(DeleteChapter(
|
||||
novelId: editorBloc.novelId,
|
||||
actId: actId,
|
||||
chapterId: chapterId!,
|
||||
));
|
||||
|
||||
// 显示操作反馈
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('正在删除章节...'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// 添加新场景
|
||||
static void _addNewScene(BuildContext context, EditorBloc editorBloc, String actId, String chapterId) {
|
||||
final newSceneId = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
AppLogger.i('Chapter', '添加新场景:actId=$actId, chapterId=$chapterId, sceneId=$newSceneId');
|
||||
|
||||
editorBloc.add(AddNewScene(
|
||||
novelId: editorBloc.novelId,
|
||||
actId: actId,
|
||||
chapterId: chapterId,
|
||||
sceneId: newSceneId,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Scene菜单定义
|
||||
class SceneMenuDefinitions {
|
||||
static List<dynamic> getMenuItems() {
|
||||
return [
|
||||
MenuItemData(
|
||||
icon: Icons.copy_outlined,
|
||||
label: '复制场景',
|
||||
onTap: (context, editorBloc, actId, chapterId, sceneId) async {
|
||||
// 实现复制场景功能
|
||||
// editorBloc.add(DuplicateScene(
|
||||
// novelId: editorBloc.novelId,
|
||||
// actId: actId,
|
||||
// chapterId: chapterId!,
|
||||
// sceneId: sceneId!,
|
||||
// ));
|
||||
},
|
||||
),
|
||||
MenuItemData(
|
||||
icon: Icons.splitscreen_outlined,
|
||||
label: '拆分场景',
|
||||
onTap: (context, editorBloc, actId, chapterId, sceneId) async {
|
||||
// 实现拆分场景功能
|
||||
},
|
||||
),
|
||||
|
||||
MenuSectionData(
|
||||
title: 'AI功能',
|
||||
items: [
|
||||
MenuItemData(
|
||||
icon: Icons.auto_awesome,
|
||||
label: '生成摘要',
|
||||
onTap: (context, editorBloc, actId, chapterId, sceneId) async {
|
||||
editorBloc.add(GenerateSceneSummaryRequested(
|
||||
sceneId: sceneId!,
|
||||
));
|
||||
},
|
||||
),
|
||||
MenuItemData(
|
||||
icon: Icons.psychology,
|
||||
label: '改进内容',
|
||||
onTap: (context, editorBloc, actId, chapterId, sceneId) async {
|
||||
// 实现AI改进内容功能
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 分隔线
|
||||
"divider",
|
||||
|
||||
// 危险操作
|
||||
MenuItemData(
|
||||
icon: Icons.delete_outline,
|
||||
label: '删除场景',
|
||||
isDangerous: true,
|
||||
onTap: (context, editorBloc, actId, chapterId, sceneId) async {
|
||||
final confirmed = await _confirmAndDelete(
|
||||
context,
|
||||
'删除场景',
|
||||
'确定要删除这个场景吗?此操作不可撤销。',
|
||||
);
|
||||
if (confirmed) {
|
||||
editorBloc.add(DeleteScene(
|
||||
novelId: editorBloc.novelId,
|
||||
actId: actId,
|
||||
chapterId: chapterId!,
|
||||
sceneId: sceneId!,
|
||||
));
|
||||
}
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// 通用确认删除对话框
|
||||
Future<bool> _confirmAndDelete(BuildContext context, String title, String message) async {
|
||||
final confirmed = await DialogUtils.showDangerousConfirmDialog(
|
||||
context: context,
|
||||
title: title,
|
||||
message: message,
|
||||
);
|
||||
return confirmed;
|
||||
}
|
||||
2595
AINoval/lib/screens/editor/widgets/novel_setting_detail.dart
Normal file
2595
AINoval/lib/screens/editor/widgets/novel_setting_detail.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,397 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:ainoval/models/setting_group.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:ainoval/screens/editor/managers/editor_layout_manager.dart';
|
||||
import 'package:ainoval/widgets/common/floating_card.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:ainoval/widgets/common/top_toast.dart';
|
||||
|
||||
|
||||
/// 浮动设定组管理器
|
||||
class FloatingNovelSettingGroupDialog {
|
||||
static bool _isShowing = false;
|
||||
|
||||
/// 显示浮动设定组卡片
|
||||
static void show({
|
||||
required BuildContext context,
|
||||
required String novelId,
|
||||
SettingGroup? group, // 若为null则表示创建新组
|
||||
required Function(SettingGroup) onSave, // 保存回调
|
||||
}) {
|
||||
if (_isShowing) {
|
||||
hide();
|
||||
}
|
||||
|
||||
// 获取布局信息
|
||||
final layoutManager = Provider.of<EditorLayoutManager>(context, listen: false);
|
||||
final sidebarWidth = layoutManager.isEditorSidebarVisible ? layoutManager.editorSidebarWidth : 0.0;
|
||||
|
||||
AppLogger.d('FloatingNovelSettingGroupDialog', '显示浮动卡片,侧边栏宽度: $sidebarWidth');
|
||||
|
||||
// 计算卡片大小
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final cardWidth = (screenSize.width * 0.25).clamp(400.0, 600.0);
|
||||
final cardHeight = (screenSize.height * 0.5).clamp(350.0, 500.0);
|
||||
|
||||
FloatingCard.show(
|
||||
context: context,
|
||||
position: FloatingCardPosition(
|
||||
left: sidebarWidth + 16.0,
|
||||
top: 80.0,
|
||||
),
|
||||
config: FloatingCardConfig(
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
showCloseButton: false,
|
||||
enableBackgroundTap: false,
|
||||
animationDuration: const Duration(milliseconds: 300),
|
||||
animationCurve: Curves.easeOutCubic,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
child: _NovelSettingGroupDialogContent(
|
||||
novelId: novelId,
|
||||
group: group,
|
||||
onSave: (settingGroup) {
|
||||
onSave(settingGroup);
|
||||
hide();
|
||||
},
|
||||
onCancel: hide,
|
||||
),
|
||||
onClose: hide,
|
||||
);
|
||||
|
||||
_isShowing = true;
|
||||
}
|
||||
|
||||
/// 隐藏浮动卡片
|
||||
static void hide() {
|
||||
if (_isShowing) {
|
||||
FloatingCard.hide();
|
||||
_isShowing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否正在显示
|
||||
static bool get isShowing => _isShowing;
|
||||
}
|
||||
|
||||
/// 小说设定组对话框内容
|
||||
///
|
||||
/// 用于创建或编辑设定组
|
||||
class _NovelSettingGroupDialogContent extends StatefulWidget {
|
||||
final String novelId;
|
||||
final SettingGroup? group; // 若为null则表示创建新组
|
||||
final Function(SettingGroup) onSave; // 保存回调
|
||||
final VoidCallback onCancel; // 取消回调
|
||||
|
||||
const _NovelSettingGroupDialogContent({
|
||||
Key? key,
|
||||
required this.novelId,
|
||||
this.group,
|
||||
required this.onSave,
|
||||
required this.onCancel,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<_NovelSettingGroupDialogContent> createState() => _NovelSettingGroupDialogContentState();
|
||||
}
|
||||
|
||||
class _NovelSettingGroupDialogContentState extends State<_NovelSettingGroupDialogContent> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// 表单控制器
|
||||
final _nameController = TextEditingController();
|
||||
final _descriptionController = TextEditingController();
|
||||
|
||||
// 激活状态
|
||||
bool _isActiveContext = false;
|
||||
|
||||
// 保存状态
|
||||
bool _isSaving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// 若为编辑模式,填充表单
|
||||
if (widget.group != null) {
|
||||
_nameController.text = widget.group!.name;
|
||||
if (widget.group!.description != null) {
|
||||
_descriptionController.text = widget.group!.description!;
|
||||
}
|
||||
if (widget.group!.isActiveContext != null) {
|
||||
_isActiveContext = widget.group!.isActiveContext!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_descriptionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// 保存设定组
|
||||
Future<void> _saveSettingGroup() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() {
|
||||
_isSaving = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// 构建设定组对象
|
||||
final settingGroup = SettingGroup(
|
||||
id: widget.group?.id,
|
||||
novelId: widget.novelId,
|
||||
name: _nameController.text,
|
||||
description: _descriptionController.text.isNotEmpty
|
||||
? _descriptionController.text
|
||||
: null,
|
||||
isActiveContext: _isActiveContext,
|
||||
itemIds: widget.group?.itemIds,
|
||||
);
|
||||
|
||||
// 调用保存回调
|
||||
widget.onSave(settingGroup);
|
||||
|
||||
setState(() {
|
||||
_isSaving = false;
|
||||
});
|
||||
|
||||
// 注意:不在这里关闭对话框,因为 FloatingNovelSettingGroupDialog.show() 的 onSave 回调会调用 hide()
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.e('NovelSettingGroupDialog', '保存设定组失败', e, stackTrace);
|
||||
setState(() {
|
||||
_isSaving = false;
|
||||
});
|
||||
|
||||
// 显示错误提示
|
||||
if (context.mounted) {
|
||||
TopToast.error(context, '保存失败: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
final isCreating = widget.group == null;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? WebTheme.darkBackground : WebTheme.lightBackground,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isDark ? WebTheme.darkGrey300 : WebTheme.grey300,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 头部
|
||||
_buildHeader(isDark, isCreating),
|
||||
|
||||
// 内容区域
|
||||
Expanded(
|
||||
child: _buildContent(isDark, isCreating),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(bool isDark, bool isCreating) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: isDark ? WebTheme.darkGrey300 : WebTheme.grey300,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
isCreating ? '创建设定组' : '编辑设定组',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: widget.onCancel,
|
||||
icon: Icon(
|
||||
Icons.close,
|
||||
size: 20,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
style: IconButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: const Size(32, 32),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(bool isDark, bool isCreating) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题
|
||||
Text(
|
||||
isCreating ? '创建设定组' : '编辑设定组',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 表单
|
||||
Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 名称
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
autofocus: true,
|
||||
maxLength: 30,
|
||||
decoration: WebTheme.getBorderedInputDecoration(
|
||||
labelText: '名称',
|
||||
hintText: '输入设定组名称 (30 字以内)',
|
||||
context: context,
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入设定组名称';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 描述
|
||||
TextFormField(
|
||||
controller: _descriptionController,
|
||||
maxLines: 3,
|
||||
maxLength: 200,
|
||||
decoration: WebTheme.getBorderedInputDecoration(
|
||||
labelText: '描述',
|
||||
hintText: '输入设定组描述(可选,200 字以内)',
|
||||
context: context,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 激活状态
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: isDark ? WebTheme.darkGrey300 : WebTheme.grey300,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Switch(
|
||||
value: _isActiveContext,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_isActiveContext = value;
|
||||
});
|
||||
},
|
||||
activeColor: WebTheme.getTextColor(context),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'设为活跃上下文',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'活跃上下文中的设定将用于AI生成和提示',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 按钮区域
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: widget.onCancel,
|
||||
style: WebTheme.getSecondaryButtonStyle(context),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: _isSaving ? null : _saveSettingGroup,
|
||||
style: WebTheme.getPrimaryButtonStyle(context),
|
||||
child: _isSaving
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
color: isDark ? WebTheme.darkBackground : WebTheme.lightBackground,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(isCreating ? '创建中...' : '保存中...'),
|
||||
],
|
||||
)
|
||||
: Text(isCreating ? '创建' : '保存'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:ainoval/models/setting_group.dart';
|
||||
import 'package:ainoval/blocs/setting/setting_bloc.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/novel_setting_group_dialog.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:ainoval/screens/editor/managers/editor_layout_manager.dart';
|
||||
import 'package:ainoval/widgets/common/floating_card.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
|
||||
/// 浮动设定组选择管理器
|
||||
class FloatingNovelSettingGroupSelectionDialog {
|
||||
static bool _isShowing = false;
|
||||
|
||||
/// 显示浮动设定组选择卡片
|
||||
static void show({
|
||||
required BuildContext context,
|
||||
required String novelId,
|
||||
required Function(String groupId, String groupName) onGroupSelected,
|
||||
}) {
|
||||
if (_isShowing) {
|
||||
hide();
|
||||
}
|
||||
|
||||
// 获取布局信息
|
||||
final layoutManager = Provider.of<EditorLayoutManager>(context, listen: false);
|
||||
final sidebarWidth = layoutManager.isEditorSidebarVisible ? layoutManager.editorSidebarWidth : 0.0;
|
||||
|
||||
AppLogger.d('FloatingNovelSettingGroupSelectionDialog', '显示浮动卡片,侧边栏宽度: $sidebarWidth');
|
||||
|
||||
// 计算卡片大小
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final cardWidth = (screenSize.width * 0.3).clamp(400.0, 600.0);
|
||||
final cardHeight = (screenSize.height * 0.6).clamp(400.0, 600.0);
|
||||
|
||||
// 获取当前的 Provider 实例
|
||||
final settingBloc = context.read<SettingBloc>();
|
||||
|
||||
FloatingCard.show(
|
||||
context: context,
|
||||
position: FloatingCardPosition(
|
||||
left: sidebarWidth + 16.0,
|
||||
top: 80.0,
|
||||
),
|
||||
config: FloatingCardConfig(
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
showCloseButton: false,
|
||||
enableBackgroundTap: false,
|
||||
animationDuration: const Duration(milliseconds: 300),
|
||||
animationCurve: Curves.easeOutCubic,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
child: MultiProvider(
|
||||
providers: [
|
||||
Provider<EditorLayoutManager>.value(value: layoutManager),
|
||||
BlocProvider<SettingBloc>.value(value: settingBloc),
|
||||
],
|
||||
child: _NovelSettingGroupSelectionDialogContent(
|
||||
novelId: novelId,
|
||||
onGroupSelected: onGroupSelected,
|
||||
onCancel: hide,
|
||||
),
|
||||
),
|
||||
onClose: hide,
|
||||
);
|
||||
|
||||
_isShowing = true;
|
||||
}
|
||||
|
||||
/// 隐藏浮动卡片
|
||||
static void hide() {
|
||||
if (_isShowing) {
|
||||
FloatingCard.hide();
|
||||
_isShowing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否正在显示
|
||||
static bool get isShowing => _isShowing;
|
||||
}
|
||||
|
||||
/// 小说设定组选择对话框内容
|
||||
///
|
||||
/// 用于选择现有设定组或创建新设定组
|
||||
class _NovelSettingGroupSelectionDialogContent extends StatefulWidget {
|
||||
final String novelId;
|
||||
final Function(String groupId, String groupName) onGroupSelected;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
const _NovelSettingGroupSelectionDialogContent({
|
||||
Key? key,
|
||||
required this.novelId,
|
||||
required this.onGroupSelected,
|
||||
required this.onCancel,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<_NovelSettingGroupSelectionDialogContent> createState() => _NovelSettingGroupSelectionDialogContentState();
|
||||
}
|
||||
|
||||
class _NovelSettingGroupSelectionDialogContentState extends State<_NovelSettingGroupSelectionDialogContent> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 加载设定组列表
|
||||
context.read<SettingBloc>().add(LoadSettingGroups(widget.novelId));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
elevation: 5,
|
||||
child: Container(
|
||||
width: 400,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'选择设定组',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 设定组列表
|
||||
BlocBuilder<SettingBloc, SettingState>(
|
||||
builder: (context, state) {
|
||||
if (state.groupsStatus == SettingStatus.loading) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24.0),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.groupsStatus == SettingStatus.failure) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24.0),
|
||||
child: Text(
|
||||
'加载设定组失败:${state.error}',
|
||||
style: const TextStyle(color: Colors.red),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.groupsStatus == SettingStatus.success && state.groups.isEmpty) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24.0),
|
||||
child: Text(
|
||||
'没有可用的设定组,请创建新设定组',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.groupsStatus == SettingStatus.success) {
|
||||
return SizedBox(
|
||||
height: 300,
|
||||
child: ListView.builder(
|
||||
itemCount: state.groups.length,
|
||||
itemBuilder: (context, index) {
|
||||
final group = state.groups[index];
|
||||
return ListTile(
|
||||
title: Text(group.name),
|
||||
subtitle: group.description != null && group.description!.isNotEmpty
|
||||
? Text(
|
||||
group.description!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: null,
|
||||
leading: Icon(
|
||||
Icons.folder_outlined,
|
||||
color: group.isActiveContext == true
|
||||
? Colors.blue
|
||||
: Colors.grey,
|
||||
),
|
||||
onTap: () {
|
||||
// 正确关闭浮动卡片,而不是使用Navigator.pop()
|
||||
// 使用Future.microtask确保回调在对话框处理之后执行
|
||||
Future.microtask(() {
|
||||
// 关闭浮动卡片
|
||||
FloatingNovelSettingGroupSelectionDialog.hide();
|
||||
// 延迟调用回调
|
||||
Future.delayed(Duration.zero, () {
|
||||
widget.onGroupSelected(group.id!, group.name);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24.0),
|
||||
child: Text('请加载设定组'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 操作按钮
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('创建新设定组'),
|
||||
onPressed: () {
|
||||
_showCreateGroupDialog(context);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: WebTheme.getPrimaryColor(context),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: widget.onCancel,
|
||||
child: const Text('取消'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 显示创建设定组对话框
|
||||
void _showCreateGroupDialog(BuildContext context) {
|
||||
FloatingNovelSettingGroupDialog.show(
|
||||
context: context,
|
||||
novelId: widget.novelId,
|
||||
onSave: (SettingGroup group) {
|
||||
AppLogger.i('NovelSettingGroupSelectionDialog', '创建设定组:${group.name}');
|
||||
|
||||
// 保存设定组
|
||||
context.read<SettingBloc>().add(CreateSettingGroup(
|
||||
novelId: widget.novelId,
|
||||
group: group,
|
||||
));
|
||||
|
||||
// 监听状态变化,找到新创建的设定组,但不要直接调用导航回调
|
||||
final settingBloc = context.read<SettingBloc>();
|
||||
late final subscription;
|
||||
subscription = settingBloc.stream.listen((state) {
|
||||
if (state.groupsStatus == SettingStatus.success) {
|
||||
// 检查是否有新添加的设定组
|
||||
final newGroup = state.groups.where((g) => g.name == group.name).lastOrNull;
|
||||
if (newGroup != null && newGroup.id != null) {
|
||||
subscription.cancel(); // 先停止监听
|
||||
|
||||
// 只显示成功提示,不执行选择回调,让用户手动选择
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('设定组 "${newGroup.name}" 创建成功!'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 刷新当前对话框的设定组列表
|
||||
if (context.mounted) {
|
||||
context.read<SettingBloc>().add(LoadSettingGroups(widget.novelId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.groupsStatus == SettingStatus.failure) {
|
||||
subscription.cancel();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('创建设定组失败:${state.error}'),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 一段时间后如果没有成功,取消订阅
|
||||
Future.delayed(const Duration(seconds: 10), () {
|
||||
subscription.cancel();
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
1589
AINoval/lib/screens/editor/widgets/novel_setting_sidebar.dart
Normal file
1589
AINoval/lib/screens/editor/widgets/novel_setting_sidebar.dart
Normal file
File diff suppressed because it is too large
Load Diff
1010
AINoval/lib/screens/editor/widgets/novel_settings_view.dart
Normal file
1010
AINoval/lib/screens/editor/widgets/novel_settings_view.dart
Normal file
File diff suppressed because it is too large
Load Diff
233
AINoval/lib/screens/editor/widgets/preset_menu_definitions.dart
Normal file
233
AINoval/lib/screens/editor/widgets/preset_menu_definitions.dart
Normal 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,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
1953
AINoval/lib/screens/editor/widgets/selection_toolbar.dart
Normal file
1953
AINoval/lib/screens/editor/widgets/selection_toolbar.dart
Normal file
File diff suppressed because it is too large
Load Diff
561
AINoval/lib/screens/editor/widgets/setting_preview_card.dart
Normal file
561
AINoval/lib/screens/editor/widgets/setting_preview_card.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
162
AINoval/lib/screens/editor/widgets/setting_reference_hover.dart
Normal file
162
AINoval/lib/screens/editor/widgets/setting_reference_hover.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
697
AINoval/lib/screens/editor/widgets/snippet_edit_form.dart
Normal file
697
AINoval/lib/screens/editor/widgets/snippet_edit_form.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
470
AINoval/lib/screens/editor/widgets/snippet_list_tab.dart
Normal file
470
AINoval/lib/screens/editor/widgets/snippet_list_tab.dart
Normal 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('片段设置功能待实现')),
|
||||
);
|
||||
}
|
||||
}
|
||||
118
AINoval/lib/screens/editor/widgets/word_count_display.dart
Normal file
118
AINoval/lib/screens/editor/widgets/word_count_display.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user