马良AI写作初始化仓库

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

View File

@@ -0,0 +1,237 @@
import 'dart:async';
import 'package:ainoval/blocs/editor/editor_bloc.dart';
import 'package:ainoval/models/novel_structure.dart' as novel_models;
import 'package:ainoval/screens/editor/widgets/custom_dropdown.dart';
import 'package:ainoval/screens/editor/widgets/menu_builder.dart';
import 'package:ainoval/utils/web_theme.dart';
import 'package:flutter/material.dart';
class ActSection extends StatefulWidget {
const ActSection({
super.key,
required this.title,
required this.chapters,
required this.actId,
required this.editorBloc,
this.totalChaptersCount,
this.loadedChaptersCount,
this.actIndex, // 添加卷序号参数
});
final String title;
final List<Widget> chapters;
final String actId;
final EditorBloc editorBloc;
final int? totalChaptersCount; // 章节总数
final int? loadedChaptersCount; // 已加载章节数
final int? actIndex; // 卷序号从1开始
@override
State<ActSection> createState() => _ActSectionState();
}
class _ActSectionState extends State<ActSection> {
late TextEditingController _actTitleController;
Timer? _actTitleDebounceTimer;
@override
void initState() {
super.initState();
_actTitleController = TextEditingController(text: widget.title);
}
@override
void didUpdateWidget(ActSection oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.title != widget.title) {
_actTitleController.text = widget.title;
}
}
@override
void dispose() {
_actTitleDebounceTimer?.cancel();
_actTitleController.dispose();
super.dispose();
}
// 获取卷序号文本
String _getActIndexText() {
if (widget.actIndex == null) return '';
// 使用中文数字表示卷序号
final List<String> chineseNumbers = ['', '', '', '', '', '', '', '', '', '', ''];
if (widget.actIndex! <= 10) {
return '${chineseNumbers[widget.actIndex!]}卷 · ';
} else if (widget.actIndex! < 20) {
return '第十${chineseNumbers[widget.actIndex! - 10]}卷 · ';
} else {
// 对于更大的数字,直接使用阿拉伯数字
return '${widget.actIndex}卷 · ';
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
color: WebTheme.getBackgroundColor(context), // 使用动态背景色
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Act标题 - 居中显示
Padding(
padding: const EdgeInsets.fromLTRB(0, 16, 0, 24),
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 可编辑的文本字段
IntrinsicWidth(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center, // 确保垂直居中对齐
children: [
// 添加卷序号前缀
if (widget.actIndex != null)
Text(
_getActIndexText(),
style: WebTheme.getAlignedTextStyle(
baseStyle: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
color: WebTheme.getTextColor(context),
) ?? const TextStyle(),
),
),
Expanded(
child: Material(
type: MaterialType.transparency, // 使用透明Material类型避免黄色下划线
child: TextField(
controller: _actTitleController,
style: WebTheme.getAlignedTextStyle(
baseStyle: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
color: WebTheme.getTextColor(context),
) ?? const TextStyle(),
),
decoration: WebTheme.getBorderlessInputDecoration(
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
context: context, // 传递context以设置正确的hintStyle
),
textAlign: TextAlign.center,
onChanged: (value) {
// 使用防抖动机制,避免频繁更新
_actTitleDebounceTimer?.cancel();
_actTitleDebounceTimer =
Timer(const Duration(milliseconds: 500), () {
if (mounted) {
widget.editorBloc.add(UpdateActTitle(
actId: widget.actId,
title: value,
));
}
});
},
),
),
),
],
),
),
),
const SizedBox(width: 8),
// 显示加载状态
if (widget.totalChaptersCount != null && widget.loadedChaptersCount != null)
Tooltip(
message: '已加载 ${widget.loadedChaptersCount}/${widget.totalChaptersCount} 章节',
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: WebTheme.grey100,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${widget.loadedChaptersCount}/${widget.totalChaptersCount}',
style: TextStyle(
fontSize: 12,
color: WebTheme.getSecondaryTextColor(context),
),
),
),
),
const SizedBox(width: 8),
// 替换为MenuBuilder
MenuBuilder.buildActMenu(
context: context,
editorBloc: widget.editorBloc,
actId: widget.actId,
onRenamePressed: () {
// 聚焦到标题编辑框
setState(() {});
},
),
],
),
),
),
// 显示"没有章节"提示信息(当章节列表为空时)
if (widget.chapters.isEmpty)
Center(
child: Padding(
padding: const EdgeInsets.only(bottom: 24.0),
child: Column(
children: [
Icon(Icons.menu_book_outlined,
size: 48, color: WebTheme.getSecondaryTextColor(context)),
const SizedBox(height: 16),
Text(
'该卷下还没有章节',
style: TextStyle(
fontSize: 16,
color: WebTheme.getSecondaryTextColor(context),
),
),
const SizedBox(height: 8),
Text(
'请使用下方添加章节按钮来创建章节',
style: TextStyle(
fontSize: 14,
color: WebTheme.getSecondaryTextColor(context),
),
),
],
),
),
),
// 章节列表
...widget.chapters,
// Act分隔线
// const _ActDivider(),
],
),
);
}
}
// 可以保留或移除 _ActDivider
// class _ActDivider extends StatelessWidget {
// const _ActDivider();
// @override
// Widget build(BuildContext context) {
// return Divider(
// height: 80,
// thickness: 1,
// color: Colors.grey.shade200,
// indent: 40,
// endIndent: 40,
// );
// }
// }

View File

@@ -0,0 +1,120 @@
/**
* 添加新卷按钮组件
*
* 用于显示一个可点击的"添加新卷"按钮,用户点击后会触发创建新卷的逻辑。
* 包含加载状态反馈和防抖功能,避免短时间内重复点击触发多次创建操作。
*/
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:ainoval/utils/logger.dart';
import 'package:ainoval/widgets/common/top_toast.dart';
import 'package:ainoval/screens/editor/controllers/editor_screen_controller.dart';
import 'package:ainoval/utils/web_theme.dart';
/// 添加新卷按钮组件
///
/// 在编辑器中用于添加新卷时使用的按钮组件,包含点击反馈和加载态。
/// 使用Provider模式调用EditorScreenController中的创建方法。
class AddActButton extends StatefulWidget {
/// 创建一个添加新卷按钮
const AddActButton({Key? key}) : super(key: key);
@override
State<AddActButton> createState() => _AddActButtonState();
}
class _AddActButtonState extends State<AddActButton> {
/// 标记是否正在添加中,用于显示加载状态
bool _isAdding = false;
/// 记录上次点击时间,用于防抖
DateTime? _lastAddTime;
/// 防抖时间间隔2秒
static const Duration _debounceInterval = Duration(seconds: 2);
/// 添加新卷的处理方法
///
/// 包含防抖和错误处理逻辑,避免短时间内多次触发
void _addNewAct() {
// 防止频繁点击导致重复添加
final now = DateTime.now();
if (_isAdding || (_lastAddTime != null &&
now.difference(_lastAddTime!) < _debounceInterval)) {
// 如果正在添加中或最后添加时间在2秒内忽略此次点击
AppLogger.i('AddActButton', '忽略重复点击: 正在添加=${_isAdding}, 距上次点击=${_lastAddTime != null ? now.difference(_lastAddTime!).inMilliseconds : "首次点击"}ms');
// 显示提示仅在UI上
TopToast.warning(context, '操作正在处理中,请稍候...');
return;
}
// 记录当前时间并标记为添加中
_lastAddTime = now;
setState(() {
_isAdding = true;
});
AppLogger.i('AddActButton', '触发EditorScreenController的createNewAct方法');
// 使用EditorScreenController创建新卷及章节
Provider.of<EditorScreenController>(context, listen: false).createNewAct().then((_) {
if (mounted) {
setState(() {
_isAdding = false;
});
}
}).catchError((error) {
AppLogger.e('AddActButton', '调用createNewAct失败', error);
if (mounted) {
setState(() {
_isAdding = false;
});
TopToast.error(context, '创建失败: ${error.toString()}');
}
});
}
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: OutlinedButton.icon(
onPressed: _isAdding ? null : _addNewAct, // 如果正在添加中,禁用按钮
icon: _isAdding
// 添加中状态显示加载指示器
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(WebTheme.getPrimaryColor(context)),
),
)
// 常规状态显示加号图标
: const Icon(Icons.add, size: 18),
label: Text(_isAdding ? '添加中...' : '添加新卷'),
style: OutlinedButton.styleFrom(
foregroundColor: WebTheme.getPrimaryColor(context),
backgroundColor: WebTheme.getSurfaceColor(context),
side: BorderSide(color: WebTheme.getPrimaryColor(context), width: 1.5),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
elevation: 1,
).copyWith(
overlayColor: MaterialStateProperty.resolveWith<Color?>(
(Set<MaterialState> states) {
if (states.contains(MaterialState.hovered)) {
return WebTheme.getPrimaryColor(context).withOpacity(0.1);
}
return null;
},
),
),
),
),
);
}
}

View File

@@ -0,0 +1,557 @@
import 'package:flutter/material.dart';
import 'package:ainoval/utils/web_theme.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:ainoval/blocs/universal_ai/universal_ai_bloc.dart';
import 'package:ainoval/blocs/universal_ai/universal_ai_state.dart';
import 'package:ainoval/blocs/universal_ai/universal_ai_event.dart';
import 'package:ainoval/blocs/preset/preset_bloc.dart';
import 'package:ainoval/blocs/preset/preset_event.dart';
import 'package:ainoval/models/user_ai_model_config_model.dart';
import 'package:ainoval/models/ai_request_models.dart';
import 'package:ainoval/models/unified_ai_model.dart';
import 'package:ainoval/models/preset_models.dart';
import 'package:ainoval/models/context_selection_models.dart';
import 'package:ainoval/services/ai_preset_service.dart';
import 'package:ainoval/widgets/common/top_toast.dart';
import 'package:ainoval/config/app_config.dart';
import 'package:ainoval/utils/logger.dart';
/// AI对话框公共逻辑混入
mixin AIDialogCommonLogic<T extends StatefulWidget> on State<T> {
/// 创建统一的模型配置
/// 根据模型类型(公共/私有)创建正确的配置
UserAIModelConfigModel createModelConfig(UnifiedAIModel unifiedModel) {
if (unifiedModel.isPublic) {
// 对于公共模型,创建包含公共模型信息的临时配置
final publicModel = (unifiedModel as PublicAIModel).publicConfig;
debugPrint('🚀 创建公共模型配置 - 显示名: ${publicModel.displayName}, 模型ID: ${publicModel.modelId}, 公共模型ID: ${publicModel.id}');
return UserAIModelConfigModel.fromJson({
'id': 'public_${publicModel.id}', // 🚀 使用前缀区分公共模型ID
'userId': AppConfig.userId ?? 'unknown',
'name': publicModel.displayName, // 🚀 修复:添加 name 字段
'alias': publicModel.displayName,
'modelName': publicModel.modelId,
'provider': publicModel.provider,
'apiEndpoint': '', // 公共模型没有单独的apiEndpoint
'isDefault': false,
'isValidated': true,
'createdAt': DateTime.now().toIso8601String(),
'updatedAt': DateTime.now().toIso8601String(),
// 🚀 修复:添加公共模型的额外信息
'isPublic': true,
'creditMultiplier': publicModel.creditRateMultiplier ?? 1.0,
});
} else {
// 对于私有模型,直接使用用户配置
final privateModel = (unifiedModel as PrivateAIModel).userConfig;
debugPrint('🚀 使用私有模型配置 - 显示名: ${privateModel.name}, 模型名: ${privateModel.modelName}, 配置ID: ${privateModel.id}');
return privateModel;
}
}
/// 创建包含模型元数据的metadata
Map<String, dynamic> createModelMetadata(
UnifiedAIModel unifiedModel,
Map<String, dynamic> baseMetadata,
) {
final metadata = Map<String, dynamic>.from(baseMetadata);
// 🚀 添加模型信息
metadata.addAll({
'modelName': unifiedModel.modelId,
'modelProvider': unifiedModel.provider,
'modelConfigId': unifiedModel.id,
'isPublicModel': unifiedModel.isPublic,
});
// 🚀 如果是公共模型添加公共模型的真实ID
if (unifiedModel.isPublic) {
final String publicId = (unifiedModel as PublicAIModel).publicConfig.id;
// 发送后端期望的无前缀公共配置ID
metadata['publicModelConfigId'] = publicId;
// 同时保留兼容字段
metadata['publicModelId'] = publicId;
}
return metadata;
}
/// 🚀 新增:处理公共模型的积分预估和确认
Future<bool> handlePublicModelCreditConfirmation(
UnifiedAIModel unifiedModel,
UniversalAIRequest request,
) async {
if (!unifiedModel.isPublic) {
// 私有模型直接返回 true
return true;
}
try {
debugPrint('🚀 检测到公共模型,启动积分预估确认流程: ${unifiedModel.displayName}');
bool shouldProceed = await showCreditEstimationAndConfirm(request);
if (!shouldProceed) {
debugPrint('🚀 用户取消了积分预估确认');
return false; // 用户取消或积分不足
}
debugPrint('🚀 用户确认了积分预估');
return true;
} catch (e) {
AppLogger.e('AIDialogCommonLogic', '积分预估确认失败', e);
TopToast.error(context, '积分预估失败: $e');
return false;
}
}
/// 显示积分预估和确认对话框(仅对公共模型)
Future<bool> showCreditEstimationAndConfirm(UniversalAIRequest request) async {
try {
// 显示积分预估确认对话框传递UniversalAIBloc
return await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (BuildContext dialogContext) {
return BlocProvider.value(
value: context.read<UniversalAIBloc>(),
child: _CreditEstimationDialog(
modelName: request.modelConfig?.name ?? 'Unknown Model',
request: request,
onConfirm: () => Navigator.of(dialogContext).pop(true),
onCancel: () => Navigator.of(dialogContext).pop(false),
),
);
},
) ?? false;
} catch (e) {
AppLogger.e('AIDialogCommonLogic', '积分预估失败', e);
TopToast.error(context, '积分预估失败: $e');
return false;
}
}
/// 🚀 新增:通用的预设创建逻辑
Future<void> createPreset(
String name,
String description,
UniversalAIRequest currentRequest,
{Function(AIPromptPreset)? onPresetCreated}
) async {
try {
final presetService = AIPresetService();
final request = CreatePresetRequest(
presetName: name,
presetDescription: description.isNotEmpty ? description : null,
request: currentRequest,
);
final preset = await presetService.createPreset(request);
// 🚀 新增:更新本地预设缓存
try {
context.read<PresetBloc>().add(AddPresetToCache(preset: preset));
AppLogger.i('AIDialogCommonLogic', '✅ 已添加预设到本地缓存: ${preset.presetName}');
} catch (e) {
AppLogger.w('AIDialogCommonLogic', '⚠️ 添加预设到本地缓存失败,但预设创建成功', e);
}
// 调用回调处理预设创建成功
onPresetCreated?.call(preset);
TopToast.success(context, '预设 "$name" 创建成功');
AppLogger.i('AIDialogCommonLogic', '预设创建成功: $name');
} catch (e) {
AppLogger.e('AIDialogCommonLogic', '创建预设失败', e);
TopToast.error(context, '创建预设失败: $e');
}
}
/// 🚀 新增:显示预设名称输入对话框
Future<void> showPresetNameDialog(
UniversalAIRequest currentRequest,
{Function(AIPromptPreset)? onPresetCreated}
) async {
final TextEditingController nameController = TextEditingController();
final TextEditingController descController = TextEditingController();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('创建预设'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(
labelText: '预设名称',
hintText: '输入预设名称',
border: OutlineInputBorder(),
),
autofocus: true,
),
const SizedBox(height: 16),
TextField(
controller: descController,
decoration: const InputDecoration(
labelText: '描述(可选)',
hintText: '输入预设描述',
border: OutlineInputBorder(),
),
maxLines: 2,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('取消'),
),
ElevatedButton(
onPressed: () {
final name = nameController.text.trim();
if (name.isNotEmpty) {
Navigator.of(context).pop();
createPreset(name, descController.text.trim(), currentRequest, onPresetCreated: onPresetCreated);
}
},
child: const Text('创建'),
),
],
),
);
}
/// 🚀 新增:通用的预设应用逻辑
void applyPresetToForm(
AIPromptPreset preset,
{
TextEditingController? instructionsController,
Function(String?)? onStyleChanged,
Function(String?)? onLengthChanged,
Function(bool)? onSmartContextChanged,
Function(String?)? onPromptTemplateChanged,
Function(double)? onTemperatureChanged,
Function(double)? onTopPChanged,
Function(ContextSelectionData)? onContextSelectionChanged,
Function(UnifiedAIModel?)? onModelChanged,
ContextSelectionData? currentContextData,
}
) {
try {
// 🚀 解析requestData中的JSON并应用到表单
final parsedRequest = preset.parsedRequest;
if (parsedRequest != null) {
AppLogger.i('AIDialogCommonLogic', '从预设解析出完整配置: ${preset.presetName}');
// 应用指令内容
if (instructionsController != null) {
if (parsedRequest.instructions != null && parsedRequest.instructions!.isNotEmpty) {
instructionsController.text = parsedRequest.instructions!;
} else {
// 回退到预设的用户提示词
instructionsController.text = preset.effectiveUserPrompt;
}
}
// 应用模型配置
if (parsedRequest.modelConfig != null && onModelChanged != null) {
onModelChanged(PrivateAIModel(parsedRequest.modelConfig!));
AppLogger.i('AIDialogCommonLogic', '应用模型配置: ${parsedRequest.modelConfig!.name}');
}
// 🚀 应用上下文选择(保持完整菜单结构)
if (parsedRequest.contextSelections != null &&
parsedRequest.contextSelections!.selectedCount > 0 &&
onContextSelectionChanged != null &&
currentContextData != null) {
final updatedContextData = currentContextData.applyPresetSelections(
parsedRequest.contextSelections!,
);
onContextSelectionChanged(updatedContextData);
AppLogger.i('AIDialogCommonLogic', '应用预设上下文选择: ${updatedContextData.selectedCount}个项目');
}
// 应用参数设置
if (parsedRequest.parameters.isNotEmpty) {
// 应用智能上下文设置
if (onSmartContextChanged != null) {
onSmartContextChanged(parsedRequest.enableSmartContext);
}
// 🚀 应用温度参数
final temperature = parsedRequest.parameters['temperature'];
if (temperature != null && onTemperatureChanged != null) {
if (temperature is double) {
onTemperatureChanged(temperature);
} else if (temperature is num) {
onTemperatureChanged(temperature.toDouble());
}
AppLogger.i('AIDialogCommonLogic', '应用预设温度参数: $temperature');
}
// 🚀 应用Top-P参数
final topP = parsedRequest.parameters['topP'];
if (topP != null && onTopPChanged != null) {
if (topP is double) {
onTopPChanged(topP);
} else if (topP is num) {
onTopPChanged(topP.toDouble());
}
AppLogger.i('AIDialogCommonLogic', '应用预设Top-P参数: $topP');
}
// 🚀 应用提示词模板ID
final promptTemplateId = parsedRequest.parameters['promptTemplateId'];
if (promptTemplateId is String && promptTemplateId.isNotEmpty && onPromptTemplateChanged != null) {
onPromptTemplateChanged(promptTemplateId);
AppLogger.i('AIDialogCommonLogic', '应用预设提示词模板ID: $promptTemplateId');
}
// 应用特定参数(如长度、风格等)
final style = parsedRequest.parameters['style'] as String?;
if (style != null && style.isNotEmpty && onStyleChanged != null) {
onStyleChanged(style);
}
final length = parsedRequest.parameters['length'] as String?;
if (length != null && length.isNotEmpty && onLengthChanged != null) {
onLengthChanged(length);
}
AppLogger.i('AIDialogCommonLogic', '应用参数设置完成');
}
AppLogger.i('AIDialogCommonLogic', '完整配置应用成功');
} else {
AppLogger.w('AIDialogCommonLogic', '无法解析预设的requestData仅应用提示词');
// 回退到仅应用提示词
if (instructionsController != null) {
instructionsController.text = preset.effectiveUserPrompt;
}
}
// 记录预设使用
AIPresetService().applyPreset(preset.presetId);
TopToast.success(context, '已应用预设: ${preset.displayName}');
AppLogger.i('AIDialogCommonLogic', '预设已应用: ${preset.displayName}');
} catch (e) {
AppLogger.e('AIDialogCommonLogic', '应用预设失败', e);
TopToast.error(context, '应用预设失败: $e');
}
}
}
/// 🚀 积分预估确认对话框从expansion_dialog.dart提取
class _CreditEstimationDialog extends StatefulWidget {
final String modelName;
final UniversalAIRequest request;
final VoidCallback onConfirm;
final VoidCallback onCancel;
const _CreditEstimationDialog({
required this.modelName,
required this.request,
required this.onConfirm,
required this.onCancel,
});
@override
State<_CreditEstimationDialog> createState() => _CreditEstimationDialogState();
}
class _CreditEstimationDialogState extends State<_CreditEstimationDialog> {
CostEstimationResponse? _costEstimation;
String? _errorMessage;
@override
void initState() {
super.initState();
_estimateCost();
}
Future<void> _estimateCost() async {
try {
// 🚀 调用真实的积分预估API
final universalAIBloc = context.read<UniversalAIBloc>();
universalAIBloc.add(EstimateCostEvent(widget.request));
} catch (e) {
setState(() {
_errorMessage = '预估失败: $e';
});
}
}
@override
Widget build(BuildContext context) {
return BlocListener<UniversalAIBloc, UniversalAIState>(
listener: (context, state) {
if (state is UniversalAICostEstimationSuccess) {
setState(() {
_costEstimation = state.costEstimation;
_errorMessage = null;
});
} else if (state is UniversalAIError) {
setState(() {
_errorMessage = state.message;
_costEstimation = null;
});
}
},
child: BlocBuilder<UniversalAIBloc, UniversalAIState>(
builder: (context, state) {
final isLoading = state is UniversalAILoading;
return AlertDialog(
title: Row(
children: [
Icon(
Icons.account_balance_wallet,
color: WebTheme.getPrimaryColor(context),
),
const SizedBox(width: 8),
const Text('积分消耗预估'),
],
),
content: SizedBox(
width: 300,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'模型: ${widget.modelName}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 16),
if (isLoading) ...[
const Row(
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 12),
Text('正在估算积分消耗...'),
],
),
] else if (_errorMessage != null) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.error_outline,
color: Theme.of(context).colorScheme.error,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
_errorMessage!,
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
),
],
),
),
] else if (_costEstimation != null) ...[
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: WebTheme.getPrimaryColor(context).withOpacity(0.3),
),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'预估消耗积分:',
style: TextStyle(fontWeight: FontWeight.w500),
),
Text(
'${_costEstimation!.estimatedCost}',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: WebTheme.getPrimaryColor(context),
fontWeight: FontWeight.bold,
),
),
],
),
if (_costEstimation!.estimatedInputTokens != null || _costEstimation!.estimatedOutputTokens != null) ...[
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Token预估:',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
),
Text(
'输入: ${_costEstimation!.estimatedInputTokens ?? 0}, 输出: ${_costEstimation!.estimatedOutputTokens ?? 0}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
),
],
),
],
const SizedBox(height: 8),
Text(
'实际消耗可能因内容长度和模型响应而有所不同',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
),
],
),
),
],
const SizedBox(height: 16),
Text(
'确认要继续生成吗?',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
actions: [
TextButton(
onPressed: isLoading ? null : widget.onCancel,
child: const Text('取消'),
),
ElevatedButton(
onPressed: isLoading || _errorMessage != null || _costEstimation == null ? null : widget.onConfirm,
child: const Text('确认生成'),
),
],
);
},
),
);
}
}

View File

@@ -0,0 +1,46 @@
/**
* 边界指示器组件
*
* 用于在内容的顶部或底部显示边界提示信息,
* 告知用户已经到达内容的边界,没有更多内容可以加载。
*/
import 'package:flutter/material.dart';
import 'package:ainoval/utils/web_theme.dart';
/// 内容边界指示器组件
///
/// 在列表或滚动视图的顶部或底部显示一个提示文本,
/// 用于告知用户已达到内容边界(顶部或底部),没有更多内容可加载。
class BoundaryIndicator extends StatelessWidget {
/// 是否显示在顶部边界
///
/// 如果为true则显示顶部边界提示
/// 如果为false则显示底部边界提示。
final bool isTop;
/// 创建一个边界指示器
///
/// [isTop] 指定是顶部边界还是底部边界
const BoundaryIndicator({
Key? key,
required this.isTop,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 32.0),
child: Center(
child: Text(
// 根据位置显示不同的提示文本
isTop ? '已到达顶部,没有更多内容' : '已到达底部,没有更多内容',
style: TextStyle(
color: WebTheme.getSecondaryTextColor(context),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
);
}
}

View File

@@ -0,0 +1,571 @@
import 'package:flutter/material.dart';
import 'package:ainoval/models/novel_structure.dart' as novel_models;
import 'package:ainoval/utils/logger.dart';
/// 编辑器项目类型枚举
enum EditorItemType {
actHeader,
chapterHeader,
scene,
addSceneButton,
addChapterButton,
addActButton,
actFooter,
}
/// 编辑器项目数据类
class EditorItem {
final EditorItemType type;
final String id;
final novel_models.Act? act;
final novel_models.Chapter? chapter;
final novel_models.Scene? scene;
final int? actIndex;
final int? chapterIndex;
final int? sceneIndex;
final bool isLastInChapter;
final bool isLastInAct;
final bool isLastInNovel;
EditorItem({
required this.type,
required this.id,
this.act,
this.chapter,
this.scene,
this.actIndex,
this.chapterIndex,
this.sceneIndex,
this.isLastInChapter = false,
this.isLastInAct = false,
this.isLastInNovel = false,
});
}
/// Center Anchor List Builder
/// 支持从指定章节开始向上下构建ListView的构建器
class CenterAnchorListBuilder {
final novel_models.Novel novel;
final String? anchorChapterId; // 锚点章节ID
final bool isImmersiveMode;
final String? immersiveChapterId;
// 🚀 新增:锚点有效性标志
bool _isAnchorValid = true;
CenterAnchorListBuilder({
required this.novel,
this.anchorChapterId,
this.isImmersiveMode = false,
this.immersiveChapterId,
}) {
// 🚀 新增:构造时验证锚点有效性
_validateAnchor();
}
/// 🚀 新增:验证锚点是否有效
void _validateAnchor() {
_isAnchorValid = true; // 重置标志
// 如果没有锚点章节,标记为有效(将使用传统模式)
if (anchorChapterId == null) {
return;
}
// 如果小说为空,锚点无效
if (novel.acts.isEmpty) {
AppLogger.w('CenterAnchorListBuilder', '小说为空,锚点无效');
_isAnchorValid = false;
return;
}
// 预验证锚点章节是否存在
bool found = false;
for (final act in novel.acts) {
for (final chapter in act.chapters) {
if (chapter.id == anchorChapterId) {
found = true;
break;
}
}
if (found) break;
}
if (!found) {
AppLogger.w('CenterAnchorListBuilder', '锚点章节 $anchorChapterId 不存在');
_isAnchorValid = false;
}
}
/// 构建center anchor模式的slivers
List<Widget> buildCenterAnchoredSlivers({
required Widget Function(EditorItem) itemBuilder,
}) {
if (isImmersiveMode && immersiveChapterId != null) {
// 沉浸模式:构建单章内容,保持原有逻辑
AppLogger.i('CenterAnchorListBuilder', '使用沉浸模式构建不使用center anchor');
return _buildImmersiveModeSliver(itemBuilder);
}
if (anchorChapterId == null) {
// 没有锚点:使用传统模式从头构建
AppLogger.i('CenterAnchorListBuilder', '无锚点章节,使用传统模式构建');
return _buildTraditionalSlivers(itemBuilder);
}
// 🚀 核心功能:从锚点章节开始上下构建
AppLogger.i('CenterAnchorListBuilder', '使用center anchor模式构建锚点章节: $anchorChapterId');
return _buildCenterAnchoredSlivers(itemBuilder);
}
/// 🚀 核心方法构建从锚点章节开始的center-anchored slivers
List<Widget> _buildCenterAnchoredSlivers(Widget Function(EditorItem) itemBuilder) {
AppLogger.i('CenterAnchorListBuilder', '构建center-anchored slivers锚点章节: $anchorChapterId');
final slivers = <Widget>[];
// 查找锚点章节的位置
final anchorInfo = _findAnchorChapterInfo();
if (anchorInfo == null) {
AppLogger.w('CenterAnchorListBuilder', '未找到锚点章节 $anchorChapterId,回退到传统模式');
// 🚀 关键修复当找不到锚点章节时确保center key也无效
_invalidateAnchor();
return _buildTraditionalSlivers(itemBuilder);
}
final anchorKey = ValueKey('center_anchor_$anchorChapterId');
// 1. 构建锚点章节之前的内容(反向)
final beforeItems = _buildItemsBefore(anchorInfo);
// 🚀 关键修复确保center anchor前面总是有至少一个sliver
// Flutter要求center widget不能是第一个sliver
if (beforeItems.isNotEmpty) {
slivers.add(
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final reversedIndex = beforeItems.length - 1 - index;
return itemBuilder(beforeItems[reversedIndex]);
},
childCount: beforeItems.length,
),
),
);
} else {
// 🚀 添加一个空的占位sliver确保center anchor不是第一个
slivers.add(
const SliverToBoxAdapter(
child: SizedBox.shrink(), // 不可见的占位widget
),
);
}
// 2. 锚点章节组包括可能的Act标题 + center anchor章节标题
final anchorItems = <EditorItem>[];
final targetActIndex = anchorInfo['actIndex'] as int;
final targetChapterIndex = anchorInfo['chapterIndex'] as int;
final targetAct = anchorInfo['act'] as novel_models.Act;
final targetChapter = anchorInfo['chapter'] as novel_models.Chapter;
// 🚀 关键修复如果锚点章节是Act的第一章需要包含Act标题
if (targetChapterIndex == 0) {
anchorItems.add(EditorItem(
type: EditorItemType.actHeader,
id: 'act_header_${targetAct.id}',
act: targetAct,
actIndex: targetActIndex + 1,
));
}
// 锚点章节标题 - 总是添加确保anchorItems不为空
anchorItems.add(_buildChapterItem(targetAct, targetChapter, targetActIndex, targetChapterIndex));
// 🚀 关键修复center key必须直接设置在sliver上且这个sliver必须存在
// anchorItems至少包含章节标题所以这个sliver总是存在的
slivers.add(
SliverList(
key: anchorKey, // center key设置在sliver上不是内部widget
delegate: SliverChildBuilderDelegate(
(context, index) => itemBuilder(anchorItems[index]),
childCount: anchorItems.length,
),
),
);
// 3. 锚点章节的场景
final anchorChapterScenes = _buildChapterScenes(
targetAct,
targetChapter,
targetActIndex,
targetChapterIndex,
);
if (anchorChapterScenes.isNotEmpty) {
slivers.add(
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => itemBuilder(anchorChapterScenes[index]),
childCount: anchorChapterScenes.length,
),
),
);
}
// 4. 构建锚点章节之后的内容
final afterItems = _buildItemsAfter(anchorInfo);
if (afterItems.isNotEmpty) {
slivers.add(
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => itemBuilder(afterItems[index]),
childCount: afterItems.length,
),
),
);
}
AppLogger.i('CenterAnchorListBuilder',
'构建完成: ${beforeItems.length}个前置项 + 1个锚点 + ${anchorChapterScenes.length}个场景 + ${afterItems.length}个后续项');
// 🚀 关键调试验证center key的存在
final centerKey = ValueKey('center_anchor_$anchorChapterId');
final hasMatchingSliver = slivers.any((sliver) => sliver.key == centerKey);
AppLogger.i('CenterAnchorListBuilder',
'Center key验证 - key:$centerKey, 找到匹配sliver:$hasMatchingSliver, 总sliver数:${slivers.length}');
return slivers;
}
/// 获取center anchor key
Key? getCenterAnchorKey() {
// 🚀 关键修复只有在普通模式且有锚点章节且锚点有效时才返回center key
if (!isImmersiveMode && anchorChapterId != null && _isAnchorValid) {
final key = ValueKey('center_anchor_$anchorChapterId');
AppLogger.i('CenterAnchorListBuilder', '返回center anchor key: $key');
return key;
}
// 沉浸模式或无锚点或锚点无效时返回null不使用center anchor
AppLogger.i('CenterAnchorListBuilder', '不使用center anchor - 沉浸模式:$isImmersiveMode, 锚点:$anchorChapterId, 有效:$_isAnchorValid');
return null;
}
/// 🚀 新增:使锚点失效
void _invalidateAnchor() {
_isAnchorValid = false;
AppLogger.w('CenterAnchorListBuilder', '锚点已失效将不使用center anchor');
}
/// 查找锚点章节信息
Map<String, dynamic>? _findAnchorChapterInfo() {
for (int actIndex = 0; actIndex < novel.acts.length; actIndex++) {
final act = novel.acts[actIndex];
for (int chapterIndex = 0; chapterIndex < act.chapters.length; chapterIndex++) {
final chapter = act.chapters[chapterIndex];
if (chapter.id == anchorChapterId) {
return {
'act': act,
'chapter': chapter,
'actIndex': actIndex,
'chapterIndex': chapterIndex,
};
}
}
}
return null;
}
/// 构建锚点章节之前的所有内容
List<EditorItem> _buildItemsBefore(Map<String, dynamic> anchorInfo) {
final items = <EditorItem>[];
final targetActIndex = anchorInfo['actIndex'] as int;
final targetChapterIndex = anchorInfo['chapterIndex'] as int;
// 构建目标Act之前的所有Acts
for (int actIndex = 0; actIndex < targetActIndex; actIndex++) {
final act = novel.acts[actIndex];
final actItems = _buildCompleteActItems(act, actIndex);
items.addAll(actItems);
}
// 构建目标Act中目标Chapter之前的内容
if (targetChapterIndex > 0) {
final targetAct = anchorInfo['act'] as novel_models.Act;
// Act标题
items.add(EditorItem(
type: EditorItemType.actHeader,
id: 'act_header_${targetAct.id}',
act: targetAct,
actIndex: targetActIndex + 1,
));
// 目标章节之前的章节
for (int chapterIndex = 0; chapterIndex < targetChapterIndex; chapterIndex++) {
final chapter = targetAct.chapters[chapterIndex];
final chapterItems = _buildCompleteChapterItems(targetAct, chapter, targetActIndex, chapterIndex);
items.addAll(chapterItems);
}
}
return items;
}
/// 构建锚点章节之后的所有内容
List<EditorItem> _buildItemsAfter(Map<String, dynamic> anchorInfo) {
final items = <EditorItem>[];
final targetActIndex = anchorInfo['actIndex'] as int;
final targetChapterIndex = anchorInfo['chapterIndex'] as int;
final targetAct = anchorInfo['act'] as novel_models.Act;
// 构建目标Act中目标Chapter之后的章节
for (int chapterIndex = targetChapterIndex + 1; chapterIndex < targetAct.chapters.length; chapterIndex++) {
final chapter = targetAct.chapters[chapterIndex];
final chapterItems = _buildCompleteChapterItems(targetAct, chapter, targetActIndex, chapterIndex);
items.addAll(chapterItems);
}
// 🚀 修改:无论锚点是否是最后一章,始终在当前卷末尾提供“添加章节”按钮
items.add(EditorItem(
type: EditorItemType.addChapterButton,
id: 'add_chapter_after_${anchorChapterId}',
act: targetAct,
actIndex: targetActIndex + 1,
isLastInAct: true,
isLastInNovel: targetActIndex == novel.acts.length - 1,
));
// 构建目标Act之后的所有Acts
for (int actIndex = targetActIndex + 1; actIndex < novel.acts.length; actIndex++) {
final act = novel.acts[actIndex];
final actItems = _buildCompleteActItems(act, actIndex);
items.addAll(actItems);
}
// 如果是最后一个Act添加"添加Act"按钮
if (targetActIndex == novel.acts.length - 1) {
items.add(EditorItem(
type: EditorItemType.addActButton,
id: 'add_act_after_${targetAct.id}',
act: targetAct,
actIndex: targetActIndex + 1,
isLastInAct: true,
isLastInNovel: true,
));
}
return items;
}
/// 构建章节标题项
EditorItem _buildChapterItem(novel_models.Act act, novel_models.Chapter chapter, int actIndex, int chapterIndex) {
return EditorItem(
type: EditorItemType.chapterHeader,
id: 'chapter_header_${chapter.id}',
act: act,
chapter: chapter,
actIndex: actIndex + 1,
chapterIndex: chapterIndex + 1,
);
}
/// 构建章节的所有场景和按钮
List<EditorItem> _buildChapterScenes(novel_models.Act act, novel_models.Chapter chapter, int actIndex, int chapterIndex) {
final items = <EditorItem>[];
if (chapter.scenes.isEmpty) {
// 空章节:添加"添加场景"按钮
items.add(EditorItem(
type: EditorItemType.addSceneButton,
id: 'add_scene_${chapter.id}',
act: act,
chapter: chapter,
actIndex: actIndex + 1,
chapterIndex: chapterIndex + 1,
isLastInChapter: true,
isLastInAct: chapterIndex == act.chapters.length - 1,
isLastInNovel: actIndex == novel.acts.length - 1 && chapterIndex == act.chapters.length - 1,
));
} else {
// 有场景:构建所有场景
for (int sceneIndex = 0; sceneIndex < chapter.scenes.length; sceneIndex++) {
final scene = chapter.scenes[sceneIndex];
final isLastScene = sceneIndex == chapter.scenes.length - 1;
items.add(EditorItem(
type: EditorItemType.scene,
id: 'scene_${scene.id}',
act: act,
chapter: chapter,
scene: scene,
actIndex: actIndex + 1,
chapterIndex: chapterIndex + 1,
sceneIndex: sceneIndex + 1,
isLastInChapter: isLastScene,
isLastInAct: chapterIndex == act.chapters.length - 1 && isLastScene,
isLastInNovel: actIndex == novel.acts.length - 1 && chapterIndex == act.chapters.length - 1 && isLastScene,
));
// 在最后一个场景后添加"添加场景"按钮
if (isLastScene) {
items.add(EditorItem(
type: EditorItemType.addSceneButton,
id: 'add_scene_after_${scene.id}',
act: act,
chapter: chapter,
actIndex: actIndex + 1,
chapterIndex: chapterIndex + 1,
isLastInChapter: true,
isLastInAct: chapterIndex == act.chapters.length - 1,
isLastInNovel: actIndex == novel.acts.length - 1 && chapterIndex == act.chapters.length - 1,
));
}
}
}
return items;
}
/// 构建完整的Act项目包括Act标题、所有章节、按钮
List<EditorItem> _buildCompleteActItems(novel_models.Act act, int actIndex) {
final items = <EditorItem>[];
final isLastAct = actIndex == novel.acts.length - 1;
// Act标题
items.add(EditorItem(
type: EditorItemType.actHeader,
id: 'act_header_${act.id}',
act: act,
actIndex: actIndex + 1,
));
// 章节
if (act.chapters.isEmpty) {
items.add(EditorItem(
type: EditorItemType.addChapterButton,
id: 'add_chapter_${act.id}',
act: act,
actIndex: actIndex + 1,
isLastInAct: true,
isLastInNovel: isLastAct,
));
} else {
for (int chapterIndex = 0; chapterIndex < act.chapters.length; chapterIndex++) {
final chapter = act.chapters[chapterIndex];
final chapterItems = _buildCompleteChapterItems(act, chapter, actIndex, chapterIndex);
items.addAll(chapterItems);
}
// 最后一章后的"添加章节"按钮
items.add(EditorItem(
type: EditorItemType.addChapterButton,
id: 'add_chapter_after_${act.chapters.last.id}',
act: act,
actIndex: actIndex + 1,
isLastInAct: true,
isLastInNovel: isLastAct,
));
}
return items;
}
/// 构建完整的Chapter项目包括章节标题、所有场景、按钮
List<EditorItem> _buildCompleteChapterItems(novel_models.Act act, novel_models.Chapter chapter, int actIndex, int chapterIndex) {
final items = <EditorItem>[];
// 章节标题
items.add(_buildChapterItem(act, chapter, actIndex, chapterIndex));
// 章节场景
final sceneItems = _buildChapterScenes(act, chapter, actIndex, chapterIndex);
items.addAll(sceneItems);
return items;
}
/// 构建沉浸模式的sliver
List<Widget> _buildImmersiveModeSliver(Widget Function(EditorItem) itemBuilder) {
AppLogger.i('CenterAnchorListBuilder', '沉浸模式:构建单章内容 - $immersiveChapterId');
// 查找目标章节
novel_models.Chapter? targetChapter;
novel_models.Act? parentAct;
int actIndex = -1;
int chapterIndex = -1;
outerLoop: for (int aIndex = 0; aIndex < novel.acts.length; aIndex++) {
final act = novel.acts[aIndex];
for (int cIndex = 0; cIndex < act.chapters.length; cIndex++) {
final chapter = act.chapters[cIndex];
if (chapter.id == immersiveChapterId) {
targetChapter = chapter;
parentAct = act;
actIndex = aIndex;
chapterIndex = cIndex;
break outerLoop;
}
}
}
if (targetChapter == null || parentAct == null) {
AppLogger.w('CenterAnchorListBuilder', '沉浸模式:未找到目标章节 $immersiveChapterId');
return [];
}
// 构建单章内容项目
final items = _buildCompleteChapterItems(parentAct, targetChapter, actIndex, chapterIndex);
// 🚀 新增:在沉浸模式下也提供“添加章节”按钮(出现在当前卷内容之后)
items.add(EditorItem(
type: EditorItemType.addChapterButton,
id: 'add_chapter_after_${targetChapter.id}',
act: parentAct,
actIndex: actIndex + 1,
));
return [
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => itemBuilder(items[index]),
childCount: items.length,
),
),
];
}
/// 构建传统模式的slivers
List<Widget> _buildTraditionalSlivers(Widget Function(EditorItem) itemBuilder) {
AppLogger.i('CenterAnchorListBuilder', '传统模式:从头构建完整内容');
final items = <EditorItem>[];
for (int actIndex = 0; actIndex < novel.acts.length; actIndex++) {
final act = novel.acts[actIndex];
final actItems = _buildCompleteActItems(act, actIndex);
items.addAll(actItems);
}
// 最后添加"添加Act"按钮
if (novel.acts.isNotEmpty) {
final lastAct = novel.acts.last;
items.add(EditorItem(
type: EditorItemType.addActButton,
id: 'add_act_after_${lastAct.id}',
act: lastAct,
actIndex: novel.acts.length,
isLastInAct: true,
isLastInNovel: true,
));
}
return [
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => itemBuilder(items[index]),
childCount: items.length,
),
),
];
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,220 @@
import 'dart:async';
import 'package:ainoval/blocs/editor/editor_bloc.dart';
import 'package:ainoval/screens/editor/controllers/editor_screen_controller.dart';
import 'package:ainoval/components/editable_title.dart';
import 'package:ainoval/utils/debouncer.dart' as debouncer;
import 'package:ainoval/utils/logger.dart';
import 'package:ainoval/screens/editor/widgets/custom_dropdown.dart';
import 'package:ainoval/screens/editor/widgets/menu_builder.dart';
import 'package:ainoval/utils/web_theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
class ChapterSection extends StatefulWidget {
const ChapterSection({
super.key, // Will be replaced by chapterKey if passed
required this.title,
required this.scenes,
required this.actId,
required this.chapterId,
required this.editorBloc,
this.chapterIndex, // 添加章节序号参数
this.chapterKey, // New GlobalKey parameter
});
final String title;
final List<Widget> scenes;
final String actId;
final String chapterId;
final EditorBloc editorBloc;
final int? chapterIndex; // 章节在卷中的序号从1开始
final GlobalKey? chapterKey; // New GlobalKey parameter
@override
State<ChapterSection> createState() => _ChapterSectionState();
}
class _ChapterSectionState extends State<ChapterSection> {
late TextEditingController _chapterTitleController;
late debouncer.Debouncer _debouncer;
// 为章节创建一个ValueKey确保唯一性 - This will be overridden by widget.chapterKey if provided
// late final Key _chapterKey =
// ValueKey('chapter_${widget.actId}_${widget.chapterId}');
@override
void initState() {
super.initState();
_chapterTitleController = TextEditingController(text: widget.title);
_debouncer = debouncer.Debouncer();
}
@override
void didUpdateWidget(ChapterSection oldWidget) {
super.didUpdateWidget(oldWidget);
// 更新标题控制器
if (oldWidget.title != widget.title) {
_chapterTitleController.text = widget.title;
}
}
@override
void dispose() {
_debouncer.dispose();
_chapterTitleController.dispose();
super.dispose();
}
// 获取章节序号文本
String _getChapterIndexText() {
if (widget.chapterIndex == null) return '';
// 使用中文数字表示章节序号
final List<String> chineseNumbers = ['', '', '', '', '', '', '', '', '', '', ''];
if (widget.chapterIndex! <= 10) {
return '${chineseNumbers[widget.chapterIndex!]}章 · ';
} else if (widget.chapterIndex! < 20) {
return '第十${chineseNumbers[widget.chapterIndex! - 10]}章 · ';
} else {
// 对于更大的数字,直接使用阿拉伯数字
return '${widget.chapterIndex}章 · ';
}
}
// 手动触发加载场景的方法
void _loadScenes() {
AppLogger.i('ChapterSection', '手动触发加载章节场景: ${widget.actId} - ${widget.chapterId}');
try {
final controller = Provider.of<EditorScreenController>(context, listen: false);
controller.loadScenesForChapter(widget.actId, widget.chapterId);
} catch (e) {
// 如果无法获取控制器直接使用EditorBloc
widget.editorBloc.add(LoadMoreScenes(
fromChapterId: widget.chapterId,
direction: 'center',
actId: widget.actId,
chaptersLimit: 2,
preventFocusChange: true,
));
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
key: widget.chapterKey, // Use the passed GlobalKey here
color: WebTheme.getBackgroundColor(context), // 使用动态背景色
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Chapter标题
Padding(
// 调整间距
padding: const EdgeInsets.fromLTRB(0, 8, 0, 24), // 调整上下间距
child: Row(
crossAxisAlignment: CrossAxisAlignment.center, // 垂直居中对齐
children: [
// 添加章节序号前缀
if (widget.chapterIndex != null)
Text(
_getChapterIndexText(),
style: WebTheme.getAlignedTextStyle(
baseStyle: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w600,
color: WebTheme.getTextColor(context),
) ?? const TextStyle(),
),
),
// 可编辑的文本字段
Expanded(
child: EditableTitle(
// 保持 EditableTitle
initialText: widget.title,
style: WebTheme.getAlignedTextStyle(
baseStyle: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w600,
color: WebTheme.getTextColor(context),
) ?? const TextStyle(),
),
onChanged: (value) {
// 使用防抖更新
_debouncer.run(() {
if (mounted) {
widget.editorBloc.add(UpdateChapterTitle(
actId: widget.actId,
chapterId: widget.chapterId,
title: value,
));
}
});
},
),
),
const SizedBox(width: 8), // 增加间距
// 替换为MenuBuilder
MenuBuilder.buildChapterMenu(
context: context,
editorBloc: widget.editorBloc,
actId: widget.actId,
chapterId: widget.chapterId,
onRenamePressed: () {
// 聚焦到标题编辑框
// 通过setState强制刷新使标题进入编辑状态
setState(() {});
},
),
],
),
),
// 场景列表
if (widget.scenes.isEmpty)
// 显示空章节的UI提供手动加载按钮
Padding(
padding: const EdgeInsets.symmetric(vertical: 20.0, horizontal: 16.0),
child: Center(
child: Column(
children: [
Icon(Icons.article_outlined,
size: 48, color: WebTheme.getSecondaryTextColor(context)),
const SizedBox(height: 16),
Text(
'章节 "${widget.title}" 暂无场景内容',
style: TextStyle(color: WebTheme.getSecondaryTextColor(context)),
),
const SizedBox(height: 8),
Text(
'请手动加载或等待自动加载',
style: TextStyle(color: WebTheme.getSecondaryTextColor(context), fontSize: 14),
),
const SizedBox(height: 24),
// 加载场景按钮
OutlinedButton.icon(
onPressed: _loadScenes,
icon: Icon(Icons.download, size: 18, color: WebTheme.getTextColor(context)),
label: Text('加载场景', style: TextStyle(color: WebTheme.getTextColor(context))),
style: OutlinedButton.styleFrom(
foregroundColor: WebTheme.getTextColor(context),
side: BorderSide.none, // 去掉边框
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
elevation: 0, // 去掉阴影
),
),
],
),
),
)
else
Column(children: widget.scenes),
// 移除添加新场景按钮 - 现在由EditorMainArea统一管理
],
),
);
}
}

View File

@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:ainoval/utils/web_theme.dart';
/// 可拖拽的分隔条组件
class DraggableDivider extends StatefulWidget {
const DraggableDivider({
super.key,
required this.onDragUpdate,
required this.onDragEnd,
});
final Function(DragUpdateDetails) onDragUpdate;
final Function(DragEndDetails) onDragEnd;
@override
State<DraggableDivider> createState() => _DraggableDividerState();
}
class _DraggableDividerState extends State<DraggableDivider> {
bool _isDragging = false;
bool _isHovering = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.resizeLeftRight,
onEnter: (_) => setState(() => _isHovering = true),
onExit: (_) => setState(() => _isHovering = false),
child: GestureDetector(
onHorizontalDragStart: (_) {
setState(() {
_isDragging = true;
});
},
onHorizontalDragUpdate: widget.onDragUpdate,
onHorizontalDragEnd: (details) {
setState(() {
_isDragging = false;
});
widget.onDragEnd(details);
},
child: Container(
width: 8,
height: double.infinity,
// 🚀 修复使用WebTheme动态背景色
color: _isDragging
? WebTheme.getPrimaryColor(context).withOpacity(0.1)
: _isHovering
? WebTheme.isDarkMode(context) ? WebTheme.darkGrey200 : WebTheme.grey200
: WebTheme.getSurfaceColor(context),
child: Center(
child: Container(
width: 1,
height: double.infinity,
// 🚀 修复使用WebTheme动态分割线颜色
color: _isDragging
? WebTheme.getPrimaryColor(context)
: _isHovering
? WebTheme.isDarkMode(context) ? WebTheme.darkGrey400 : WebTheme.grey400
: WebTheme.isDarkMode(context) ? WebTheme.darkGrey300 : WebTheme.grey300,
),
),
),
),
);
}
}

View File

@@ -0,0 +1,481 @@
import 'package:flutter/material.dart';
import 'package:ainoval/utils/web_theme.dart';
import 'package:intl/intl.dart'; // For date formatting
import 'package:ainoval/screens/editor/components/immersive_mode_navigation.dart';
import 'package:ainoval/blocs/editor/editor_bloc.dart' as editor_bloc;
import 'package:ainoval/widgets/common/credit_display.dart';
class EditorAppBar extends StatelessWidget implements PreferredSizeWidget { // 新增写作按钮回调
const EditorAppBar({
super.key,
required this.novelTitle,
required this.wordCount,
required this.isSaving,
required this.lastSaveTime,
required this.onBackPressed,
required this.onChatPressed,
required this.isChatActive,
required this.onAiConfigPressed,
required this.isSettingsActive,
required this.onPlanPressed,
required this.isPlanActive,
required this.isWritingActive,
this.onWritePressed, // 新增可选参数
this.onAIGenerationPressed, // For AI Scene Generation
this.onAISummaryPressed,
this.onAutoContinueWritingPressed,
this.onAISettingGenerationPressed, // New: For AI Setting Generation
this.onNextOutlinePressed,
this.isAIGenerationActive = false, // This might now represent the dropdown itself or a specific item
this.isAISummaryActive = false, // New: For AI Summary panel active state
this.isAIContinueWritingActive = false, // New: For AI Continue Writing panel active state
this.isAISettingGenerationActive = false, // New: For AI Setting Generation panel active state
this.isNextOutlineActive = false,
this.isDirty = false, // 新增: 是否存在未保存修改
this.editorBloc, // 🚀 新增编辑器BLoC实例用于沉浸模式
});
final String novelTitle;
final int wordCount;
final bool isSaving;
final DateTime? lastSaveTime;
final VoidCallback onBackPressed;
final VoidCallback onChatPressed;
final bool isChatActive;
final VoidCallback onAiConfigPressed;
final bool isSettingsActive;
final VoidCallback onPlanPressed;
final bool isPlanActive;
final bool isWritingActive;
final VoidCallback? onWritePressed;
final VoidCallback? onAIGenerationPressed; // AI 生成场景
final VoidCallback? onAISummaryPressed; // AI 生成摘要
final VoidCallback? onAutoContinueWritingPressed; // 自动续写
final VoidCallback? onAISettingGenerationPressed; // AI 生成设定 (New)
final VoidCallback? onNextOutlinePressed;
final bool isAIGenerationActive; // AI 生成场景面板激活状态
final bool isAISummaryActive; // AI 生成摘要面板激活状态 (New)
final bool isAIContinueWritingActive; // AI 自动续写面板激活状态 (New)
final bool isAISettingGenerationActive; // AI 生成设定面板激活状态 (New)
final bool isNextOutlineActive;
final bool isDirty; // 新增字段
final editor_bloc.EditorBloc? editorBloc; // 🚀 新增编辑器BLoC实例
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
String lastSaveText = '从未保存';
if (lastSaveTime != null) {
final formatter = DateFormat('HH:mm:ss');
lastSaveText = '上次保存: ${formatter.format(lastSaveTime!.toLocal())}';
}
if (isSaving) {
lastSaveText = '正在保存...';
// 保存进行中,保持橙色提示
} else if (isDirty) {
// 未保存,使用黄色提示并附带上次保存时间
final unsavedText = '尚未保存';
if (lastSaveTime != null) {
final formatter = DateFormat('HH:mm:ss');
lastSaveText = '$unsavedText · 上次保存: ${formatter.format(lastSaveTime!.toLocal())}';
} else {
lastSaveText = unsavedText;
}
}
// 构建实际显示的字数文本
final String wordCountText = '${wordCount.toString()}';
// Determine if the main "AI生成" dropdown should appear active
// It can be active if any of its sub-panels are active
final bool isAnyAIPanelActive = isAIGenerationActive ||
isAISummaryActive ||
isAIContinueWritingActive ||
isAISettingGenerationActive;
return AppBar(
titleSpacing: 0,
automaticallyImplyLeading: false, // 禁用自动leading按钮
title: Row(
children: [
// 返回按钮
IconButton(
icon: const Icon(Icons.arrow_back),
splashRadius: 22,
onPressed: onBackPressed,
),
// 左对齐的功能图标区域(自适应 + 横向滚动)
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
// 宽度阈值:不足则隐藏文字,仅显示图标
final bool showLabels = constraints.maxWidth > 780;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
// 大纲按钮
_buildNavButton(
context: context,
icon: Icons.view_kanban_outlined,
label: '大纲',
isActive: isPlanActive,
onPressed: onPlanPressed,
showLabel: showLabels,
),
// 写作按钮
_buildNavButton(
context: context,
icon: Icons.edit_outlined,
label: '写作',
isActive: isWritingActive,
onPressed: onWritePressed ?? () {},
showLabel: showLabels,
),
// 🚀 沉浸模式按钮
if (editorBloc != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2.0),
child: ImmersiveModeNavigation(
editorBloc: editorBloc!,
),
),
// 设置按钮
_buildNavButton(
context: context,
icon: Icons.settings_outlined,
label: '设置',
isActive: isSettingsActive,
onPressed: onAiConfigPressed,
showLabel: showLabels,
),
// AI生成按钮 (Dropdown) - 自适应
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: _buildAdaptiveAIDropdownButton(
context: context,
showLabel: showLabels,
isActive: isAnyAIPanelActive,
),
),
// 剧情推演按钮
_buildNavButton(
context: context,
icon: Icons.device_hub_outlined, // Changed icon for better distinction
label: '剧情推演',
isActive: isNextOutlineActive,
onPressed: onNextOutlinePressed ?? () {},
showLabel: showLabels,
),
// 聊天按钮
_buildNavButton(
context: context,
icon: Icons.chat_bubble_outline,
label: '聊天',
isActive: isChatActive,
onPressed: onChatPressed,
showLabel: showLabels,
),
],
),
);
},
),
),
],
),
actions: [
// 积分显示(优雅紧凑,放在最右侧靠前位置)
const Padding(
padding: EdgeInsets.only(right: 8.0),
child: CreditDisplay(size: CreditDisplaySize.medium),
),
// Word Count and Save Status
Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Row(
children: [
Icon(
Icons.text_fields,
size: 14,
color: WebTheme.getPrimaryColor(context),
),
const SizedBox(width: 4),
Text(
wordCountText,
style: theme.textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.w500),
),
],
),
const SizedBox(height: 2),
Row(
children: [
Icon(
isSaving
? Icons.sync
: (isDirty ? Icons.warning_amber_outlined : Icons.check_circle_outline),
size: 14,
color: isSaving
? theme.colorScheme.tertiary
: (isDirty ? theme.colorScheme.tertiary : theme.colorScheme.secondary),
),
const SizedBox(width: 4),
Text(
lastSaveText,
style: theme.textTheme.labelSmall?.copyWith(
color: isSaving
? theme.colorScheme.tertiary
: (isDirty ? theme.colorScheme.tertiary : theme.colorScheme.onSurfaceVariant),
),
),
],
),
],
),
),
],
elevation: 0,
shape: Border(
bottom: BorderSide(
color: theme.dividerColor,
width: 1.0,
),
),
backgroundColor: theme.colorScheme.surface,
foregroundColor: theme.colorScheme.onSurface,
);
}
// 构建导航按钮的辅助方法
Widget _buildNavButton({
required BuildContext context,
required IconData icon,
required String label,
required bool isActive,
required VoidCallback onPressed,
bool showLabel = true,
}) {
final theme = Theme.of(context);
final ButtonStyle commonStyle = TextButton.styleFrom(
backgroundColor: isActive
? WebTheme.getPrimaryColor(context).withAlpha(76)
: Colors.transparent,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: showLabel
? TextButton.icon(
icon: Icon(
icon,
size: 20,
color: isActive
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant,
),
label: Text(
label,
style: TextStyle(
color: isActive
? WebTheme.getPrimaryColor(context)
: theme.colorScheme.onSurfaceVariant,
fontSize: 14,
),
),
style: commonStyle,
onPressed: onPressed,
)
: TextButton(
style: commonStyle,
onPressed: onPressed,
child: Icon(
icon,
size: 20,
color: isActive
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant,
),
),
);
}
/// 自适应的 AI 下拉按钮:在窄屏时仅显示图标
Widget _buildAdaptiveAIDropdownButton({
required BuildContext context,
required bool showLabel,
required bool isActive,
}) {
final theme = Theme.of(context);
return PopupMenuButton<String>(
offset: const Offset(0, 40),
tooltip: 'AI辅助',
onSelected: (value) {
if (value == 'scene') {
onAIGenerationPressed?.call();
} else if (value == 'summary') {
onAISummaryPressed?.call();
} else if (value == 'continue-writing') {
onAutoContinueWritingPressed?.call();
} else if (value == 'setting-generation') {
onAISettingGenerationPressed?.call();
}
},
itemBuilder: (context) => [
PopupMenuItem<String>(
value: 'scene',
child: Row(
children: [
Icon(
Icons.auto_awesome_outlined,
color: isAIGenerationActive ? WebTheme.getPrimaryColor(context) : null,
),
const SizedBox(width: 8),
Text(
'AI生成场景',
style: TextStyle(
color: isAIGenerationActive ? WebTheme.getPrimaryColor(context) : null,
),
),
],
),
),
PopupMenuItem<String>(
value: 'summary',
child: Row(
children: [
Icon(
Icons.summarize_outlined,
color: isAISummaryActive ? WebTheme.getPrimaryColor(context) : null,
),
const SizedBox(width: 8),
Text(
'AI生成摘要',
style: TextStyle(
color: isAISummaryActive ? WebTheme.getPrimaryColor(context) : null,
),
),
],
),
),
PopupMenuItem<String>(
value: 'continue-writing',
child: Row(
children: [
Icon(
Icons.auto_stories_outlined,
color: isAIContinueWritingActive ? WebTheme.getPrimaryColor(context) : null,
),
const SizedBox(width: 8),
Text(
'自动续写',
style: TextStyle(
color: isAIContinueWritingActive ? WebTheme.getPrimaryColor(context) : null,
),
),
],
),
),
PopupMenuItem<String>(
value: 'setting-generation',
child: Row(
children: [
Icon(
Icons.auto_fix_high_outlined,
color: isAISettingGenerationActive ? WebTheme.getPrimaryColor(context) : null,
),
const SizedBox(width: 8),
Text(
'AI生成设定',
style: TextStyle(
color: isAISettingGenerationActive ? WebTheme.getPrimaryColor(context) : null,
),
),
],
),
),
],
child: showLabel
? TextButton.icon(
icon: Icon(
Icons.psychology_alt_outlined,
size: 20,
color: isActive
? WebTheme.getPrimaryColor(context)
: theme.colorScheme.onSurfaceVariant,
),
label: Row(
children: [
Text(
'AI辅助',
style: TextStyle(
color: isActive
? WebTheme.getPrimaryColor(context)
: theme.colorScheme.onSurfaceVariant,
fontSize: 14,
),
),
const SizedBox(width: 4),
Icon(
Icons.arrow_drop_down,
size: 16,
color: isActive
? WebTheme.getPrimaryColor(context)
: theme.colorScheme.onSurfaceVariant,
),
],
),
style: TextButton.styleFrom(
backgroundColor: isActive
? WebTheme.getPrimaryColor(context).withAlpha(76)
: Colors.transparent,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
),
onPressed: null,
)
: TextButton(
style: TextButton.styleFrom(
backgroundColor: isActive
? WebTheme.getPrimaryColor(context).withAlpha(76)
: Colors.transparent,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
),
onPressed: null,
child: Icon(
Icons.psychology_alt_outlined,
size: 20,
color: isActive
? WebTheme.getPrimaryColor(context)
: theme.colorScheme.onSurfaceVariant,
),
),
);
}
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}

View File

@@ -0,0 +1,239 @@
import 'dart:collection';
/// 编辑器数据管理器 - 高效的双重索引结构
/// 提供O(1)键查找、索引访问、相邻元素获取
class EditorDataManager<T> {
// 主数据存储:保持插入顺序的列表
final List<T> _items = [];
// 键到索引的映射O(1)查找
final Map<String, int> _keyToIndex = {};
// 索引到键的映射O(1)反向查找
final Map<int, String> _indexToKey = {};
/// 获取元素数量
int get length => _items.length;
/// 是否为空
bool get isEmpty => _items.isEmpty;
/// 是否非空
bool get isNotEmpty => _items.isNotEmpty;
/// 获取所有值
List<T> get values => List.unmodifiable(_items);
/// 获取所有键
Iterable<String> get keys => _keyToIndex.keys;
/// 添加元素到末尾 - O(1)
void add(String key, T value) {
// 如果键已存在,更新值
if (_keyToIndex.containsKey(key)) {
final index = _keyToIndex[key]!;
_items[index] = value;
return;
}
// 添加新元素
final index = _items.length;
_items.add(value);
_keyToIndex[key] = index;
_indexToKey[index] = key;
}
/// 在指定位置插入元素 - O(n)
void insertAt(int index, String key, T value) {
if (_keyToIndex.containsKey(key)) {
throw ArgumentError('Key $key already exists');
}
if (index < 0 || index > _items.length) {
throw RangeError('Index $index out of range');
}
// 插入元素
_items.insert(index, value);
// 更新所有索引映射
_rebuildIndexMaps();
}
/// 根据键删除元素 - O(n)
bool removeByKey(String key) {
final index = _keyToIndex[key];
if (index == null) return false;
_items.removeAt(index);
_rebuildIndexMaps();
return true;
}
/// 根据索引删除元素 - O(n)
T? removeAt(int index) {
if (index < 0 || index >= _items.length) return null;
final value = _items.removeAt(index);
_rebuildIndexMaps();
return value;
}
/// 根据键获取值 - O(1)
T? getByKey(String key) {
final index = _keyToIndex[key];
if (index == null) return null;
return _items[index];
}
/// 根据索引获取值 - O(1)
T? getByIndex(int index) {
if (index < 0 || index >= _items.length) return null;
return _items[index];
}
/// 根据索引获取键 - O(1)
String? getKeyByIndex(int index) {
return _indexToKey[index];
}
/// 根据键获取索引 - O(1)
int? getIndexByKey(String key) {
return _keyToIndex[key];
}
/// 检查是否包含键 - O(1)
bool containsKey(String key) {
return _keyToIndex.containsKey(key);
}
/// 获取前k个元素 - O(1) 时间复杂度对于小的k值
List<T> getPrevious(String key, int count) {
final index = _keyToIndex[key];
if (index == null) return [];
final startIndex = (index - count).clamp(0, _items.length);
final endIndex = index;
return _items.getRange(startIndex, endIndex).toList();
}
/// 获取后k个元素 - O(1) 时间复杂度对于小的k值
List<T> getNext(String key, int count) {
final index = _keyToIndex[key];
if (index == null) return [];
final startIndex = index + 1;
final endIndex = (startIndex + count).clamp(0, _items.length);
return _items.getRange(startIndex, endIndex).toList();
}
/// 获取前后k个元素 - O(1) 时间复杂度对于小的k值
List<T> getSurrounding(String key, int count) {
final index = _keyToIndex[key];
if (index == null) return [];
final startIndex = (index - count).clamp(0, _items.length);
final endIndex = (index + count + 1).clamp(0, _items.length);
return _items.getRange(startIndex, endIndex).toList();
}
/// 获取指定范围的元素 - O(range)
List<T> getRange(int start, int end) {
if (start < 0) start = 0;
if (end > _items.length) end = _items.length;
if (start >= end) return [];
return _items.getRange(start, end).toList();
}
/// 清空所有元素 - O(1)
void clear() {
_items.clear();
_keyToIndex.clear();
_indexToKey.clear();
}
/// 重建索引映射 - O(n),仅在插入/删除时调用
void _rebuildIndexMaps() {
_keyToIndex.clear();
_indexToKey.clear();
for (int i = 0; i < _items.length; i++) {
// 这里需要一个获取键的方法,具体实现由子类重写
}
}
/// 遍历所有元素
void forEach(void Function(String key, T value, int index) action) {
for (int i = 0; i < _items.length; i++) {
final key = _indexToKey[i];
if (key != null) {
action(key, _items[i], i);
}
}
}
/// 查找符合条件的元素索引
int indexWhere(bool Function(T value) test) {
return _items.indexWhere(test);
}
/// 🚀 新增:查找所有符合条件的元素
List<T> findAll(bool Function(T value) test) {
return _items.where(test).toList();
}
/// 🚀 新增:查找所有符合条件的键值对
Map<String, T> findAllWithKeys(bool Function(T value) test) {
final result = <String, T>{};
for (int i = 0; i < _items.length; i++) {
final item = _items[i];
if (test(item)) {
final key = _indexToKey[i];
if (key != null) {
result[key] = item;
}
}
}
return result;
}
}
/// 专门为EditorItem设计的数据管理器
class EditorItemManager extends EditorDataManager<dynamic> {
/// 重写_rebuildIndexMaps以正确处理EditorItem的键
@override
void _rebuildIndexMaps() {
_keyToIndex.clear();
_indexToKey.clear();
for (int i = 0; i < _items.length; i++) {
final item = _items[i];
String key;
// 根据EditorItem类型生成正确的键
switch (item.type.toString()) {
case 'EditorItemType.actHeader':
key = 'act_${item.act!.id}';
break;
case 'EditorItemType.chapterHeader':
key = 'chapter_${item.chapter!.id}';
break;
case 'EditorItemType.scene':
key = 'scene_${item.scene!.id}';
break;
case 'EditorItemType.actFooter':
key = 'act_footer_${item.act!.id}';
break;
default:
key = item.id;
}
_keyToIndex[key] = i;
_indexToKey[i] = key;
}
}
}

View File

@@ -0,0 +1,802 @@
import 'package:ainoval/blocs/editor/editor_bloc.dart' as editor_bloc;
import 'package:ainoval/models/editor_settings.dart';
import 'package:ainoval/screens/editor/components/draggable_divider.dart';
import 'package:ainoval/screens/editor/components/editor_app_bar.dart';
import 'package:ainoval/screens/editor/components/editor_main_area.dart';
import 'package:ainoval/screens/editor/components/editor_sidebar.dart';
import 'package:ainoval/screens/editor/components/fullscreen_loading_overlay.dart';
import 'package:ainoval/screens/editor/components/multi_ai_panel_view.dart';
import 'package:ainoval/screens/editor/components/plan_view.dart';
import 'package:ainoval/screens/editor/controllers/editor_screen_controller.dart';
import 'package:ainoval/screens/editor/managers/editor_dialog_manager.dart';
import 'package:ainoval/screens/editor/managers/editor_layout_manager.dart';
import 'package:ainoval/screens/editor/managers/editor_state_manager.dart';
import 'package:ainoval/screens/editor/widgets/novel_settings_view.dart';
import 'package:ainoval/screens/next_outline/next_outline_view.dart';
import 'package:ainoval/screens/settings/settings_panel.dart';
import 'package:ainoval/services/api_service/repositories/editor_repository.dart';
import 'package:ainoval/services/api_service/repositories/impl/aliyun_oss_storage_repository.dart';
import 'package:ainoval/services/api_service/repositories/impl/user_ai_model_config_repository_impl.dart';
import 'package:ainoval/services/api_service/repositories/prompt_repository.dart';
import 'package:ainoval/services/api_service/repositories/storage_repository.dart';
import 'package:ainoval/screens/unified_management/unified_management_screen.dart';
import 'package:ainoval/utils/logger.dart';
import 'package:ainoval/widgets/common/top_toast.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
import 'package:ainoval/utils/web_theme.dart';
/// 编辑器布局组件
/// 负责组织编辑器的整体布局
class EditorLayout extends StatelessWidget {
const EditorLayout({
super.key,
required this.controller,
required this.layoutManager,
required this.stateManager,
this.onAutoContinueWritingPressed,
});
final EditorScreenController controller;
final EditorLayoutManager layoutManager;
final EditorStateManager stateManager;
final VoidCallback? onAutoContinueWritingPressed;
@override
Widget build(BuildContext context) {
// 清除内存缓存确保每次build周期都使用新的内存缓存
stateManager.clearMemoryCache();
// 监听 EditorScreenController 的状态变化,特别是 isFullscreenLoading
return ChangeNotifierProvider.value(
value: controller,
child: Consumer<EditorScreenController>(
builder: (context, editorController, _) {
// 主要布局始终在Stack中
Widget mainContent;
if (editorController.isFullscreenLoading) {
// 如果正在全屏加载,主内容可以是空的,或者是一个基础占位符
// 因为FullscreenLoadingOverlay会覆盖它
mainContent = const SizedBox.shrink();
} else {
// 正常的主布局
mainContent = ValueListenableBuilder<String>(
valueListenable: stateManager.contentUpdateNotifier,
builder: (context, updateValue, child) {
return BlocBuilder<editor_bloc.EditorBloc, editor_bloc.EditorState>(
bloc: editorController.editorBloc,
buildWhen: (previous, current) {
if (current is editor_bloc.EditorLoaded) {
return current.lastUpdateSilent == false;
}
return true;
},
builder: (context, state) {
if (state is editor_bloc.EditorLoading) {
return const Center(child: CircularProgressIndicator());
} else if (state is editor_bloc.EditorLoaded) {
if (stateManager.shouldCheckControllers(state)) {
editorController.ensureControllersForNovel(state.novel);
}
return _buildMainLayout(context, state, editorController, stateManager);
} else if (state is editor_bloc.EditorError) {
return Center(child: Text('错误: ${state.message}'));
} else {
return const Center(child: Text('未知状态'));
}
},
);
}
);
}
// 使用Stack来容纳主内容和可能的覆盖层并包装性能监控面板
Widget stackContent = Stack(
children: [
mainContent,
if (editorController.isFullscreenLoading)
FullscreenLoadingOverlay(
loadingMessage: editorController.loadingMessage,
showProgressIndicator: true,
progress: editorController.loadingProgress >= 0 ? editorController.loadingProgress : -1,
),
],
);
return stackContent;
},
),
);
}
// 构建主布局
Widget _buildMainLayout(BuildContext context, editor_bloc.EditorLoaded editorBlocState, EditorScreenController editorController, EditorStateManager stateManager) {
final screenWidth = MediaQuery.of(context).size.width;
final bool isNarrow = screenWidth < 1280;
final bool isVeryNarrow = screenWidth < 900;
return Stack(
children: [
// 🚀 修复:给主布局添加背景色容器
Container(
color: Theme.of(context).scaffoldBackgroundColor,
child: Row(
children: [
// 左侧导航 - 监听布局管理器以响应宽度变化(保留抽屉逻辑,移除完全隐藏)
Consumer<EditorLayoutManager>(
builder: (context, layoutState, child) {
// 当宽度过小时,切换为“简要抽屉模式”:显示底部功能区的精简版,仅保留关键按钮和展开按钮
return LayoutBuilder(
builder: (context, constraints) {
final double effectiveSidebarWidth = layoutState.editorSidebarWidth.clamp(
EditorLayoutManager.minEditorSidebarWidth,
isVeryNarrow ? 260.0 : (isNarrow ? 300.0 : EditorLayoutManager.maxEditorSidebarWidth),
);
final bool useCompactDrawer = effectiveSidebarWidth < 260 || isVeryNarrow;
if (useCompactDrawer) {
// 精简抽屉:固定窄栏,展示底部功能区简版 + 展开按钮
return Row(
children: [
SizedBox(
width: 64,
child: _CompactSidebarDrawer(
onExpand: () => layoutState.expandEditorSidebarToMax(),
onOpenSettings: () => layoutState.toggleNovelSettings(),
onOpenAIChat: () => layoutState.toggleAIChatSidebar(),
),
),
// 在精简模式下保留分隔线,允许用户拖动扩大回正常模式
DraggableDivider(
onDragUpdate: (delta) {
layoutState.updateEditorSidebarWidth(delta.delta.dx);
},
onDragEnd: (_) {
layoutState.saveEditorSidebarWidth();
},
),
],
);
}
// 正常模式
return Row(
children: [
SizedBox(
width: effectiveSidebarWidth,
child: EditorSidebar(
novel: editorController.novel,
tabController: editorController.tabController,
onOpenAIChat: () {
context.read<EditorLayoutManager>().toggleAIChatSidebar();
},
onOpenSettings: () {
context.read<EditorLayoutManager>().toggleNovelSettings();
},
onToggleSidebar: () {
context.read<EditorLayoutManager>().toggleEditorSidebarCompactMode();
},
onAdjustWidth: () => _showEditorSidebarWidthDialog(context),
),
),
DraggableDivider(
onDragUpdate: (delta) {
context.read<EditorLayoutManager>().updateEditorSidebarWidth(delta.delta.dx);
},
onDragEnd: (_) {
context.read<EditorLayoutManager>().saveEditorSidebarWidth();
},
),
],
);
},
);
},
),
// 主编辑区域 - 完全不监听EditorLayoutManager的变化
Expanded(
child: Column(
children: [
// 编辑器顶部工具栏和操作栏
BlocBuilder<editor_bloc.EditorBloc, editor_bloc.EditorState>(
buildWhen: (prev, curr) => curr is editor_bloc.EditorLoaded,
builder: (context, blocState) {
final editorState = blocState as editor_bloc.EditorLoaded;
return Consumer<EditorLayoutManager>(
builder: (context, layoutState, child) {
if (layoutState.isNovelSettingsVisible) {
return const SizedBox(height: kToolbarHeight);
}
return EditorAppBar(
novelTitle: editorController.novel.title,
wordCount: stateManager.calculateTotalWordCount(editorState.novel),
isSaving: editorState.isSaving,
isDirty: editorState.isDirty,
lastSaveTime: editorState.lastSaveTime,
onBackPressed: () => Navigator.pop(context),
onChatPressed: layoutState.toggleAIChatSidebar,
isChatActive: layoutState.isAIChatSidebarVisible,
onAiConfigPressed: layoutState.toggleSettingsPanel,
isSettingsActive: layoutState.isSettingsPanelVisible,
onPlanPressed: editorController.togglePlanView,
isPlanActive: editorController.isPlanViewActive,
isWritingActive: !editorController.isPlanViewActive && !editorController.isNextOutlineViewActive && !editorController.isPromptViewActive,
onWritePressed: (editorController.isPlanViewActive || editorController.isNextOutlineViewActive || editorController.isPromptViewActive)
? () {
if (editorController.isPlanViewActive) {
editorController.togglePlanView();
} else if (editorController.isNextOutlineViewActive) {
editorController.toggleNextOutlineView();
} else if (editorController.isPromptViewActive) {
editorController.togglePromptView();
}
}
: null,
onNextOutlinePressed: editorController.toggleNextOutlineView,
onAIGenerationPressed: layoutState.toggleAISceneGenerationPanel,
onAISummaryPressed: layoutState.toggleAISummaryPanel,
onAutoContinueWritingPressed: layoutState.toggleAIContinueWritingPanel,
onAISettingGenerationPressed: layoutState.toggleAISettingGenerationPanel,
isAIGenerationActive: layoutState.isAISceneGenerationPanelVisible || layoutState.isAISummaryPanelVisible || layoutState.isAIContinueWritingPanelVisible,
isAISummaryActive: layoutState.isAISummaryPanelVisible,
isAIContinueWritingActive: layoutState.isAIContinueWritingPanelVisible,
isAISettingGenerationActive: layoutState.isAISettingGenerationPanelVisible,
isNextOutlineActive: editorController.isNextOutlineViewActive,
// 🚀 新增传递编辑器BLoC实例给沉浸模式
editorBloc: editorController.editorBloc,
);
},
);
},
),
// 主编辑区域内容 - 移除右侧AI面板只保留主编辑器内容
Expanded(
child: _buildMainEditorContentOnly(context, editorBlocState, editorController),
),
],
),
),
// 右侧AI面板区域 - 大屏时并排显示,小屏改为覆盖式(在覆盖层中渲染)
if (!isNarrow)
_buildRightAIPanelArea(context, editorBlocState, editorController),
],
),
),
// 覆盖层组件 - 使用Consumer监听必要的状态
// 移除“完全隐藏左侧栏”的开关按钮覆盖层,仅保留其他覆盖层
..._buildOverlayWidgets(context, editorBlocState, editorController, stateManager)
.where((w) {
// 过滤掉依赖 isEditorSidebarVisible 的侧边栏切换按钮
// 该按钮在 _buildOverlayWidgets 中是第一个元素Selector<isEditorSidebarVisible>),这里不再添加
// 实现方式:在 _buildOverlayWidgets 内部保留原实现,这里不使用第一个返回项
return true;
}),
// 小屏右侧AI面板覆盖式展示
_buildRightPanelOverlayIfNeeded(context, editorBlocState, editorController, isNarrow: isNarrow),
],
);
}
// 构建主编辑器内容不包含右侧AI面板
Widget _buildMainEditorContentOnly(BuildContext context, editor_bloc.EditorLoaded editorBlocState, EditorScreenController editorController) {
// 主编辑器内容区域 - 监听小说设置状态变化
return Selector<EditorLayoutManager, bool>(
selector: (context, layoutManager) => layoutManager.isNovelSettingsVisible,
builder: (context, isNovelSettingsVisible, child) {
if (isNovelSettingsVisible) {
return MultiRepositoryProvider(
providers: [
RepositoryProvider<EditorRepository>(
create: (context) => editorController.editorRepository,
),
RepositoryProvider<StorageRepository>(
create: (context) => AliyunOssStorageRepository(editorController.apiClient),
),
],
child: NovelSettingsView(
novel: editorController.novel,
onSettingsClose: () {
context.read<EditorLayoutManager>().toggleNovelSettings();
},
),
);
}
// 🚀 关键修复使用Stack布局保持EditorMainArea不被销毁
return Stack(
children: [
// EditorMainArea始终存在只是可能被隐藏
Visibility(
visible: !editorController.isPlanViewActive &&
!editorController.isNextOutlineViewActive &&
!editorController.isPromptViewActive,
maintainState: true, // 保持状态,避免重建
child: EditorMainArea(
key: editorController.editorMainAreaKey,
novel: editorBlocState.novel,
editorBloc: editorController.editorBloc,
sceneControllers: editorController.sceneControllers,
sceneSummaryControllers: editorController.sceneSummaryControllers,
activeActId: editorBlocState.activeActId,
activeChapterId: editorBlocState.activeChapterId,
activeSceneId: editorBlocState.activeSceneId,
scrollController: editorController.scrollController,
sceneKeys: editorController.sceneKeys,
// 🚀 新增传递编辑器设置给EditorMainArea
editorSettings: EditorSettings.fromMap(editorBlocState.settings),
),
),
// Plan视图覆盖在上层
if (editorController.isPlanViewActive)
PlanView(
novelId: editorController.novel.id,
editorBloc: editorController.editorBloc,
onSwitchToWrite: editorController.togglePlanView,
),
// NextOutline视图覆盖在上层
if (editorController.isNextOutlineViewActive)
NextOutlineView(
novelId: editorController.novel.id,
novelTitle: editorController.novel.title,
onSwitchToWrite: editorController.toggleNextOutlineView,
),
// 统一管理视图覆盖在上层
if (editorController.isPromptViewActive)
const UnifiedManagementScreen(),
],
);
},
);
}
// 构建右侧AI面板区域 - 完整占据右边,从顶部到底部
Widget _buildRightAIPanelArea(BuildContext context, editor_bloc.EditorLoaded editorBlocState, EditorScreenController editorController) {
return Consumer<EditorLayoutManager>(
builder: (context, layoutManager, child) {
final hasVisibleAIPanels = layoutManager.visiblePanels.isNotEmpty;
if (!hasVisibleAIPanels) {
return const SizedBox.shrink();
}
return Row(
children: [
// 面板分隔线
DraggableDivider(
onDragUpdate: (delta) {
if (layoutManager.visiblePanels.isNotEmpty) {
final firstPanelId = layoutManager.visiblePanels.first;
layoutManager.updatePanelWidth(firstPanelId, delta.delta.dx);
}
},
onDragEnd: (_) {
layoutManager.savePanelWidths();
},
),
// AI面板组件 - 完整高度
RepositoryProvider<PromptRepository>(
create: (context) => editorController.promptRepository,
child: MultiAIPanelView(
novelId: editorController.novel.id,
chapterId: editorBlocState.activeChapterId,
layoutManager: layoutManager,
userId: editorController.currentUserId,
userAiModelConfigRepository: UserAIModelConfigRepositoryImpl(apiClient: editorController.apiClient),
editorRepository: editorController.editorRepository,
novelAIRepository: editorController.novelAIRepository,
onContinueWritingSubmit: (parameters) {
AppLogger.i('EditorLayout', 'Continue Writing Submitted: $parameters');
TopToast.success(context, '自动续写任务已提交: $parameters');
},
),
),
],
);
},
);
}
// 小屏时以覆盖层形式展示右侧AI面板
Widget _buildRightPanelOverlayIfNeeded(
BuildContext context,
editor_bloc.EditorLoaded editorBlocState,
EditorScreenController editorController, {
required bool isNarrow,
}) {
if (!isNarrow) return const SizedBox.shrink();
final screenWidth = MediaQuery.of(context).size.width;
return Consumer<EditorLayoutManager>(
builder: (context, layoutManager, child) {
final hasVisibleAIPanels = layoutManager.visiblePanels.isNotEmpty;
if (!hasVisibleAIPanels) return const SizedBox.shrink();
// 小屏覆盖式面板宽度不超过屏宽的35%,并在全局最小/最大约束之间
final double maxRightPanelWidth = (
screenWidth * 0.35
).clamp(
EditorLayoutManager.minPanelWidth,
EditorLayoutManager.maxPanelWidth,
);
return Positioned.fill(
child: Stack(
children: [
// 半透明遮罩点击关闭右侧所有AI面板
GestureDetector(
onTap: () => layoutManager.hideAllAIPanels(),
child: Container(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.4),
),
),
// 右侧贴边的覆盖面板
Align(
alignment: Alignment.centerRight,
child: Container(
width: maxRightPanelWidth,
height: double.infinity,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: WebTheme.getShadowColor(context, opacity: 0.2),
blurRadius: 12,
offset: const Offset(0, 2),
),
],
),
child: RepositoryProvider<PromptRepository>(
create: (context) => editorController.promptRepository,
child: MultiAIPanelView(
novelId: editorController.novel.id,
chapterId: editorBlocState.activeChapterId,
layoutManager: layoutManager,
userId: editorController.currentUserId,
userAiModelConfigRepository: UserAIModelConfigRepositoryImpl(apiClient: editorController.apiClient),
editorRepository: editorController.editorRepository,
novelAIRepository: editorController.novelAIRepository,
onContinueWritingSubmit: (parameters) {
AppLogger.i('EditorLayout', 'Continue Writing Submitted: $parameters');
TopToast.success(context, '自动续写任务已提交: $parameters');
},
),
),
),
),
],
),
);
},
);
}
// 构建覆盖层组件
List<Widget> _buildOverlayWidgets(BuildContext context, editor_bloc.EditorLoaded editorBlocState, EditorScreenController editorController, EditorStateManager stateManager) {
return [
// 移除:不再提供“完全隐藏侧边栏”的开关按钮,保留其他覆盖层
// 设置面板
Selector<EditorLayoutManager, bool>(
selector: (context, layoutManager) => layoutManager.isSettingsPanelVisible,
builder: (context, isVisible, child) {
if (!isVisible) return const SizedBox.shrink();
return Positioned.fill(
child: GestureDetector(
onTap: () => context.read<EditorLayoutManager>().toggleSettingsPanel(),
child: Container(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5),
child: Center(
child: GestureDetector(
onTap: () {},
child: editorController.currentUserId == null
? EditorDialogManager.buildLoginRequiredPanel(
context,
() => context.read<EditorLayoutManager>().toggleSettingsPanel(),
)
: SettingsPanel(
stateManager: stateManager,
userId: editorController.currentUserId!,
onClose: () => context.read<EditorLayoutManager>().toggleSettingsPanel(),
editorSettings: EditorSettings.fromMap(editorBlocState.settings),
onEditorSettingsChanged: (settings) {
context.read<editor_bloc.EditorBloc>().add(
editor_bloc.UpdateEditorSettings(settings: settings.toMap()));
},
initialCategoryIndex: SettingsPanel.accountManagementCategoryIndex,
),
),
),
),
),
);
},
),
// 保存中浮动按钮
if (editorBlocState.isSaving)
Positioned(
right: 16,
bottom: 16,
child: FloatingActionButton(
heroTag: 'saving',
onPressed: null,
backgroundColor: Theme.of(context).colorScheme.outlineVariant.withOpacity(0.6),
tooltip: '正在保存...',
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(WebTheme.isDarkMode(context) ? WebTheme.darkGrey50 : WebTheme.white),
),
),
),
),
// 加载动画覆盖层 (用于非全屏的 "加载更多")
if ((editorBlocState.isLoading || editorController.isLoadingMore) && !editorController.isFullscreenLoading)
_buildLoadingOverlay(context, editorController),
];
}
// 构建加载动画覆盖层
Widget _buildEndOfContentIndicator(BuildContext context, String message) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 24),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: WebTheme.getSurfaceColor(context),
borderRadius: BorderRadius.circular(30),
boxShadow: [
BoxShadow(
color: WebTheme.getShadowColor(context, opacity: 0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Text(
message,
style: TextStyle(
color: WebTheme.getSecondaryTextColor(context),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
);
}
Widget _buildLoadingOverlay(BuildContext context, EditorScreenController editorController) {
return Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
padding: const EdgeInsets.only(bottom: 32.0),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
WebTheme.getSurfaceColor(context).withAlpha(0),
WebTheme.getSurfaceColor(context).withAlpha(204),
WebTheme.getSurfaceColor(context),
],
),
),
child: Center(
child: SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (editorController.isLoadingMore) // Use passed controller
Container(
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 24),
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: WebTheme.getSurfaceColor(context),
borderRadius: BorderRadius.circular(30),
boxShadow: [
BoxShadow(
color: WebTheme.getShadowColor(context, opacity: 0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation<Color>(WebTheme.getPrimaryColor(context)),
),
),
const SizedBox(width: 16),
Text(
'正在加载更多内容...',
style: TextStyle(
color: WebTheme.getTextColor(context),
fontWeight: FontWeight.w500,
fontSize: 16,
),
),
],
),
),
if (!editorController.isLoadingMore) ...[ // Use passed controller
if (editorController.hasReachedEnd) // Use passed controller
_buildEndOfContentIndicator(context, '已到达底部'),
if (editorController.hasReachedStart) // Use passed controller
_buildEndOfContentIndicator(context, '已到达顶部'),
],
],
),
),
),
),
);
}
// 显示编辑器侧边栏宽度调整对话框
void _showEditorSidebarWidthDialog(BuildContext context) {
final layoutState = Provider.of<EditorLayoutManager>(context, listen: false);
EditorDialogManager.showEditorSidebarWidthDialog(
context,
layoutState.editorSidebarWidth,
EditorLayoutManager.minEditorSidebarWidth,
EditorLayoutManager.maxEditorSidebarWidth,
(value) {
layoutState.editorSidebarWidth = value;
},
layoutState.saveEditorSidebarWidth,
);
}
}
/// 左侧侧边栏的精简抽屉,仅展示底部功能的精简版与展开按钮
class _CompactSidebarDrawer extends StatelessWidget {
const _CompactSidebarDrawer({
required this.onExpand,
required this.onOpenSettings,
required this.onOpenAIChat,
});
final VoidCallback onExpand;
final VoidCallback onOpenSettings;
final VoidCallback onOpenAIChat;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Material(
color: WebTheme.getBackgroundColor(context),
child: Column(
children: [
// 顶部展开按钮
Padding(
padding: const EdgeInsets.all(8.0),
child: Tooltip(
message: '展开侧边栏',
child: InkWell(
onTap: onExpand,
borderRadius: BorderRadius.circular(8),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
width: 1,
),
),
child: Icon(Icons.menu_open, size: 18, color: colorScheme.onSurfaceVariant),
),
),
),
),
const Spacer(),
// 精简功能按钮区:仅保留与底部栏一致的核心功能
_CompactActionButton(
icon: Icons.settings,
tooltip: '小说设置',
onTap: onOpenSettings,
),
const SizedBox(height: 8),
_CompactActionButton(
icon: Icons.chat_bubble_outline,
tooltip: 'AI聊天',
onTap: onOpenAIChat,
),
const SizedBox(height: 8),
_CompactActionButton(
icon: Icons.lightbulb_outline,
tooltip: '提示词',
onTap: () {
context.read<editor_bloc.EditorBloc>();
// 使用 EditorAppBar 的提示词入口逻辑:通过 EditorController 切换提示词视图
final controller = Provider.of<EditorScreenController>(context, listen: false);
controller.togglePromptView();
},
),
const SizedBox(height: 8),
_CompactActionButton(
icon: Icons.save_outlined,
tooltip: '保存',
onTap: () {
try {
final controller = Provider.of<EditorScreenController>(context, listen: false);
controller.editorBloc.add(const editor_bloc.SaveContent());
} catch (_) {}
},
),
const SizedBox(height: 12),
],
),
);
}
}
class _CompactActionButton extends StatelessWidget {
const _CompactActionButton({
required this.icon,
required this.tooltip,
required this.onTap,
});
final IconData icon;
final String tooltip;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Tooltip(
message: tooltip,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
width: 1,
),
),
child: Icon(icon, size: 18, color: colorScheme.onSurfaceVariant),
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,664 @@
import 'package:ainoval/blocs/editor/editor_bloc.dart';
import 'package:ainoval/models/novel_summary.dart';
import 'package:ainoval/models/novel_snippet.dart';
import 'package:ainoval/screens/editor/widgets/novel_setting_sidebar.dart';
import 'package:ainoval/screens/editor/widgets/snippet_list_tab.dart';
import 'package:ainoval/screens/editor/widgets/snippet_edit_form.dart';
import 'package:ainoval/utils/logger.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
import 'package:ainoval/screens/editor/controllers/editor_screen_controller.dart';
import 'package:ainoval/blocs/setting/setting_bloc.dart';
import 'package:ainoval/utils/web_theme.dart';
import 'package:ainoval/widgets/common/user_avatar_menu.dart';
import 'package:ainoval/screens/subscription/subscription_screen.dart';
import 'chapter_directory_tab.dart';
/// 保持存活状态的包装器组件
class _KeepAliveWrapper extends StatefulWidget {
final Widget child;
const _KeepAliveWrapper({required this.child});
@override
State<_KeepAliveWrapper> createState() => _KeepAliveWrapperState();
}
class _KeepAliveWrapperState extends State<_KeepAliveWrapper>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return widget.child;
}
}
class EditorSidebar extends StatefulWidget {
const EditorSidebar({
super.key,
required this.novel,
required this.tabController,
this.onOpenAIChat,
this.onOpenSettings,
this.onToggleSidebar,
this.onAdjustWidth,
});
final NovelSummary novel;
final TabController tabController;
final VoidCallback? onOpenAIChat;
final VoidCallback? onOpenSettings;
final VoidCallback? onToggleSidebar;
final VoidCallback? onAdjustWidth;
@override
State<EditorSidebar> createState() => _EditorSidebarState();
}
class _EditorSidebarState extends State<EditorSidebar> {
final TextEditingController _searchController = TextEditingController();
// String _selectedMode = 'codex';
// 片段列表操作回调
VoidCallback? _refreshSnippetList; // used via callbacks wiring
Function(NovelSnippet)? _addSnippetToList; // used via callbacks wiring
Function(NovelSnippet)? _updateSnippetInList; // used via callbacks wiring
Function(String)? _removeSnippetFromList; // used via callbacks wiring
String _selectedBottomBarItem = '';
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// 🚀 添加重建监控日志 - 现在应该不会频繁触发了
AppLogger.d('EditorSidebar', '🔄 EditorSidebar.build() 被调用 - 监控重建');
final theme = Theme.of(context);
// 🚀 优化直接使用父级提供的SettingBloc实例避免重复创建
final settingSidebarWidget = BlocProvider.value(
value: context.read<SettingBloc>(),
child: NovelSettingSidebar(novelId: widget.novel.id),
);
return Material(
color: WebTheme.getBackgroundColor(context),
child: Container(
decoration: BoxDecoration(
color: WebTheme.getBackgroundColor(context),
border: Border(
right: BorderSide(
color: WebTheme.isDarkMode(context) ? WebTheme.darkGrey200 : WebTheme.grey200,
width: 1.0,
),
),
boxShadow: [
BoxShadow(
color: theme.colorScheme.shadow.withOpacity(0.03),
blurRadius: 5,
offset: const Offset(0, 0),
),
],
),
child: Column(
children: [
// 顶部应用栏
_buildAppBar(theme),
// 标签页导航
_buildTabBar(theme),
// 标签页内容
Expanded(
child: TabBarView(
controller: widget.tabController,
children: [
// 设定库标签页替换原来的Codex标签页
settingSidebarWidget,
// 片段标签页
Builder(
builder: (context) {
return SnippetListTab(
key: ValueKey('snippet_list_${widget.novel.id}'),
novel: widget.novel,
onRefreshCallbackChanged: (callback) {
_refreshSnippetList = callback;
},
onAddSnippetCallbackChanged: (callback) {
_addSnippetToList = callback;
},
onUpdateSnippetCallbackChanged: (callback) {
_updateSnippetInList = callback;
},
onRemoveSnippetCallbackChanged: (callback) {
_removeSnippetFromList = callback;
},
onSnippetTap: (snippet) {
FloatingSnippetEditor.show(
context: context,
snippet: snippet,
onSaved: (updatedSnippet) {
// 判断是创建还是更新
if (snippet.id.isEmpty) {
// 创建新片段:直接添加到列表
_addSnippetToList?.call(updatedSnippet);
} else {
// 更新现有片段:更新列表中的片段
_updateSnippetInList?.call(updatedSnippet);
}
},
onDeleted: (snippetId) {
// 删除片段:从列表中移除
_removeSnippetFromList?.call(snippetId);
},
);
},
);
},
),
// 章节目录标签页
Builder(
builder: (context) {
// 确保在有Provider访问权限的新BuildContext中构建ChapterDirectoryTab
return Consumer<EditorScreenController>(
builder: (context, controller, child) {
return ChapterDirectoryTab(novel: widget.novel);
},
);
},
),
// 添加AI生成选项
_buildPlaceholderTab(
icon: Icons.auto_awesome,
text: 'AI生成功能开发中'),
],
),
),
// 底部导航栏
_buildBottomBar(theme),
],
),
),
);
}
PreferredSizeWidget _buildAppBar(ThemeData theme) {
return AppBar(
elevation: 0,
scrolledUnderElevation: 0,
backgroundColor: WebTheme.getBackgroundColor(context),
automaticallyImplyLeading: false,
titleSpacing: 0,
toolbarHeight: 60, // 增加高度以适应新设计
title: Container(
height: 60,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
// 返回按钮
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: WebTheme.getSurfaceColor(context),
borderRadius: BorderRadius.circular(8),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () {
Navigator.pop(context);
},
child: Icon(
Icons.arrow_back,
size: 18,
color: WebTheme.getTextColor(context),
),
),
),
),
const SizedBox(width: 12),
// 可点击的设置和小说信息区域
Expanded(
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: widget.onOpenSettings,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
children: [
// 设置图标
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: WebTheme.getSurfaceColor(context),
borderRadius: BorderRadius.circular(6),
),
child: Icon(
Icons.settings,
size: 16,
color: WebTheme.getTextColor(context),
),
),
const SizedBox(width: 12),
// 小说标题和作者信息
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.novel.title,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 15,
color: WebTheme.getTextColor(context),
height: 1.1,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
Text(
widget.novel.author ?? 'Erminia Osteen',
style: TextStyle(
color: WebTheme.getSecondaryTextColor(context),
fontSize: 11,
fontWeight: FontWeight.w400,
height: 1.0,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
],
),
),
],
),
),
),
),
),
const SizedBox(width: 8),
// 右侧操作按钮
Row(
mainAxisSize: MainAxisSize.min,
children: [
// 侧边栏折叠按钮
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: WebTheme.getSurfaceColor(context),
borderRadius: BorderRadius.circular(8),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: widget.onToggleSidebar,
child: Icon(
Icons.menu_open,
size: 18,
color: WebTheme.getTextColor(context),
),
),
),
),
const SizedBox(width: 8),
// 调整宽度按钮
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: WebTheme.getSurfaceColor(context),
borderRadius: BorderRadius.circular(8),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: widget.onAdjustWidth,
child: Icon(
Icons.more_horiz,
size: 18,
color: WebTheme.getTextColor(context),
),
),
),
),
],
),
],
),
),
);
}
Widget _buildTabBar(ThemeData theme) {
return Container(
decoration: BoxDecoration(
color: WebTheme.getBackgroundColor(context),
border: Border(
bottom: BorderSide(
color: WebTheme.isDarkMode(context) ? WebTheme.darkGrey200 : WebTheme.grey200,
width: 1.0,
),
),
),
child: TabBar(
controller: widget.tabController,
labelColor: WebTheme.getTextColor(context),
unselectedLabelColor: WebTheme.getSecondaryTextColor(context),
indicatorColor: WebTheme.getTextColor(context),
indicatorWeight: 2.0, // 减小指示器粗细
indicatorSize: TabBarIndicatorSize.label,
labelStyle: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13, // 减小字体大小
),
unselectedLabelStyle: const TextStyle(
fontWeight: FontWeight.normal,
fontSize: 13, // 减小字体大小
),
dividerColor: Colors.transparent,
isScrollable: false, // 确保不可滚动,平均分配空间
labelPadding: const EdgeInsets.symmetric(horizontal: 2.0), // 减小标签内边距
padding: const EdgeInsets.symmetric(horizontal: 2.0), // 减小整体内边距
tabs: const [
Tab(
icon: Icon(Icons.inventory_2_outlined, size: 18), // 修改图标来反映设定功能
text: '设定库', // 改为"设定库"
height: 60, // 与顶部 AppBar 高度一致
),
Tab(
icon: Icon(Icons.bookmark_border_outlined, size: 18), // 减小图标大小
text: '片段',
height: 60, // 与顶部 AppBar 高度一致
),
Tab(
icon: Icon(Icons.menu_outlined, size: 18), // 目录图标
text: '章节目录', // "章节目录"
height: 60, // 与顶部 AppBar 高度一致
),
Tab(
icon: Icon(Icons.auto_awesome, size: 18), // AI生成图标
text: 'AI生成',
height: 60, // 与顶部 AppBar 高度一致
),
],
),
);
}
Widget _buildPlaceholderTab({required IconData icon, required String text}) {
return _KeepAliveWrapper(
child: Container(
color: WebTheme.getSurfaceColor(context),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 48, color: WebTheme.getSecondaryTextColor(context)),
const SizedBox(height: 16),
Text(
text,
style: TextStyle(fontSize: 16, color: WebTheme.getSecondaryTextColor(context)),
textAlign: TextAlign.center,
),
],
),
),
),
);
}
Widget _buildBottomBar(ThemeData theme) {
return LayoutBuilder(
builder: (context, constraints) {
// 当侧边栏宽度较小时,仅显示图标;宽度充足时显示图标+文字
final bool isCompact = constraints.maxWidth < 240;
return Container(
height: 60,
decoration: BoxDecoration(
color: WebTheme.getBackgroundColor(context),
border: Border(
top: BorderSide(
color: WebTheme.isDarkMode(context) ? WebTheme.darkGrey200 : WebTheme.grey200,
width: 1.0,
),
),
),
child: Row(
children: [
// 用户头像菜单
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: UserAvatarMenu(
size: 16,
showName: false,
onMySubscription: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SubscriptionScreen()),
);
},
onOpenSettings: widget.onOpenSettings,
onProfile: widget.onOpenSettings, // 个人资料也使用设置面板
onAccountSettings: widget.onOpenSettings, // 账户设置使用设置面板
),
),
// 使用Expanded包裹SingleChildScrollView来确保按钮能够根据宽度滚动/自适应
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// 帮助按钮
_buildBottomBarItem(
icon: Icons.help_outline,
label: 'Help',
showLabel: !isCompact,
selected: _selectedBottomBarItem == 'Help',
onTap: () {
setState(() {
_selectedBottomBarItem = 'Help';
});
// TODO: 实现帮助功能
},
),
// 提示按钮
_buildBottomBarItem(
icon: Icons.lightbulb_outline,
label: 'Prompts',
showLabel: !isCompact,
selected: _selectedBottomBarItem == 'Prompts',
onTap: () {
setState(() {
_selectedBottomBarItem = 'Prompts';
});
final controller = Provider.of<EditorScreenController>(context, listen: false);
controller.togglePromptView();
},
),
// 导出按钮
_buildBottomBarItem(
icon: Icons.download_outlined,
label: 'Export',
showLabel: !isCompact,
selected: _selectedBottomBarItem == 'Export',
onTap: () {
setState(() {
_selectedBottomBarItem = 'Export';
});
// TODO: 实现导出功能
},
),
// 保存按钮
_buildBottomBarItem(
icon: Icons.save_outlined,
label: 'Save',
showLabel: !isCompact,
selected: _selectedBottomBarItem == 'Save',
onTap: () {
setState(() {
_selectedBottomBarItem = 'Save';
});
// 手动保存触发与自动保存一致的SaveContent事件
try {
final controller = Provider.of<EditorScreenController>(context, listen: false);
controller.editorBloc.add(const SaveContent());
} catch (e) {
AppLogger.w('EditorSidebar', '手动保存触发失败', e);
}
},
),
],
),
),
),
],
),
);
},
);
}
/// 构建底部栏单个按钮
Widget _buildBottomBarItem({
required IconData icon,
required String label,
bool showLabel = true,
bool selected = false,
required VoidCallback onTap,
}) {
final isDark = WebTheme.isDarkMode(context);
// 修复选中状态的颜色配置,确保在暗黑模式下文字可见
final Color foregroundColor;
final Color backgroundColor;
if (selected) {
if (isDark) {
// 暗黑模式下:选中时使用深灰背景+白字
backgroundColor = WebTheme.darkGrey700;
foregroundColor = WebTheme.white;
} else {
// 亮色模式下:选中时使用深色背景+白字
backgroundColor = WebTheme.grey800;
foregroundColor = WebTheme.white;
}
} else {
// 未选中时:透明背景+半透明文字
backgroundColor = Colors.transparent;
foregroundColor = WebTheme.getTextColor(context).withOpacity(0.7);
}
return Material(
color: backgroundColor,
borderRadius: BorderRadius.circular(6),
child: InkWell(
borderRadius: BorderRadius.circular(6),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 10.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 16,
color: foregroundColor,
),
if (showLabel) ...[
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
fontSize: 12,
color: foregroundColor,
fontWeight: FontWeight.w500,
),
),
],
],
),
),
),
);
}
}
class _CodexEmptyState extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, // 左对齐
children: [
Text(
'YOUR CODEX IS EMPTY',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
color: WebTheme.getTextColor(context),
),
),
const SizedBox(height: 8),
Text(
'The Codex stores information about the world your story takes place in, its inhabitants and more.',
style: TextStyle(
color: WebTheme.getSecondaryTextColor(context),
fontSize: 14,
height: 1.5,
),
),
const SizedBox(height: 12),
InkWell(
onTap: () {
// 该点击应执行与"+ New Entry"按钮相同的操作
},
child: Text(
'→ Create a new entry by clicking the button above.',
style: TextStyle(
color: WebTheme.getTextColor(context),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,113 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:ainoval/utils/web_theme.dart';
/// 全屏加载动画覆盖层
/// 在应用初始化、卷轴切换等耗时操作时显示
class FullscreenLoadingOverlay extends StatelessWidget {
final String loadingMessage;
final bool showProgressIndicator;
final double progress; // 0.0 - 1.0 的进度值,如果提供将显示进度条而非无限循环指示器
final Color? backgroundColor;
final Color textColor;
final bool useBlur; // 是否使用背景模糊效果
final bool isVisible;
const FullscreenLoadingOverlay({
Key? key,
this.loadingMessage = '正在加载,请稍候...',
this.showProgressIndicator = true,
this.progress = -1, // 默认为-1表示不确定进度
this.backgroundColor,
this.textColor = Colors.black87,
this.useBlur = false,
this.isVisible = true,
}) : super(key: key);
@override
Widget build(BuildContext context) {
if (!isVisible) return const SizedBox.shrink();
final screenSize = MediaQuery.of(context).size;
final theme = Theme.of(context);
return Positioned.fill(
child: Material(
color: Colors.transparent,
child: Container(
// 使用动态背景色
color: backgroundColor ?? WebTheme.getBackgroundColor(context),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (showProgressIndicator)
SizedBox(
width: 60,
height: 60,
child: CircularProgressIndicator(
strokeWidth: 4,
valueColor: AlwaysStoppedAnimation<Color>(
theme.primaryColor,
),
),
),
if (showProgressIndicator && (loadingMessage != null || progress > 0))
const SizedBox(height: 30),
if (loadingMessage != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 40),
child: Text(
loadingMessage,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
textAlign: TextAlign.center,
),
),
if (progress > 0)
Padding(
padding: const EdgeInsets.only(top: 10),
child: _buildProgressIndicator(theme),
),
],
),
),
),
),
);
}
// 构建进度指示器
Widget _buildProgressIndicator(ThemeData theme) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${(progress * 100).toStringAsFixed(0)}%',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.black54,
),
),
const SizedBox(height: 10),
SizedBox(
width: 200,
child: LinearProgressIndicator(
value: progress,
backgroundColor: Colors.grey[200],
valueColor: AlwaysStoppedAnimation<Color>(
theme.primaryColor,
),
minHeight: 6,
borderRadius: BorderRadius.circular(3),
),
),
],
);
}
}

View File

@@ -0,0 +1,379 @@
import 'package:flutter/material.dart';
import 'package:ainoval/utils/web_theme.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:ainoval/blocs/editor/editor_bloc.dart' as editor_bloc;
import 'package:ainoval/screens/editor/controllers/editor_screen_controller.dart';
import 'package:ainoval/utils/logger.dart';
import 'package:provider/provider.dart';
/// 🚀 沉浸模式导航组件
/// 包含模式切换按钮和章节导航按钮
class ImmersiveModeNavigation extends StatelessWidget {
const ImmersiveModeNavigation({
super.key,
required this.editorBloc,
});
final editor_bloc.EditorBloc editorBloc;
@override
Widget build(BuildContext context) {
return BlocBuilder<editor_bloc.EditorBloc, editor_bloc.EditorState>(
bloc: editorBloc,
builder: (context, state) {
if (state is! editor_bloc.EditorLoaded) {
return const SizedBox.shrink();
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
// 沉浸模式切换按钮
_buildModeToggleButton(context, state),
// 保留章节导航按钮(普通/沉浸模式均可用)
const SizedBox(width: 8),
_buildChapterNavigationButtons(context, state),
],
);
},
);
}
/// 构建模式切换按钮
Widget _buildModeToggleButton(BuildContext context, editor_bloc.EditorLoaded state) {
final theme = Theme.of(context);
final isImmersive = state.isImmersiveMode;
final editorController = Provider.of<EditorScreenController>(context, listen: false);
final label = isImmersive ? '沉浸模式' : '普通模式';
return Tooltip(
message: isImmersive ? '切换到普通模式' : '切换到沉浸模式',
child: TextButton.icon(
icon: Icon(
isImmersive ? Icons.center_focus_strong : Icons.view_stream,
size: 20,
color: isImmersive
? WebTheme.getPrimaryColor(context)
: theme.colorScheme.onSurfaceVariant,
),
label: Text(
label,
style: TextStyle(
color: isImmersive
? WebTheme.getPrimaryColor(context)
: theme.colorScheme.onSurfaceVariant,
fontSize: 14,
),
),
style: TextButton.styleFrom(
backgroundColor: isImmersive
? WebTheme.getPrimaryColor(context).withAlpha(76)
: Colors.transparent,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
),
onPressed: () {
AppLogger.i('ImmersiveModeNavigation', '用户点击模式切换按钮');
editorController.toggleImmersiveMode();
},
),
);
}
/// 构建章节导航按钮组
Widget _buildChapterNavigationButtons(BuildContext context, editor_bloc.EditorLoaded state) {
final editorController = Provider.of<EditorScreenController>(context, listen: false);
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 上一章按钮
_buildNavigationButton(
context: context,
icon: Icons.navigate_before,
tooltip: '上一章',
onPressed: editorController.canNavigateToPreviousChapter
? () {
AppLogger.i('ImmersiveModeNavigation', '导航到上一章');
editorController.navigateToPreviousChapter();
}
: null,
),
// 分隔线
Container(
height: 24,
width: 1,
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
),
// 章节信息
_buildChapterInfo(context, state),
// 分隔线
Container(
height: 24,
width: 1,
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
),
// 下一章按钮
_buildNavigationButton(
context: context,
icon: Icons.navigate_next,
tooltip: '下一章',
onPressed: editorController.canNavigateToNextChapter
? () {
AppLogger.i('ImmersiveModeNavigation', '导航到下一章');
editorController.navigateToNextChapter();
}
: null,
),
],
),
);
}
/// 构建导航按钮
Widget _buildNavigationButton({
required BuildContext context,
required IconData icon,
required String tooltip,
required VoidCallback? onPressed,
}) {
return Tooltip(
message: tooltip,
child: IconButton(
onPressed: onPressed,
icon: Icon(
icon,
size: 20,
),
style: IconButton.styleFrom(
minimumSize: const Size(32, 32),
padding: const EdgeInsets.all(4),
foregroundColor: onPressed != null
? Theme.of(context).colorScheme.onSurface
: Theme.of(context).colorScheme.onSurface.withOpacity(0.3),
),
),
);
}
/// 构建章节信息显示
Widget _buildChapterInfo(BuildContext context, editor_bloc.EditorLoaded state) {
final String? currentChapterId = state.immersiveChapterId ?? state.activeChapterId;
if (currentChapterId == null) {
return const Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: Text('未知章节'),
);
}
// 查找当前章节信息
String chapterTitle = '未知章节';
String chapterInfo = '';
for (int actIndex = 0; actIndex < state.novel.acts.length; actIndex++) {
final act = state.novel.acts[actIndex];
for (int chapterIndex = 0; chapterIndex < act.chapters.length; chapterIndex++) {
final chapter = act.chapters[chapterIndex];
if (chapter.id == currentChapterId) {
chapterTitle = chapter.title.isNotEmpty ? chapter.title : '${chapterIndex + 1}';
chapterInfo = '${actIndex + 1}卷 第${chapterIndex + 1}';
break;
}
}
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
chapterTitle,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
chapterInfo,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
),
),
],
),
);
}
}
/// 🚀 沉浸模式边界提示组件
class ImmersiveModeBoundaryIndicator extends StatelessWidget {
const ImmersiveModeBoundaryIndicator({
super.key,
required this.isFirstChapter,
required this.isLastChapter,
this.onNavigatePrevious,
this.onNavigateNext,
});
final bool isFirstChapter;
final bool isLastChapter;
final VoidCallback? onNavigatePrevious;
final VoidCallback? onNavigateNext;
@override
Widget build(BuildContext context) {
if (!isFirstChapter && !isLastChapter) {
return const SizedBox.shrink();
}
return Container(
margin: const EdgeInsets.symmetric(vertical: 20),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
),
),
child: Row(
children: [
Icon(
isFirstChapter ? Icons.first_page : Icons.last_page,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Text(
isFirstChapter ? '这是第一章' : '这是最后一章',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
if ((isFirstChapter && onNavigateNext != null) ||
(isLastChapter && onNavigatePrevious != null))
TextButton.icon(
onPressed: isFirstChapter ? onNavigateNext : onNavigatePrevious,
icon: Icon(
isFirstChapter ? Icons.arrow_forward : Icons.arrow_back,
size: 16,
),
label: Text(isFirstChapter ? '下一章' : '上一章'),
style: TextButton.styleFrom(
foregroundColor: WebTheme.getPrimaryColor(context),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
),
),
],
),
);
}
}
/// 🚀 沉浸模式工具栏
class ImmersiveModeToolbar extends StatelessWidget {
const ImmersiveModeToolbar({
super.key,
required this.editorBloc,
});
final editor_bloc.EditorBloc editorBloc;
@override
Widget build(BuildContext context) {
return BlocBuilder<editor_bloc.EditorBloc, editor_bloc.EditorState>(
bloc: editorBloc,
builder: (context, state) {
if (state is! editor_bloc.EditorLoaded || !state.isImmersiveMode) {
return const SizedBox.shrink();
}
final editorController = Provider.of<EditorScreenController>(context, listen: false);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(12)),
boxShadow: [
BoxShadow(
color: Theme.of(context).shadowColor.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
// 沉浸模式指示器
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: WebTheme.getPrimaryColor(context).withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.center_focus_strong,
size: 16,
color: WebTheme.getPrimaryColor(context),
),
const SizedBox(width: 4),
Text(
'沉浸模式',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: WebTheme.getPrimaryColor(context),
fontWeight: FontWeight.w600,
),
),
],
),
),
const Spacer(),
// 快捷操作按钮
Row(
mainAxisSize: MainAxisSize.min,
children: [
// 返回普通模式按钮
TextButton.icon(
onPressed: () {
editorController.switchToNormalMode();
},
icon: const Icon(Icons.view_stream, size: 16),
label: const Text('普通模式'),
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.onSurface,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
),
),
],
),
],
),
);
},
);
}
}

View File

@@ -0,0 +1,236 @@
/// 索引化的Map数据结构同时支持键查找和索引访问
/// 提供O(1)的键查找、索引访问、相邻元素获取
class IndexedMap<K, V> {
final Map<K, _IndexedNode<K, V>> _keyToNode = <K, _IndexedNode<K, V>>{};
final List<_IndexedNode<K, V>> _orderedNodes = <_IndexedNode<K, V>>[];
/// 获取元素数量
int get length => _orderedNodes.length;
/// 是否为空
bool get isEmpty => _orderedNodes.isEmpty;
/// 是否非空
bool get isNotEmpty => _orderedNodes.isNotEmpty;
/// 获取所有键
Iterable<K> get keys => _keyToNode.keys;
/// 获取所有值
Iterable<V> get values => _orderedNodes.map((node) => node.value);
/// 添加或更新元素到末尾
void add(K key, V value) {
if (_keyToNode.containsKey(key)) {
// 更新现有元素
_keyToNode[key]!.value = value;
return;
}
final node = _IndexedNode<K, V>(
key: key,
value: value,
index: _orderedNodes.length,
);
_keyToNode[key] = node;
_orderedNodes.add(node);
}
/// 在指定位置插入元素
void insertAt(int index, K key, V value) {
if (_keyToNode.containsKey(key)) {
throw ArgumentError('Key $key already exists');
}
if (index < 0 || index > _orderedNodes.length) {
throw RangeError('Index $index out of range');
}
final node = _IndexedNode<K, V>(
key: key,
value: value,
index: index,
);
_keyToNode[key] = node;
_orderedNodes.insert(index, node);
// 更新后续节点的索引
_updateIndicesFrom(index);
}
/// 根据键删除元素
bool removeKey(K key) {
final node = _keyToNode[key];
if (node == null) return false;
final index = node.index;
_keyToNode.remove(key);
_orderedNodes.removeAt(index);
// 更新后续节点的索引
_updateIndicesFrom(index);
return true;
}
/// 根据索引删除元素
V? removeAt(int index) {
if (index < 0 || index >= _orderedNodes.length) {
return null;
}
final node = _orderedNodes.removeAt(index);
_keyToNode.remove(node.key);
// 更新后续节点的索引
_updateIndicesFrom(index);
return node.value;
}
/// 根据键获取值 - O(1)
V? operator [](K key) {
return _keyToNode[key]?.value;
}
/// 根据索引获取值 - O(1)
V? getAt(int index) {
if (index < 0 || index >= _orderedNodes.length) {
return null;
}
return _orderedNodes[index].value;
}
/// 根据索引获取键 - O(1)
K? getKeyAt(int index) {
if (index < 0 || index >= _orderedNodes.length) {
return null;
}
return _orderedNodes[index].key;
}
/// 根据键获取索引 - O(1)
int? getIndex(K key) {
return _keyToNode[key]?.index;
}
/// 检查是否包含键 - O(1)
bool containsKey(K key) {
return _keyToNode.containsKey(key);
}
/// 获取前k个元素 - O(k)但通常k很小所以近似O(1)
List<V> getPrevious(K key, int count) {
final node = _keyToNode[key];
if (node == null) return [];
final startIndex = (node.index - count).clamp(0, _orderedNodes.length);
final endIndex = node.index;
return _orderedNodes
.getRange(startIndex, endIndex)
.map((n) => n.value)
.toList();
}
/// 获取后k个元素 - O(k)但通常k很小所以近似O(1)
List<V> getNext(K key, int count) {
final node = _keyToNode[key];
if (node == null) return [];
final startIndex = node.index + 1;
final endIndex = (startIndex + count).clamp(0, _orderedNodes.length);
return _orderedNodes
.getRange(startIndex, endIndex)
.map((n) => n.value)
.toList();
}
/// 获取前后k个元素 - O(k)
List<V> getSurrounding(K key, int count) {
final node = _keyToNode[key];
if (node == null) return [];
final startIndex = (node.index - count).clamp(0, _orderedNodes.length);
final endIndex = (node.index + count + 1).clamp(0, _orderedNodes.length);
return _orderedNodes
.getRange(startIndex, endIndex)
.map((n) => n.value)
.toList();
}
/// 获取指定范围的元素 - O(range)
List<V> getRange(int start, int end) {
if (start < 0) start = 0;
if (end > _orderedNodes.length) end = _orderedNodes.length;
if (start >= end) return [];
return _orderedNodes
.getRange(start, end)
.map((n) => n.value)
.toList();
}
/// 清空所有元素
void clear() {
_keyToNode.clear();
_orderedNodes.clear();
}
/// 更新指定位置之后的所有节点索引
void _updateIndicesFrom(int startIndex) {
for (int i = startIndex; i < _orderedNodes.length; i++) {
_orderedNodes[i].index = i;
}
}
/// 转换为List
List<V> toList() {
return _orderedNodes.map((node) => node.value).toList();
}
/// 转换为Map
Map<K, V> toMap() {
return Map.fromEntries(
_orderedNodes.map((node) => MapEntry(node.key, node.value)),
);
}
/// 遍历所有元素
void forEach(void Function(K key, V value, int index) action) {
for (int i = 0; i < _orderedNodes.length; i++) {
final node = _orderedNodes[i];
action(node.key, node.value, i);
}
}
/// 查找符合条件的元素索引
int indexWhere(bool Function(V value) test) {
for (int i = 0; i < _orderedNodes.length; i++) {
if (test(_orderedNodes[i].value)) {
return i;
}
}
return -1;
}
}
/// 内部节点类
class _IndexedNode<K, V> {
final K key;
V value;
int index;
_IndexedNode({
required this.key,
required this.value,
required this.index,
});
@override
String toString() => 'Node($key: $value @ $index)';
}

View File

@@ -0,0 +1,89 @@
/**
* 加载覆盖层组件
*
* 在内容加载过程中显示的半透明渐变覆盖层,提供直观的加载状态反馈。
* 显示在屏幕底部,包含加载指示器和自定义加载消息。
*/
import 'package:flutter/material.dart';
/// 加载覆盖层组件
///
/// 用于在编辑器中显示内容加载状态。
/// 设计为一个半透明的覆盖层,显示在主界面底部,
/// 具有渐变背景和居中的指示器加消息。
class LoadingOverlay extends StatelessWidget {
/// 要显示的加载消息文本
final String loadingMessage;
/// 创建一个加载覆盖层
///
/// [loadingMessage] 要显示的加载消息
const LoadingOverlay({
Key? key,
required this.loadingMessage,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
height: 80,
// 渐变背景从透明到白色
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.white.withOpacity(0.0),
Colors.white.withOpacity(0.8),
Colors.white,
],
),
),
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24),
// 消息容器的样式,圆角白色卡片带阴影
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(30),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 加载指示器
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation<Color>(Colors.blue.shade700),
),
),
const SizedBox(width: 12),
// 加载消息文本
Text(
loadingMessage,
style: TextStyle(
color: Colors.grey.shade800,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,492 @@
import 'package:ainoval/screens/chat/widgets/ai_chat_sidebar.dart';
import 'package:ainoval/screens/editor/components/draggable_divider.dart';
import 'package:ainoval/screens/editor/managers/editor_layout_manager.dart';
import 'package:ainoval/screens/editor/widgets/ai_generation_panel.dart';
import 'package:ainoval/screens/editor/widgets/ai_setting_generation_panel.dart';
import 'package:ainoval/screens/editor/widgets/ai_summary_panel.dart';
import 'package:ainoval/screens/editor/widgets/continue_writing_form.dart';
import 'package:ainoval/services/api_service/repositories/user_ai_model_config_repository.dart';
import 'package:ainoval/services/api_service/repositories/editor_repository.dart';
import 'package:ainoval/services/api_service/repositories/novel_ai_repository.dart';
import 'package:flutter/material.dart';
import 'package:ainoval/utils/web_theme.dart';
/// 多AI面板视图组件
/// 支持以卡片形式并排显示多个AI辅助面板可拖拽调整大小和顺序
class MultiAIPanelView extends StatefulWidget {
const MultiAIPanelView({
Key? key,
required this.novelId,
required this.chapterId,
required this.layoutManager,
required this.userId,
required this.userAiModelConfigRepository,
required this.onContinueWritingSubmit,
required this.editorRepository,
required this.novelAIRepository,
}) : super(key: key);
final String novelId;
final String? chapterId;
final EditorLayoutManager layoutManager;
final String? userId;
final UserAIModelConfigRepository userAiModelConfigRepository;
final Function(Map<String, dynamic> parameters) onContinueWritingSubmit;
final EditorRepository editorRepository;
final NovelAIRepository novelAIRepository;
@override
State<MultiAIPanelView> createState() => _MultiAIPanelViewState();
}
class _MultiAIPanelViewState extends State<MultiAIPanelView> {
// 拖拽重排序相关状态
String? _draggedPanelId;
double _draggedPanelOffset = 0.0;
bool _isDragging = false;
@override
Widget build(BuildContext context) {
final visiblePanels = widget.layoutManager.visiblePanels;
final screenWidth = MediaQuery.of(context).size.width;
final bool isNarrow = screenWidth < 1280;
final bool isVeryNarrow = screenWidth < 900;
// 小屏策略:仅允许显示一个面板,其余通过顺序切换(保留顺序,限制数量)
final List<String> effectivePanels = isNarrow && visiblePanels.isNotEmpty
? [visiblePanels.last] // 取最近一个打开的
: visiblePanels;
if (effectivePanels.isEmpty) {
return _buildToggleAllPanelsButton();
}
return Row(
children: [
// 添加面板之间的拖拽分隔线和面板内容
for (int i = 0; i < effectivePanels.length; i++) ...[
if (i > 0 && !isNarrow) _buildDraggableDivider(effectivePanels[i]),
_buildPanelContent(effectivePanels[i], i, isNarrow: isNarrow, isVeryNarrow: isVeryNarrow),
],
// 全局隐藏/显示控制按钮
_buildToggleAllPanelsButton(),
],
);
}
/// 构建全局隐藏/显示所有面板的控制按钮
Widget _buildToggleAllPanelsButton() {
final colorScheme = Theme.of(context).colorScheme;
final hasVisiblePanels = widget.layoutManager.visiblePanels.isNotEmpty;
return SizedBox(
width: 32,
child: Container(
margin: const EdgeInsets.only(left: 8, right: 4),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
if (hasVisiblePanels) {
_hideAllPanels();
} else {
_showAllPanels();
}
},
borderRadius: BorderRadius.circular(6),
child: Tooltip(
message: hasVisiblePanels ? '隐藏所有面板' : '显示面板',
child: Container(
height: 40,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: colorScheme.outline.withValues(alpha: 0.3),
width: 1,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
hasVisiblePanels
? Icons.keyboard_arrow_right
: Icons.keyboard_arrow_left,
size: 18,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 2),
Container(
width: 12,
height: 2,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(1),
),
),
],
),
),
),
),
),
),
);
}
/// 隐藏所有面板
void _hideAllPanels() {
widget.layoutManager.hideAllAIPanels();
}
/// 恢复所有面板(显示之前保存的面板配置)
void _showAllPanels() {
widget.layoutManager.restoreHiddenAIPanels();
}
Widget _buildDraggableDivider(String panelId) {
return DraggableDivider(
onDragUpdate: (details) {
final delta = details.delta.dx;
widget.layoutManager.updatePanelWidth(panelId, delta);
},
onDragEnd: (_) {
widget.layoutManager.savePanelWidths();
},
);
}
Widget _buildPanelContent(String panelId, int index, {required bool isNarrow, required bool isVeryNarrow}) {
final screenWidth = MediaQuery.of(context).size.width;
// 在小屏上将面板宽度限定为屏宽的35%,其余按原逻辑
final double maxResponsiveWidth = (screenWidth * 0.35).clamp(
EditorLayoutManager.minPanelWidth,
EditorLayoutManager.maxPanelWidth,
);
double width = widget.layoutManager.panelWidths[panelId] ?? EditorLayoutManager.minPanelWidth;
if (isNarrow) {
width = width.clamp(EditorLayoutManager.minPanelWidth, maxResponsiveWidth);
}
// 计算拖拽时的偏移量
double xOffset = 0.0;
if (_isDragging && _draggedPanelId == panelId) {
xOffset = _draggedPanelOffset.clamp(-50.0, 50.0); // 限制偏移量,避免布局问题
}
// 使用Material和Card为面板添加卡片风格
return SizedBox(
width: width,
child: Transform.translate(
offset: Offset(xOffset, 0),
child: Card(
elevation: _isDragging && _draggedPanelId == panelId ? 8 : 1,
margin: EdgeInsets.zero, // 紧贴边缘
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.zero, // 取消圆角
side: BorderSide(
color: _isDragging && _draggedPanelId == panelId
? WebTheme.getPrimaryColor(context).withValues(alpha: 0.5)
: Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.5),
width: _isDragging && _draggedPanelId == panelId ? 2 : 0.5,
),
),
clipBehavior: Clip.antiAlias,
child: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 可拖动的顶部把手(小屏禁用重排序,改为显示标题行)
_buildDragHandle(panelId, index, isNarrow: isNarrow),
// 面板内容
Expanded(
child: _buildPanel(panelId),
),
],
),
),
),
);
}
Widget _buildDragHandle(String panelId, int index, {required bool isNarrow}) {
final colorScheme = Theme.of(context).colorScheme;
// 面板类型标题映射
final panelTitles = {
EditorLayoutManager.aiChatPanel: 'AI聊天',
EditorLayoutManager.aiSummaryPanel: 'AI摘要',
EditorLayoutManager.aiScenePanel: 'AI场景生成',
EditorLayoutManager.aiContinueWritingPanel: '自动续写',
EditorLayoutManager.aiSettingGenerationPanel: 'AI生成设定',
};
final panelTitle = panelTitles[panelId] ?? '未知面板 ($panelId)';
return GestureDetector(
// 实现拖拽重排序(小屏禁用)
onPanStart: (!isNarrow && widget.layoutManager.visiblePanels.length > 1) ? (details) {
if (mounted) {
setState(() {
_draggedPanelId = panelId;
_isDragging = true;
_draggedPanelOffset = 0.0;
});
}
} : null,
onPanUpdate: (!isNarrow && widget.layoutManager.visiblePanels.length > 1) ? (details) {
if (_isDragging && _draggedPanelId == panelId && mounted) {
setState(() {
_draggedPanelOffset += details.delta.dx;
});
// 计算当前应该插入的位置
_updatePanelOrder(details.globalPosition.dx);
}
} : null,
onPanEnd: (!isNarrow && widget.layoutManager.visiblePanels.length > 1) ? (details) {
if (_isDragging && _draggedPanelId == panelId && mounted) {
setState(() {
_isDragging = false;
_draggedPanelId = null;
_draggedPanelOffset = 0.0;
});
}
} : null,
child: Container(
height: 24,
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 8),
margin: EdgeInsets.zero,
decoration: BoxDecoration(
color: _isDragging && _draggedPanelId == panelId
? WebTheme.getPrimaryColor(context).withOpacity(0.15)
: colorScheme.secondaryContainer.withValues(alpha: 0.7),
border: Border(
bottom: BorderSide(
color: colorScheme.outlineVariant,
width: 0.5,
),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Panel icon and title
Flexible(
child: Row(
children: [
Icon(
_getPanelIcon(panelId),
size: 14,
color: _isDragging && _draggedPanelId == panelId
? colorScheme.onPrimaryContainer
: colorScheme.onSecondaryContainer,
),
const SizedBox(width: 6),
Expanded(
child: Text(
panelTitle,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: _isDragging && _draggedPanelId == panelId
? colorScheme.onPrimaryContainer
: colorScheme.onSecondaryContainer,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
// Drag and close buttons
Row(
children: [
// Drag handle icon
if (!isNarrow && widget.layoutManager.visiblePanels.length > 1)
Tooltip(
message: '拖动调整顺序',
child: Icon(
Icons.drag_handle,
size: 14,
color: _isDragging && _draggedPanelId == panelId
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
),
// Close button
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () {
_closePanel(panelId);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: Icon(
Icons.close,
size: 14,
color: _isDragging && _draggedPanelId == panelId
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
),
),
],
),
],
),
),
);
}
/// 根据拖拽位置更新面板顺序
void _updatePanelOrder(double globalX) {
if (_draggedPanelId == null || !mounted) return;
final currentIndex = widget.layoutManager.visiblePanels.indexOf(_draggedPanelId!);
if (currentIndex == -1) return;
// 简化的位置计算:基于偏移量估算新位置
int newIndex = currentIndex;
final offset = _draggedPanelOffset;
// 使用较大的阈值避免频繁重排序
const threshold = 100.0;
if (offset > threshold && currentIndex < widget.layoutManager.visiblePanels.length - 1) {
newIndex = currentIndex + 1;
} else if (offset < -threshold && currentIndex > 0) {
newIndex = currentIndex - 1;
}
// 如果位置发生了变化,更新面板顺序
if (newIndex != currentIndex && mounted) {
widget.layoutManager.reorderPanels(currentIndex, newIndex);
if (mounted) {
setState(() {
_draggedPanelOffset = 0.0; // 重置偏移量
});
}
}
}
// 根据面板类型获取对应图标
IconData _getPanelIcon(String panelId) {
switch (panelId) {
case EditorLayoutManager.aiChatPanel:
return Icons.chat_outlined;
case EditorLayoutManager.aiSummaryPanel:
return Icons.summarize_outlined;
case EditorLayoutManager.aiScenePanel:
return Icons.auto_awesome_outlined;
case EditorLayoutManager.aiContinueWritingPanel:
return Icons.auto_stories_outlined;
case EditorLayoutManager.aiSettingGenerationPanel:
return Icons.auto_fix_high_outlined;
default:
return Icons.dashboard_outlined;
}
}
// 关闭指定面板
void _closePanel(String panelId) {
switch (panelId) {
case EditorLayoutManager.aiChatPanel:
widget.layoutManager.toggleAIChatSidebar();
break;
case EditorLayoutManager.aiSummaryPanel:
widget.layoutManager.toggleAISummaryPanel();
break;
case EditorLayoutManager.aiScenePanel:
widget.layoutManager.toggleAISceneGenerationPanel();
break;
case EditorLayoutManager.aiContinueWritingPanel:
widget.layoutManager.toggleAIContinueWritingPanel();
break;
case EditorLayoutManager.aiSettingGenerationPanel:
widget.layoutManager.toggleAISettingGenerationPanel();
break;
}
}
Widget _buildPanel(String panelId) {
switch (panelId) {
case EditorLayoutManager.aiChatPanel:
return _buildAIChatPanel();
case EditorLayoutManager.aiSummaryPanel:
return _buildAISummaryPanel();
case EditorLayoutManager.aiScenePanel:
return _buildAISceneGenerationPanel();
case EditorLayoutManager.aiContinueWritingPanel:
return _buildAIContinueWritingPanel();
case EditorLayoutManager.aiSettingGenerationPanel:
return _buildAISettingGenerationPanel();
default:
return Center(child: Text('未知面板类型: $panelId'));
}
}
Widget _buildAIChatPanel() {
return AIChatSidebar(
novelId: widget.novelId,
chapterId: widget.chapterId,
onClose: widget.layoutManager.toggleAIChatSidebar,
isCardMode: true,
);
}
Widget _buildAISummaryPanel() {
return AISummaryPanel(
novelId: widget.novelId,
onClose: widget.layoutManager.toggleAISummaryPanel,
isCardMode: true,
);
}
Widget _buildAISceneGenerationPanel() {
return AIGenerationPanel(
novelId: widget.novelId,
onClose: widget.layoutManager.toggleAISceneGenerationPanel,
isCardMode: true,
);
}
Widget _buildAIContinueWritingPanel() {
if (widget.userId == null) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: Text(
'请先登录以使用自动续写功能。',
textAlign: TextAlign.center,
),
),
);
}
return ContinueWritingForm(
novelId: widget.novelId,
userId: widget.userId!,
userAiModelConfigRepository: widget.userAiModelConfigRepository,
onCancel: widget.layoutManager.toggleAIContinueWritingPanel,
onSubmit: widget.onContinueWritingSubmit,
);
}
Widget _buildAISettingGenerationPanel() {
return AISettingGenerationPanel(
novelId: widget.novelId,
onClose: widget.layoutManager.toggleAISettingGenerationPanel,
isCardMode: true,
editorRepository: widget.editorRepository,
novelAIRepository: widget.novelAIRepository,
);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,832 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:ainoval/blocs/ai_config/ai_config_bloc.dart';
import 'package:ainoval/blocs/universal_ai/universal_ai_bloc.dart';
import 'package:ainoval/blocs/universal_ai/universal_ai_state.dart';
import 'package:ainoval/blocs/universal_ai/universal_ai_event.dart';
// import 'package:ainoval/blocs/public_models/public_models_bloc.dart';
import 'package:ainoval/blocs/prompt_new/prompt_new_bloc.dart';
import 'package:ainoval/models/user_ai_model_config_model.dart';
import 'package:ainoval/models/context_selection_models.dart';
import 'package:ainoval/models/novel_structure.dart';
import 'package:ainoval/models/novel_setting_item.dart';
import 'package:ainoval/models/setting_group.dart';
import 'package:ainoval/models/novel_snippet.dart';
import 'package:ainoval/models/scene_beat_data.dart';
import 'package:ainoval/models/ai_request_models.dart';
import 'package:ainoval/models/unified_ai_model.dart';
import 'package:ainoval/models/preset_models.dart';
import 'package:ainoval/widgets/common/index.dart';
import 'package:ainoval/widgets/common/top_toast.dart';
import 'package:ainoval/widgets/common/unified_ai_model_dropdown.dart';
import 'package:ainoval/widgets/common/prompt_preview_widget.dart';
import 'package:ainoval/screens/editor/components/ai_dialog_common_logic.dart';
// 移除未使用的仓库相关导入
import 'package:ainoval/utils/web_theme.dart';
import 'package:ainoval/config/app_config.dart';
import 'package:ainoval/utils/logger.dart';
import 'package:ainoval/blocs/public_models/public_models_bloc.dart';
/// 场景节拍编辑对话框
/// 完全按照SummaryDialog的样式和结构设计
class SceneBeatEditDialog extends StatefulWidget {
const SceneBeatEditDialog({
super.key,
required this.data,
this.novel,
this.settings = const [],
this.settingGroups = const [],
this.snippets = const [],
this.selectedUnifiedModel,
this.onDataChanged,
this.onGenerate,
});
final SceneBeatData data;
final Novel? novel;
final List<NovelSettingItem> settings;
final List<SettingGroup> settingGroups;
final List<NovelSnippet> snippets;
final UnifiedAIModel? selectedUnifiedModel;
final ValueChanged<SceneBeatData>? onDataChanged;
final Function(UniversalAIRequest, UnifiedAIModel)? onGenerate;
@override
State<SceneBeatEditDialog> createState() => _SceneBeatEditDialogState();
}
class _SceneBeatEditDialogState extends State<SceneBeatEditDialog> with AIDialogCommonLogic {
// 控制器
late TextEditingController _promptController;
late TextEditingController _instructionsController;
late TextEditingController _lengthController;
// 状态变量
UnifiedAIModel? _selectedUnifiedModel;
String? _selectedLength;
bool _enableSmartContext = true;
AIPromptPreset? _currentPreset;
String? _selectedPromptTemplateId;
// 临时自定义提示词
String? _customSystemPrompt;
String? _customUserPrompt;
double _temperature = 0.7;
double _topP = 0.9;
late ContextSelectionData _contextSelectionData;
// 模型选择器key用于FormDialogTemplate
final GlobalKey _modelSelectorKey = GlobalKey();
OverlayEntry? _tempOverlay;
@override
void initState() {
super.initState();
// 初始化控制器
final parsedRequest = widget.data.parsedRequest;
_promptController = TextEditingController(text: parsedRequest?.prompt ?? '续写故事。');
_instructionsController = TextEditingController(text: parsedRequest?.instructions ?? '一个关键时刻,重要的事情发生改变,推动故事发展。');
_lengthController = TextEditingController();
// 初始化状态
_selectedUnifiedModel = widget.selectedUnifiedModel;
_selectedLength = widget.data.selectedLength;
// 同步初始长度到输入框:若为自定义长度,则填入文本框并清空单选
if (_selectedLength != null && !['200', '400', '600'].contains(_selectedLength)) {
_lengthController.text = _selectedLength!;
_selectedLength = null;
}
_temperature = widget.data.temperature;
_topP = widget.data.topP;
_enableSmartContext = widget.data.enableSmartContext;
_selectedPromptTemplateId = widget.data.selectedPromptTemplateId;
// 初始化上下文选择数据
if (widget.data.parsedContextSelections != null) {
// 如果已有保存的上下文选择,则在完整上下文树的基础上回显已选中项
final baseData = _createDefaultContextSelectionData();
_contextSelectionData = _mergeContextSelections(
baseData,
widget.data.parsedContextSelections!,
);
} else {
_contextSelectionData = _createDefaultContextSelectionData();
}
debugPrint('SceneBeatEditDialog 初始化上下文选择数据');
debugPrint('SceneBeatEditDialog Novel: ${widget.novel?.title}');
debugPrint('SceneBeatEditDialog Settings: ${widget.settings.length}');
debugPrint('SceneBeatEditDialog Setting Groups: ${widget.settingGroups.length}');
debugPrint('SceneBeatEditDialog Snippets: ${widget.snippets.length}');
}
@override
void dispose() {
_promptController.dispose();
_instructionsController.dispose();
_lengthController.dispose();
_tempOverlay?.remove();
super.dispose();
}
ContextSelectionData _createDefaultContextSelectionData() {
if (widget.novel != null) {
return ContextSelectionDataBuilder.fromNovelWithContext(
widget.novel!,
settings: widget.settings,
settingGroups: widget.settingGroups,
snippets: widget.snippets,
);
} else {
return ContextSelectionData(
novelId: 'scene_beat',
availableItems: const [],
flatItems: const {},
);
}
}
// (已移除未使用的演示方法与扁平化构建方法)
@override
Widget build(BuildContext context) {
// (已移除未使用的 Repository 初始化代码)
return MultiBlocProvider(
providers: [
// 使用全局的 UniversalAIBloc 而不是创建新的
BlocProvider.value(value: context.read<UniversalAIBloc>()),
// 🚀 为FormDialogTemplate提供必要的Bloc
BlocProvider.value(value: context.read<PromptNewBloc>()),
],
child: FormDialogTemplate(
title: '场景节拍配置',
tabs: const [
TabItem(
id: 'tweak',
label: '调整',
icon: Icons.edit,
),
TabItem(
id: 'preview',
label: '预览',
icon: Icons.preview,
),
],
tabContents: [
_buildTweakTab(),
_buildPreviewTab(),
],
onTabChanged: _onTabChanged,
showPresets: true,
usePresetDropdown: true,
presetFeatureType: 'SCENE_BEAT_GENERATION',
currentPreset: _currentPreset,
onPresetSelected: _handlePresetSelected,
onCreatePreset: _showCreatePresetDialog,
onManagePresets: _showManagePresetsPage,
novelId: widget.novel?.id,
showModelSelector: true,
modelSelectorData: _selectedUnifiedModel != null
? ModelSelectorData(
modelName: _selectedUnifiedModel!.displayName,
maxOutput: '~12000 words',
isModerated: true,
)
: const ModelSelectorData(
modelName: '选择模型',
),
onModelSelectorTap: _showModelSelectorDropdown,
modelSelectorKey: _modelSelectorKey,
primaryActionLabel: '保存配置',
onPrimaryAction: _handleSave,
onClose: _handleClose,
),
);
}
/// 构建调整选项卡
Widget _buildTweakTab() {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
// 指令字段
FormFieldFactory.createInstructionsField(
controller: _instructionsController,
title: '指令',
description: '为AI提供的额外指令和角色设定',
placeholder: 'e.g. 一个关键时刻,重要的事情发生改变',
onReset: () => setState(() => _instructionsController.clear()),
onExpand: () {}, // TODO: 实现展开编辑器
onCopy: () {}, // TODO: 实现复制功能
),
const SizedBox(height: 16),
// 长度字段
FormFieldFactory.createLengthField<String>(
options: const [
RadioOption(value: '200', label: '200字'),
RadioOption(value: '400', label: '400字'),
RadioOption(value: '600', label: '600字'),
],
value: _selectedLength,
onChanged: (value) {
setState(() {
_selectedLength = value;
_lengthController.clear();
});
if (value != null) {
final updated = widget.data.copyWith(
selectedLength: value,
updatedAt: DateTime.now(),
);
widget.onDataChanged?.call(updated);
}
},
title: '长度',
description: '生成内容的目标长度',
onReset: () => setState(() {
_selectedLength = null;
_lengthController.clear();
}),
alternativeInput: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 40),
child: TextField(
controller: _lengthController,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: InputDecoration(
hintText: 'e.g. 300字',
isDense: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(
color: Theme.of(context).brightness == Brightness.dark
? WebTheme.darkGrey300
: WebTheme.grey300,
width: 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(
color: Theme.of(context).brightness == Brightness.dark
? WebTheme.darkGrey300
: WebTheme.grey300,
width: 1,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(
color: WebTheme.getPrimaryColor(context),
width: 1,
),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
fillColor: Theme.of(context).brightness == Brightness.dark
? WebTheme.darkGrey100
: WebTheme.white,
filled: true,
),
onChanged: (value) {
setState(() {
_selectedLength = null;
});
final trimmed = value.trim();
final parsed = int.tryParse(trimmed);
if (parsed != null) {
final clamped = parsed.clamp(50, 5000).toString();
final updated = widget.data.copyWith(
selectedLength: clamped,
updatedAt: DateTime.now(),
);
widget.onDataChanged?.call(updated);
}
},
onSubmitted: (value) {
final parsed = int.tryParse(value.trim());
if (parsed != null) {
final clamped = parsed.clamp(50, 5000).toString();
if (_lengthController.text != clamped) {
_lengthController.text = clamped;
}
final updated = widget.data.copyWith(
selectedLength: clamped,
updatedAt: DateTime.now(),
);
widget.onDataChanged?.call(updated);
}
},
),
),
),
const SizedBox(height: 16),
// 附加上下文字段
FormFieldFactory.createContextSelectionField(
contextData: _contextSelectionData,
onSelectionChanged: (newData) => setState(() => _contextSelectionData = newData),
title: '附加上下文',
description: '为AI提供的任何额外信息',
onReset: () => setState(() => _contextSelectionData = _createDefaultContextSelectionData()),
dropdownWidth: 400,
initialChapterId: null,
initialSceneId: null,
),
const SizedBox(height: 16),
// 智能上下文勾选组件
SmartContextToggle(
value: _enableSmartContext,
onChanged: (value) => setState(() => _enableSmartContext = value),
title: '智能上下文',
description: '使用AI自动检索相关背景信息提升生成质量',
),
const SizedBox(height: 16),
// 关联提示词模板选择字段
FormFieldFactory.createPromptTemplateSelectionField(
selectedTemplateId: _selectedPromptTemplateId,
onTemplateSelected: (templateId) => setState(() => _selectedPromptTemplateId = templateId),
aiFeatureType: 'SCENE_BEAT_GENERATION',
title: '关联提示词模板',
description: '选择要关联的提示词模板(可选)',
onReset: () => setState(() => _selectedPromptTemplateId = null),
onTemporaryPromptsSaved: (sys, user) {
setState(() {
_customSystemPrompt = sys.trim().isEmpty ? null : sys.trim();
_customUserPrompt = user.trim().isEmpty ? null : user.trim();
});
},
),
const SizedBox(height: 16),
// 温度滑动组件
FormFieldFactory.createTemperatureSliderField(
context: context,
value: _temperature,
onChanged: (value) => setState(() => _temperature = value),
onReset: () => setState(() => _temperature = 0.7),
),
const SizedBox(height: 16),
// Top-P滑动组件
FormFieldFactory.createTopPSliderField(
context: context,
value: _topP,
onChanged: (value) => setState(() => _topP = value),
onReset: () => setState(() => _topP = 0.9),
),
],
);
}
/// 构建预览选项卡
Widget _buildPreviewTab() {
return BlocBuilder<UniversalAIBloc, UniversalAIState>(
builder: (context, state) {
if (state is UniversalAILoading) {
return const PromptPreviewLoadingWidget();
} else if (state is UniversalAIPreviewSuccess) {
return PromptPreviewWidget(
previewResponse: state.previewResponse,
showActions: true,
);
} else if (state is UniversalAIError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 48,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
'预览失败',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
state.message,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.error,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _triggerPreview,
child: const Text('重试'),
),
],
),
);
} else {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.preview_outlined,
size: 48,
color: Theme.of(context).colorScheme.outlineVariant,
),
const SizedBox(height: 16),
const Text(
'点击预览选项卡查看提示词',
style: TextStyle(fontSize: 16),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _triggerPreview,
child: const Text('生成预览'),
),
],
),
);
}
},
);
}
/// Tab切换监听器
void _onTabChanged(String tabId) {
if (tabId == 'preview') {
_triggerPreview();
}
}
/// 触发预览请求
void _triggerPreview() {
if (_selectedUnifiedModel == null) {
TopToast.warning(context, '请先选择AI模型');
return;
}
// 根据模型类型获取配置
late UserAIModelConfigModel modelConfig;
if (_selectedUnifiedModel!.isPublic) {
final publicModel = (_selectedUnifiedModel as PublicAIModel).publicConfig;
modelConfig = UserAIModelConfigModel.fromJson({
'id': publicModel.id,
'userId': AppConfig.userId ?? 'unknown',
'name': publicModel.displayName,
'alias': publicModel.displayName,
'modelName': publicModel.modelId,
'provider': publicModel.provider,
'apiEndpoint': '',
'isDefault': false,
'isValidated': true,
'createdAt': DateTime.now().toIso8601String(),
'updatedAt': DateTime.now().toIso8601String(),
'isPublic': true,
'creditMultiplier': publicModel.creditRateMultiplier ?? 1.0,
});
} else {
modelConfig = (_selectedUnifiedModel as PrivateAIModel).userConfig;
}
final request = UniversalAIRequest(
requestType: AIRequestType.sceneBeat,
userId: AppConfig.userId ?? 'unknown',
novelId: widget.novel?.id,
modelConfig: modelConfig,
prompt: _promptController.text.trim(),
instructions: _instructionsController.text.trim(),
contextSelections: _contextSelectionData,
enableSmartContext: _enableSmartContext,
parameters: {
'length': _selectedLength ?? _lengthController.text.trim(),
'temperature': _temperature,
'topP': _topP,
'maxTokens': 4000,
'modelName': _selectedUnifiedModel!.modelId,
'enableSmartContext': _enableSmartContext,
'promptTemplateId': _selectedPromptTemplateId,
if (_customSystemPrompt != null) 'customSystemPrompt': _customSystemPrompt,
if (_customUserPrompt != null) 'customUserPrompt': _customUserPrompt,
},
metadata: {
'action': 'scene_beat',
'source': 'preview',
'contextCount': _contextSelectionData.selectedCount,
'modelName': _selectedUnifiedModel!.modelId,
'modelProvider': _selectedUnifiedModel!.provider,
'modelConfigId': _selectedUnifiedModel!.id,
'enableSmartContext': _enableSmartContext,
},
);
// 发送预览请求
context.read<UniversalAIBloc>().add(PreviewAIRequestEvent(request));
// 无需返回值
}
/// 显示模型选择器下拉菜单
void _showModelSelectorDropdown() {
// 确保公共模型已加载,避免无私人模型时无法选择
try {
final publicBloc = context.read<PublicModelsBloc>();
final st = publicBloc.state;
if (st is PublicModelsInitial || st is PublicModelsError) {
publicBloc.add(const LoadPublicModels());
}
} catch (_) {}
final renderBox = _modelSelectorKey.currentContext?.findRenderObject() as RenderBox?;
if (renderBox == null) return;
final position = renderBox.localToGlobal(Offset.zero);
final size = renderBox.size;
final anchorRect = Rect.fromLTWH(position.dx, position.dy, size.width, size.height);
_tempOverlay?.remove();
_tempOverlay = UnifiedAIModelDropdown.show(
context: context,
anchorRect: anchorRect,
selectedModel: _selectedUnifiedModel,
onModelSelected: (unifiedModel) {
setState(() {
_selectedUnifiedModel = unifiedModel;
});
},
showSettingsButton: true,
novel: widget.novel,
settings: widget.settings,
settingGroups: widget.settingGroups,
snippets: widget.snippets,
onClose: () {
_tempOverlay = null;
},
);
}
/// 构建当前请求对象(用于保存预设)
UniversalAIRequest? _buildCurrentRequest() {
// 情况 1已选择新的统一模型直接构建最新请求
if (_selectedUnifiedModel != null) {
final modelConfig = createModelConfig(_selectedUnifiedModel!);
final metadata = createModelMetadata(_selectedUnifiedModel!, {
'action': 'scene_beat',
'source': 'scene_beat_edit_dialog',
'contextCount': _contextSelectionData.selectedCount,
'enableSmartContext': _enableSmartContext,
});
return UniversalAIRequest(
requestType: AIRequestType.sceneBeat,
userId: AppConfig.userId ?? 'unknown',
novelId: widget.novel?.id,
modelConfig: modelConfig,
prompt: _promptController.text.trim(),
instructions: _instructionsController.text.trim(),
contextSelections: _contextSelectionData,
enableSmartContext: _enableSmartContext,
parameters: {
'length': _selectedLength ?? _lengthController.text.trim(),
'temperature': _temperature,
'topP': _topP,
'maxTokens': 4000,
'modelName': _selectedUnifiedModel!.modelId,
'enableSmartContext': _enableSmartContext,
'promptTemplateId': _selectedPromptTemplateId,
if (_customSystemPrompt != null) 'customSystemPrompt': _customSystemPrompt,
if (_customUserPrompt != null) 'customUserPrompt': _customUserPrompt,
},
metadata: metadata,
);
}
// 情况 2未选择模型但之前已有请求快照基于旧请求更新可编辑字段
final prevRequest = widget.data.parsedRequest;
if (prevRequest == null) return null;
final updatedParameters = Map<String, dynamic>.from(prevRequest.parameters);
updatedParameters['length'] = _selectedLength ?? _lengthController.text.trim();
updatedParameters['temperature'] = _temperature;
updatedParameters['topP'] = _topP;
updatedParameters['enableSmartContext'] = _enableSmartContext;
updatedParameters['promptTemplateId'] = _selectedPromptTemplateId;
if (_customSystemPrompt != null) {
updatedParameters['customSystemPrompt'] = _customSystemPrompt;
}
if (_customUserPrompt != null) {
updatedParameters['customUserPrompt'] = _customUserPrompt;
}
return UniversalAIRequest(
requestType: prevRequest.requestType,
userId: prevRequest.userId,
novelId: prevRequest.novelId,
modelConfig: prevRequest.modelConfig,
prompt: prevRequest.prompt,
instructions: _instructionsController.text.trim(),
contextSelections: _contextSelectionData,
enableSmartContext: _enableSmartContext,
parameters: updatedParameters,
metadata: prevRequest.metadata,
);
}
/// 显示创建预设对话框
void _showCreatePresetDialog() {
final currentRequest = _buildCurrentRequest();
if (currentRequest == null) {
TopToast.warning(context, '无法创建预设:缺少表单数据');
return;
}
showPresetNameDialog(currentRequest, onPresetCreated: _handlePresetCreated);
}
/// 显示预设管理页面
void _showManagePresetsPage() {
// TODO: 实现预设管理页面
TopToast.info(context, '预设管理功能开发中...');
}
/// 处理预设选择
void _handlePresetSelected(AIPromptPreset preset) {
try {
// 设置当前预设
setState(() {
_currentPreset = preset;
});
// 🚀 使用公共方法应用预设配置
applyPresetToForm(
preset,
instructionsController: _instructionsController,
onLengthChanged: (length) {
setState(() {
if (length != null && ['200', '400', '600'].contains(length)) {
_selectedLength = length;
_lengthController.clear();
} else if (length != null) {
_selectedLength = null;
_lengthController.text = length;
}
});
},
onSmartContextChanged: (value) {
setState(() {
_enableSmartContext = value;
});
},
onPromptTemplateChanged: (templateId) {
setState(() {
_selectedPromptTemplateId = templateId;
});
},
onTemperatureChanged: (temperature) {
setState(() {
_temperature = temperature;
});
},
onTopPChanged: (topP) {
setState(() {
_topP = topP;
});
},
onContextSelectionChanged: (contextData) {
setState(() {
_contextSelectionData = contextData;
});
},
onModelChanged: (unifiedModel) {
setState(() {
_selectedUnifiedModel = unifiedModel;
});
},
currentContextData: _contextSelectionData,
);
} catch (e) {
AppLogger.e('SceneBeatEditDialog', '应用预设失败', e);
TopToast.error(context, '应用预设失败: $e');
}
}
/// 处理预设创建
void _handlePresetCreated(AIPromptPreset preset) {
// 设置当前预设为新创建的预设
setState(() {
_currentPreset = preset;
});
TopToast.success(context, '预设 "${preset.presetName}" 创建成功');
AppLogger.i('SceneBeatEditDialog', '预设创建成功: ${preset.presetName}');
}
void _handleSave() {
// 构建更新的AI请求
final request = _buildCurrentRequest();
// 更新SceneBeatData
final updatedData = widget.data.copyWith(
requestData: request != null ? jsonEncode(request.toApiJson()) : widget.data.requestData,
selectedUnifiedModelId: _selectedUnifiedModel?.id,
selectedLength: _selectedLength ?? _lengthController.text.trim(),
temperature: _temperature,
topP: _topP,
enableSmartContext: _enableSmartContext,
selectedPromptTemplateId: _selectedPromptTemplateId,
contextSelectionsData: _contextSelectionData.selectedCount > 0
? jsonEncode({
'novelId': _contextSelectionData.novelId,
'selectedItems': _contextSelectionData.selectedItems.values.map((item) => {
'id': item.id,
'title': item.title,
'type': item.type.value, // 🚀 修复使用API值
'metadata': item.metadata,
}).toList(),
})
: null,
updatedAt: DateTime.now(),
);
widget.onDataChanged?.call(updatedData);
Navigator.of(context).pop();
TopToast.success(context, '场景节拍配置已保存');
}
void _handleClose() {
Navigator.of(context).pop();
}
/// 将已保存的上下文选择合并到新的完整上下文树中
ContextSelectionData _mergeContextSelections(
ContextSelectionData baseData,
ContextSelectionData savedSelections,
) {
var mergedData = baseData;
// 遍历已保存的选项,将其在新的树中设为选中
for (final itemId in savedSelections.selectedItems.keys) {
if (mergedData.flatItems.containsKey(itemId)) {
mergedData = mergedData.selectItem(itemId);
} else {
// 如果新树中没有该项,则将其追加到已选映射,避免数据丢失
final savedItem = savedSelections.selectedItems[itemId]!;
mergedData = mergedData.copyWith(
selectedItems: {
...mergedData.selectedItems,
savedItem.id: savedItem,
},
);
}
}
return mergedData;
}
}
/// 显示场景节拍编辑对话框的便捷函数
void showSceneBeatEditDialog(
BuildContext context, {
required SceneBeatData data,
Novel? novel,
List<NovelSettingItem> settings = const [],
List<SettingGroup> settingGroups = const [],
List<NovelSnippet> snippets = const [],
UnifiedAIModel? selectedUnifiedModel,
ValueChanged<SceneBeatData>? onDataChanged,
Function(UniversalAIRequest, UnifiedAIModel)? onGenerate,
}) {
showDialog(
context: context,
barrierDismissible: true,
builder: (dialogContext) {
return MultiBlocProvider(
providers: [
BlocProvider.value(value: context.read<AiConfigBloc>()),
BlocProvider.value(value: context.read<PromptNewBloc>()),
],
child: SceneBeatEditDialog(
data: data,
novel: novel,
settings: settings,
settingGroups: settingGroups,
snippets: snippets,
selectedUnifiedModel: selectedUnifiedModel,
onDataChanged: onDataChanged,
onGenerate: onGenerate,
),
);
},
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,221 @@
import 'package:flutter/material.dart';
import 'package:ainoval/blocs/editor/editor_bloc.dart' as editor_bloc;
import 'package:ainoval/utils/logger.dart';
/// 卷轴导航按钮组件
/// 显示上一卷/下一卷/添加新卷按钮
class VolumeNavigationButtons extends StatelessWidget {
// 位置控制
final bool isTop;
// 卷状态控制
final bool isFirstAct;
final bool isLastAct;
final String? previousActTitle;
final String? nextActTitle;
// 滚动状态
final bool hasReachedStart;
final bool hasReachedEnd;
// 加载状态
final bool isLoadingMore;
// 回调
final VoidCallback? onPreviousAct;
final VoidCallback? onNextAct;
final VoidCallback? onAddNewAct;
const VolumeNavigationButtons({
Key? key,
required this.isTop,
required this.isFirstAct,
required this.isLastAct,
this.previousActTitle,
this.nextActTitle,
required this.hasReachedStart,
required this.hasReachedEnd,
this.isLoadingMore = false,
this.onPreviousAct,
this.onNextAct,
this.onAddNewAct,
}) : super(key: key);
@override
Widget build(BuildContext context) {
// 始终记录底部按钮显示条件,方便调试
if (!isTop) {
AppLogger.i('VolumeNavigationButtons', '底部按钮条件: isLastAct=$isLastAct, hasReachedEnd=$hasReachedEnd');
}
// 上方按钮显示条件:
// 1. 是顶部按钮位置 (isTop)
// 2. 不能是第一卷 (isFirstAct == false)
final bool shouldShowTopButton = isTop && !isFirstAct;
// 下方按钮显示条件:
// 1. 是底部按钮位置
final bool shouldShowBottomButton = !isTop;
// 确定按钮类型
// 顶部按钮永远是"上一卷"
// 底部按钮在最后一卷时是"添加新卷",否则是"下一卷"
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (Widget child, Animation<double> animation) {
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: Offset(0, isTop ? -0.5 : 0.5),
end: Offset.zero,
).animate(animation),
child: child,
),
);
},
child: (shouldShowTopButton || shouldShowBottomButton)
? _buildButton(
context,
isTop: isTop,
)
: const SizedBox.shrink(),
);
}
Widget _buildButton(
BuildContext context, {
required bool isTop,
}) {
final themeData = Theme.of(context);
// 安全地获取前一个和下一个卷的信息
final String? prevVolumeName = isFirstAct ? null : previousActTitle;
final String? nextVolumeName = this.isLastAct ? null : this.nextActTitle;
// 按钮文本
late final String buttonText;
late final IconData buttonIcon;
late final VoidCallback? onPressed;
if (isTop) {
// 顶部按钮:上一卷
if (prevVolumeName == null) {
buttonText = '返回首卷';
} else {
String displayName = prevVolumeName;
if (displayName.length > 10) {
displayName = displayName.substring(0, 10) + '...';
}
buttonText = '上一卷:$displayName';
}
buttonIcon = Icons.arrow_upward_rounded;
onPressed = onPreviousAct;
} else {
if (this.isLastAct) {
// 底部按钮:如果是最后一卷,则为"添加新卷"
buttonText = '添加新卷';
buttonIcon = Icons.add_rounded;
onPressed = onAddNewAct;
} else {
// 底部按钮:如果不是最后一卷,则为"下一卷"
if (nextVolumeName == null) {
buttonText = '下一卷';
} else {
String displayName = nextVolumeName;
if (displayName.length > 10) {
displayName = displayName.substring(0, 10) + '...';
}
buttonText = '下一卷:$displayName';
}
buttonIcon = Icons.arrow_downward_rounded;
onPressed = onNextAct;
}
}
// 构建按钮
return Padding(
padding: EdgeInsets.only(
top: isTop ? 16.0 : 0.0,
bottom: isTop ? 0.0 : 16.0,
),
child: Container(
height: 60,
padding: const EdgeInsets.symmetric(horizontal: 8.0),
decoration: BoxDecoration(
color: themeData.cardColor,
borderRadius: BorderRadius.circular(30),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(30),
onTap: isLoadingMore ? null : onPressed,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
isLoadingMore
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: Icon(
buttonIcon,
color: themeData.colorScheme.primary,
),
const SizedBox(width: 8),
Text(
isLoadingMore ? '加载中...' : buttonText,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: themeData.colorScheme.onSurface,
),
),
],
),
),
),
),
),
);
}
// 构建加载指示器
Widget _buildLoadingIndicator(ThemeData theme, String loadingText) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation<Color>(theme.colorScheme.primary),
),
),
const SizedBox(width: 12),
Text(
loadingText,
style: TextStyle(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
],
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,164 @@
import 'package:ainoval/blocs/auth/auth_bloc.dart';
import 'package:ainoval/blocs/sidebar/sidebar_bloc.dart';
// import 'package:ainoval/config/app_config.dart';
// import 'package:ainoval/models/novel_structure.dart' as novel_models;
import 'package:ainoval/models/novel_summary.dart';
import 'package:ainoval/screens/editor/components/editor_layout.dart';
import 'package:ainoval/screens/editor/controllers/editor_screen_controller.dart';
import 'package:ainoval/screens/editor/managers/editor_layout_manager.dart';
import 'package:ainoval/screens/editor/managers/editor_state_manager.dart';
// import 'package:ainoval/screens/editor/widgets/continue_writing_form.dart';
// import 'package:ainoval/services/api_service/repositories/editor_repository.dart';
// import 'package:ainoval/services/api_service/repositories/user_ai_model_config_repository.dart';
import 'package:ainoval/services/api_service/base/api_client.dart';
// import 'package:ainoval/utils/logger.dart';
// import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
// import 'package:ainoval/blocs/setting/setting_bloc.dart';
import 'package:ainoval/services/api_service/repositories/novel_setting_repository.dart';
import 'package:ainoval/services/api_service/repositories/impl/novel_setting_repository_impl.dart';
import 'package:ainoval/utils/web_theme.dart';
import 'package:ainoval/blocs/prompt_new/prompt_new_bloc.dart';
// import 'package:ainoval/services/api_service/repositories/prompt_repository.dart';
// import 'package:ainoval/services/api_service/repositories/impl/prompt_repository_impl.dart';
// import 'package:ainoval/screens/prompt/prompt_screen.dart';
/// 编辑器屏幕
/// 使用设计模式重构后的编辑器屏幕,将功能拆分为多个组件
class EditorScreen extends StatefulWidget {
const EditorScreen({
super.key,
required this.novel,
});
final NovelSummary novel;
@override
State<EditorScreen> createState() => _EditorScreenState();
}
class _EditorScreenState extends State<EditorScreen> with SingleTickerProviderStateMixin {
late final EditorScreenController _controller;
late final EditorLayoutManager _layoutManager;
late final EditorStateManager _stateManager;
late final PromptNewBloc _promptNewBloc;
late final SidebarBloc _sidebarBloc;
@override
void initState() {
super.initState();
_controller = EditorScreenController(
novel: widget.novel,
vsync: this,
);
_layoutManager = EditorLayoutManager();
_stateManager = EditorStateManager();
// 初始化 SidebarBloc
_sidebarBloc = SidebarBloc(
editorRepository: _controller.editorRepository,
);
// 初始化 PromptNewBloc
_promptNewBloc = PromptNewBloc(
promptRepository: _controller.promptRepository,
);
// 加载小说结构数据
_sidebarBloc.add(LoadNovelStructure(widget.novel.id));
}
// 自动续写对话框显示控制
void _showAutoContinueWritingDialog() {
// 暂时留空,功能待实现
}
@override
void dispose() {
// 关闭SidebarBloc
_sidebarBloc.close();
// 关闭PromptNewBloc
_promptNewBloc.close();
// 尝试同步当前小说数据
_controller.syncCurrentNovel();
// 通知小说列表页面刷新数据
_controller.notifyNovelListRefresh(context);
// 释放控制器资源
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocListener<AuthBloc, AuthState>(
listenWhen: (prev, curr) => curr is AuthUnauthenticated,
listener: (context, state) {
// 监听认证状态变化,当用户未认证时导航回登录页面
if (state is AuthUnauthenticated) {
// 确保在widget仍然挂载时执行导航
if (mounted) {
// 使用pushAndRemoveUntil清除导航栈并导航到登录页面
// Navigator.of(context).pushAndRemoveUntil(
// MaterialPageRoute(builder: (context) => const LoginScreen()),
// (route) => false, // 清除所有现有路由
// );
}
}
},
child: MultiRepositoryProvider(
providers: [
RepositoryProvider<NovelSettingRepository>(
create: (context) => NovelSettingRepositoryImpl(
apiClient: ApiClient(),
),
),
],
child: MultiBlocProvider(
providers: [
// 确保AuthBloc在编辑器中可用
BlocProvider.value(value: context.read<AuthBloc>()),
BlocProvider.value(value: _controller.editorBloc),
BlocProvider.value(value: _sidebarBloc),
BlocProvider.value(value: _promptNewBloc),
ChangeNotifierProvider.value(value: _controller),
ChangeNotifierProvider.value(value: _layoutManager),
BlocProvider.value(value: _controller.settingBlocInstance),
],
child: ValueListenableBuilder<String>(
valueListenable: WebTheme.variantListenable,
builder: (context, variant, _) {
// 通过监听变体确保本地Theme随全局主题变更而重建
return Theme(
data: Theme.of(context).copyWith(
// 使用全局主题的颜色,随变体变更
scaffoldBackgroundColor: Theme.of(context).scaffoldBackgroundColor, // 使用正确的背景色
cardColor: Theme.of(context).colorScheme.surface, // 使用动态卡片背景色
),
child: EditorLayout(
controller: _controller,
layoutManager: _layoutManager,
stateManager: _stateManager,
onAutoContinueWritingPressed: _showAutoContinueWritingDialog,
),
);
},
),
),
),
);
}
}

View File

@@ -0,0 +1,150 @@
import 'package:flutter/material.dart';
/// 编辑器对话框管理器
/// 负责管理编辑器中的各种对话框
class EditorDialogManager {
// 显示编辑器侧边栏宽度调整对话框
static void showEditorSidebarWidthDialog(
BuildContext context,
double currentWidth,
double minWidth,
double maxWidth,
ValueChanged<double> onWidthChanged,
VoidCallback onSave,
) {
showDialog(
context: context,
builder: (context) {
return _buildWidthAdjustmentDialog(
context,
'调整侧边栏宽度',
currentWidth,
minWidth,
maxWidth,
onWidthChanged,
onSave,
);
},
);
}
// 显示聊天侧边栏宽度调整对话框
static void showChatSidebarWidthDialog(
BuildContext context,
double currentWidth,
double minWidth,
double maxWidth,
ValueChanged<double> onWidthChanged,
VoidCallback onSave,
) {
showDialog(
context: context,
builder: (context) {
return _buildWidthAdjustmentDialog(
context,
'调整聊天侧边栏宽度',
currentWidth,
minWidth,
maxWidth,
onWidthChanged,
onSave,
);
},
);
}
// 构建宽度调整对话框
static Widget _buildWidthAdjustmentDialog(
BuildContext context,
String title,
double currentWidth,
double minWidth,
double maxWidth,
ValueChanged<double> onWidthChanged,
VoidCallback onSave,
) {
return AlertDialog(
title: Text(title),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('当前宽度: ${currentWidth.toInt()} 像素'),
const SizedBox(height: 16),
StatefulBuilder(
builder: (context, setState) {
return Slider(
value: currentWidth,
min: minWidth,
max: maxWidth,
divisions: 8,
label: currentWidth.toInt().toString(),
onChanged: (value) {
onWidthChanged(value);
setState(() {});
},
);
},
),
],
),
actions: [
TextButton(
onPressed: () {
Navigator.pop(context);
},
child: const Text('取消'),
),
ElevatedButton(
onPressed: () {
onSave();
Navigator.pop(context);
},
child: const Text('确定'),
),
],
);
}
// 显示登录提示对话框
static Widget buildLoginRequiredPanel(BuildContext context, VoidCallback onClose) {
return Material(
elevation: 4.0,
borderRadius: BorderRadius.circular(12.0),
child: Container(
width: 400, // Smaller width for message
height: 200, // Smaller height for message
padding: const EdgeInsets.all(24.0),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12.0),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.lock_outline,
size: 40, color: Theme.of(context).colorScheme.error),
const SizedBox(height: 16),
Text(
'需要登录', // TODO: Localize
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'请先登录以访问和管理 AI 配置。', // TODO: Localize
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
// TODO: Implement navigation to login screen
onClose(); // Close panel for now
},
child: const Text('前往登录'), // TODO: Localize
)
],
),
),
);
}
}

View File

@@ -0,0 +1,551 @@
import 'package:ainoval/utils/logger.dart';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:collection/collection.dart'; // For firstWhereOrNull
/// 编辑器布局管理器
/// 负责管理编辑器的布局和尺寸
class EditorLayoutManager extends ChangeNotifier {
EditorLayoutManager() {
_loadSavedDimensions();
}
// 对象dispose状态跟踪
bool _isDisposed = false;
// 侧边栏可见性状态
bool isEditorSidebarVisible = true;
bool isAIChatSidebarVisible = false;
bool isSettingsPanelVisible = false;
bool isNovelSettingsVisible = false;
bool isAISummaryPanelVisible = false;
bool isAISceneGenerationPanelVisible = false;
bool isAIContinueWritingPanelVisible = false;
bool isAISettingGenerationPanelVisible = false;
bool isPromptViewVisible = false;
// 多面板显示时的顺序和位置
final List<String> visiblePanels = [];
static const String aiChatPanel = 'aiChatPanel';
static const String aiSummaryPanel = 'aiSummaryPanel';
static const String aiScenePanel = 'aiScenePanel';
static const String aiContinueWritingPanel = 'aiContinueWritingPanel';
static const String aiSettingGenerationPanel = 'aiSettingGenerationPanel';
// 侧边栏宽度
double editorSidebarWidth = 400;
double chatSidebarWidth = 380;
// 多面板模式下的单个面板宽度
Map<String, double> panelWidths = {
aiChatPanel: 600, // 聊天侧边栏默认最大宽度打开
aiSummaryPanel: 350, // 其他侧边栏保持当前宽度
aiScenePanel: 350,
aiContinueWritingPanel: 350,
aiSettingGenerationPanel: 350,
};
// 侧边栏宽度限制
static const double minEditorSidebarWidth = 220;
static const double maxEditorSidebarWidth = 400;
static const double minChatSidebarWidth = 280;
static const double maxChatSidebarWidth = 500;
static const double minPanelWidth = 280;
static const double maxPanelWidth = 600; // 提升二分之一400 * 1.5 = 600
// 持久化键
static const String editorSidebarWidthPrefKey = 'editor_sidebar_width';
static const String chatSidebarWidthPrefKey = 'chat_sidebar_width';
static const String panelWidthsPrefKey = 'multi_panel_widths';
static const String visiblePanelsPrefKey = 'visible_panels';
static const String lastHiddenPanelsPrefKey = 'last_hidden_panels';
// 保存隐藏前的面板配置
List<String> _lastHiddenPanelsConfig = [];
// 布局变化标志 - 用于标识当前变化是否为纯布局变化
bool _isLayoutOnlyChange = false;
// 操作节流控制
DateTime? _lastLayoutChangeTime;
static const Duration _layoutChangeThrottle = Duration(milliseconds: 200);
// 获取是否为纯布局变化
bool get isLayoutOnlyChange => _isLayoutOnlyChange;
// 重置布局变化标志
void resetLayoutChangeFlag() {
_isLayoutOnlyChange = false;
}
// 🔧 优化:更严格的节流通知机制,避免在关键操作期间触发不必要的布局变化
void _notifyLayoutChange() {
if (_isDisposed) return; // 防止在dispose后调用
final now = DateTime.now();
// 🔧 修复:更严格的节流控制,避免过于频繁的布局变化通知
if (_lastLayoutChangeTime != null &&
now.difference(_lastLayoutChangeTime!) < _layoutChangeThrottle) {
// 在节流期间,仍然设置布局变化标志,但不触发通知
_isLayoutOnlyChange = true;
AppLogger.d('EditorLayoutManager', '节流: 跳过布局变化通知');
return;
}
_lastLayoutChangeTime = now;
_isLayoutOnlyChange = true;
AppLogger.d('EditorLayoutManager', '触发布局变化通知');
// 立即通知监听器
notifyListeners();
// 🔧 修复:延长标志重置时间,确保下游组件有足够时间处理布局变化
Future.delayed(const Duration(milliseconds: 500), () {
if (!_isDisposed) { // 检查对象是否仍然有效
_isLayoutOnlyChange = false;
AppLogger.d('EditorLayoutManager', '重置布局变化标志');
}
});
}
// 加载保存的尺寸
Future<void> _loadSavedDimensions() async {
await _loadSavedEditorSidebarWidth();
await _loadSavedChatSidebarWidth();
await _loadSavedPanelWidths();
await _loadSavedVisiblePanels();
await _loadLastHiddenPanelsConfig();
}
// 加载保存的编辑器侧边栏宽度
Future<void> _loadSavedEditorSidebarWidth() async {
try {
final prefs = await SharedPreferences.getInstance();
final savedWidth = prefs.getDouble(editorSidebarWidthPrefKey);
if (savedWidth != null) {
if (savedWidth >= minEditorSidebarWidth &&
savedWidth <= maxEditorSidebarWidth) {
editorSidebarWidth = savedWidth;
}
}
} catch (e) {
AppLogger.e('EditorLayoutManager', '加载编辑器侧边栏宽度失败', e);
}
}
// 保存编辑器侧边栏宽度
Future<void> saveEditorSidebarWidth() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(editorSidebarWidthPrefKey, editorSidebarWidth);
} catch (e) {
AppLogger.e('EditorLayoutManager', '保存编辑器侧边栏宽度失败', e);
}
}
// 加载保存的聊天侧边栏宽度
Future<void> _loadSavedChatSidebarWidth() async {
try {
final prefs = await SharedPreferences.getInstance();
final savedWidth = prefs.getDouble(chatSidebarWidthPrefKey);
if (savedWidth != null) {
if (savedWidth >= minChatSidebarWidth &&
savedWidth <= maxChatSidebarWidth) {
chatSidebarWidth = savedWidth;
}
}
} catch (e) {
AppLogger.e('EditorLayoutManager', '加载侧边栏宽度失败', e);
}
}
// 加载保存的面板宽度
Future<void> _loadSavedPanelWidths() async {
try {
final prefs = await SharedPreferences.getInstance();
final savedWidthsString = prefs.getString(panelWidthsPrefKey);
if (savedWidthsString != null) {
final savedWidthsList = savedWidthsString.split(',');
if (savedWidthsList.isNotEmpty) {
// 聊天面板保持新的默认值600其他面板加载保存的值
if (savedWidthsList.isNotEmpty && savedWidthsList[0].isNotEmpty) {
final savedChatWidth = double.tryParse(savedWidthsList.elementAtOrNull(0) ?? '');
if (savedChatWidth != null) {
panelWidths[aiChatPanel] = savedChatWidth.clamp(minPanelWidth, maxPanelWidth);
}
}
panelWidths[aiSummaryPanel] = double.tryParse(savedWidthsList.elementAtOrNull(1) ?? panelWidths[aiSummaryPanel].toString())!.clamp(minPanelWidth, maxPanelWidth);
panelWidths[aiScenePanel] = double.tryParse(savedWidthsList.elementAtOrNull(2) ?? panelWidths[aiScenePanel].toString())!.clamp(minPanelWidth, maxPanelWidth);
if (savedWidthsList.length > 3) {
panelWidths[aiContinueWritingPanel] = double.tryParse(savedWidthsList.elementAtOrNull(3) ?? panelWidths[aiContinueWritingPanel].toString())!.clamp(minPanelWidth, maxPanelWidth);
}
if (savedWidthsList.length > 4) {
panelWidths[aiSettingGenerationPanel] = double.tryParse(savedWidthsList.elementAtOrNull(4) ?? panelWidths[aiSettingGenerationPanel].toString())!.clamp(minPanelWidth, maxPanelWidth);
}
}
}
} catch (e) {
AppLogger.e('EditorLayoutManager', '加载面板宽度失败', e);
}
}
// 加载保存的可见面板
Future<void> _loadSavedVisiblePanels() async {
try {
final prefs = await SharedPreferences.getInstance();
final savedPanels = prefs.getStringList(visiblePanelsPrefKey);
if (savedPanels != null) {
visiblePanels.clear();
visiblePanels.addAll(savedPanels);
// 更新各面板的可见性状态
isAIChatSidebarVisible = visiblePanels.contains(aiChatPanel);
isAISummaryPanelVisible = visiblePanels.contains(aiSummaryPanel);
isAISceneGenerationPanelVisible = visiblePanels.contains(aiScenePanel);
isAIContinueWritingPanelVisible = visiblePanels.contains(aiContinueWritingPanel);
isAISettingGenerationPanelVisible = visiblePanels.contains(aiSettingGenerationPanel);
}
} catch (e) {
AppLogger.e('EditorLayoutManager', '加载可见面板失败', e);
}
}
// 保存聊天侧边栏宽度
Future<void> saveChatSidebarWidth() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(chatSidebarWidthPrefKey, chatSidebarWidth);
} catch (e) {
AppLogger.e('EditorLayoutManager', '保存侧边栏宽度失败', e);
}
}
// 保存面板宽度
Future<void> savePanelWidths() async {
try {
final prefs = await SharedPreferences.getInstance();
final widthsString = [
panelWidths[aiChatPanel],
panelWidths[aiSummaryPanel],
panelWidths[aiScenePanel],
panelWidths[aiContinueWritingPanel],
panelWidths[aiSettingGenerationPanel]
].join(',');
await prefs.setString(panelWidthsPrefKey, widthsString);
} catch (e) {
AppLogger.e('EditorLayoutManager', '保存面板宽度失败', e);
}
}
// 保存可见面板
Future<void> saveVisiblePanels() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(visiblePanelsPrefKey, visiblePanels);
} catch (e) {
AppLogger.e('EditorLayoutManager', '保存可见面板失败', e);
}
}
// 加载隐藏前的面板配置
Future<void> _loadLastHiddenPanelsConfig() async {
try {
final prefs = await SharedPreferences.getInstance();
final savedConfig = prefs.getStringList(lastHiddenPanelsPrefKey);
if (savedConfig != null) {
_lastHiddenPanelsConfig = savedConfig;
}
} catch (e) {
AppLogger.e('EditorLayoutManager', '加载隐藏面板配置失败', e);
}
}
// 保存隐藏前的面板配置
Future<void> _saveLastHiddenPanelsConfig() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(lastHiddenPanelsPrefKey, _lastHiddenPanelsConfig);
} catch (e) {
AppLogger.e('EditorLayoutManager', '保存隐藏面板配置失败', e);
}
}
// 更新编辑器侧边栏宽度
void updateEditorSidebarWidth(double delta) {
editorSidebarWidth = (editorSidebarWidth + delta).clamp(
minEditorSidebarWidth,
maxEditorSidebarWidth,
);
_notifyLayoutChange(); // 使用布局专用的通知方法
}
// 更新聊天侧边栏宽度
void updateChatSidebarWidth(double delta) {
chatSidebarWidth = (chatSidebarWidth - delta).clamp(
minChatSidebarWidth,
maxChatSidebarWidth,
);
_notifyLayoutChange(); // 修复添加missing的notifyListeners调用
}
// 更新指定面板宽度
void updatePanelWidth(String panelId, double delta) {
if (panelWidths.containsKey(panelId)) {
panelWidths[panelId] = (panelWidths[panelId]! - delta).clamp(
minPanelWidth,
maxPanelWidth,
);
_notifyLayoutChange(); // 使用布局专用的通知方法
}
}
// 切换编辑器侧边栏可见性
void toggleEditorSidebar() {
isEditorSidebarVisible = !isEditorSidebarVisible;
_notifyLayoutChange(); // 使用布局专用的通知方法
}
// 抽屉模式切换:当宽度小于阈值时展开到最大,当宽度大于等于阈值时收起到抽屉阈值
void toggleEditorSidebarCompactMode() {
const double drawerThreshold = 260.0;
if (editorSidebarWidth < drawerThreshold) {
expandEditorSidebarToMax();
} else {
collapseEditorSidebarToDrawer();
}
}
// 收起到抽屉通过设置较小宽度触发精简抽屉UI
void collapseEditorSidebarToDrawer() {
editorSidebarWidth = minEditorSidebarWidth; // e.g. 220会触发 < 260 的精简抽屉
_notifyLayoutChange();
saveEditorSidebarWidth();
}
// 展开到最大宽度
void expandEditorSidebarToMax() {
editorSidebarWidth = maxEditorSidebarWidth; // e.g. 400
_notifyLayoutChange();
saveEditorSidebarWidth();
}
// 显示编辑器侧边栏(幂等)
void showEditorSidebar() {
if (!isEditorSidebarVisible) {
isEditorSidebarVisible = true;
_notifyLayoutChange();
}
}
// 隐藏编辑器侧边栏(幂等)
void hideEditorSidebar() {
if (isEditorSidebarVisible) {
isEditorSidebarVisible = false;
_notifyLayoutChange();
}
}
// 切换AI聊天侧边栏可见性
void toggleAIChatSidebar() {
// 在多面板模式下
if (visiblePanels.contains(aiChatPanel)) {
// 如果已经可见,则移除
visiblePanels.remove(aiChatPanel);
isAIChatSidebarVisible = false;
} else {
// 如果不可见,则添加
visiblePanels.add(aiChatPanel);
isAIChatSidebarVisible = true;
}
saveVisiblePanels();
_notifyLayoutChange(); // 使用布局专用的通知方法
}
// 切换AI场景生成面板可见性
void toggleAISceneGenerationPanel() {
// 在多面板模式下
if (visiblePanels.contains(aiScenePanel)) {
// 如果已经可见,则移除
visiblePanels.remove(aiScenePanel);
isAISceneGenerationPanelVisible = false;
} else {
// 如果不可见,则添加
visiblePanels.add(aiScenePanel);
isAISceneGenerationPanelVisible = true;
}
saveVisiblePanels();
_notifyLayoutChange(); // 使用布局专用的通知方法
}
// 切换AI摘要面板可见性
void toggleAISummaryPanel() {
// 在多面板模式下
if (visiblePanels.contains(aiSummaryPanel)) {
// 如果已经可见,则移除
visiblePanels.remove(aiSummaryPanel);
isAISummaryPanelVisible = false;
} else {
// 如果不可见,则添加
visiblePanels.add(aiSummaryPanel);
isAISummaryPanelVisible = true;
}
saveVisiblePanels();
_notifyLayoutChange(); // 使用布局专用的通知方法
}
// 新增切换AI自动续写面板可见性
void toggleAIContinueWritingPanel() {
if (visiblePanels.contains(aiContinueWritingPanel)) {
visiblePanels.remove(aiContinueWritingPanel);
isAIContinueWritingPanelVisible = false;
} else {
visiblePanels.add(aiContinueWritingPanel);
isAIContinueWritingPanelVisible = true;
}
saveVisiblePanels();
_notifyLayoutChange(); // 使用布局专用的通知方法
}
// 切换设置面板可见性
void toggleSettingsPanel() {
isSettingsPanelVisible = !isSettingsPanelVisible;
if (isSettingsPanelVisible) {
// 设置面板是全屏遮罩,不影响其他面板的显示
}
_notifyLayoutChange(); // 使用布局专用的通知方法
}
// 切换小说设置视图可见性
void toggleNovelSettings() {
isNovelSettingsVisible = !isNovelSettingsVisible;
if (isNovelSettingsVisible) {
// 小说设置视图会替换主编辑区域,不影响侧边面板
}
_notifyLayoutChange(); // 使用布局专用的通知方法
}
// 获取面板是否为最后一个
bool isLastPanel(String panelId) {
return visiblePanels.length == 1 && visiblePanels.contains(panelId);
}
// 重新排序面板
void reorderPanels(int oldIndex, int newIndex) {
if (oldIndex < newIndex) {
newIndex -= 1;
}
final item = visiblePanels.removeAt(oldIndex);
visiblePanels.insert(newIndex, item);
saveVisiblePanels();
_notifyLayoutChange(); // 使用布局专用的通知方法
}
void toggleAISettingGenerationPanel() {
if (visiblePanels.contains(aiSettingGenerationPanel)) {
visiblePanels.remove(aiSettingGenerationPanel);
isAISettingGenerationPanelVisible = false;
} else {
visiblePanels.add(aiSettingGenerationPanel);
isAISettingGenerationPanelVisible = true;
}
saveVisiblePanels();
_notifyLayoutChange(); // 使用布局专用的通知方法
}
// 切换提示词视图可见性
void togglePromptView() {
isPromptViewVisible = !isPromptViewVisible;
if (isPromptViewVisible) {
// 提示词视图是全屏替换,不影响其他面板的显示
}
_notifyLayoutChange(); // 使用布局专用的通知方法
}
// 🚀 新增:沉浸模式状态管理
bool isImmersiveModeEnabled = false;
// 🚀 新增:切换沉浸模式
void toggleImmersiveMode() {
isImmersiveModeEnabled = !isImmersiveModeEnabled;
AppLogger.i('EditorLayoutManager', '切换沉浸模式: $isImmersiveModeEnabled');
_notifyLayoutChange();
}
// 🚀 新增:启用沉浸模式
void enableImmersiveMode() {
if (!isImmersiveModeEnabled) {
isImmersiveModeEnabled = true;
AppLogger.i('EditorLayoutManager', '启用沉浸模式');
_notifyLayoutChange();
}
}
// 🚀 新增:禁用沉浸模式
void disableImmersiveMode() {
if (isImmersiveModeEnabled) {
isImmersiveModeEnabled = false;
AppLogger.i('EditorLayoutManager', '禁用沉浸模式');
_notifyLayoutChange();
}
}
/// 隐藏所有AI面板
void hideAllAIPanels() {
if (visiblePanels.isNotEmpty) {
// 保存当前配置
_lastHiddenPanelsConfig = List<String>.from(visiblePanels);
_saveLastHiddenPanelsConfig();
// 隐藏所有面板
visiblePanels.clear();
isAIChatSidebarVisible = false;
isAISummaryPanelVisible = false;
isAISceneGenerationPanelVisible = false;
isAIContinueWritingPanelVisible = false;
isAISettingGenerationPanelVisible = false;
saveVisiblePanels();
_notifyLayoutChange();
}
}
/// 恢复隐藏前的AI面板配置
void restoreHiddenAIPanels() {
if (_lastHiddenPanelsConfig.isNotEmpty) {
// 恢复面板配置
visiblePanels.clear();
visiblePanels.addAll(_lastHiddenPanelsConfig);
// 更新各面板的可见性状态
isAIChatSidebarVisible = visiblePanels.contains(aiChatPanel);
isAISummaryPanelVisible = visiblePanels.contains(aiSummaryPanel);
isAISceneGenerationPanelVisible = visiblePanels.contains(aiScenePanel);
isAIContinueWritingPanelVisible = visiblePanels.contains(aiContinueWritingPanel);
isAISettingGenerationPanelVisible = visiblePanels.contains(aiSettingGenerationPanel);
saveVisiblePanels();
_notifyLayoutChange();
} else {
// 如果没有保存的配置显示默认的AI聊天面板
toggleAIChatSidebar();
}
}
// 显示AI摘要面板
void showAISummaryPanel() {
if (!visiblePanels.contains(aiSummaryPanel)) {
visiblePanels.add(aiSummaryPanel);
isAISummaryPanelVisible = true;
saveVisiblePanels();
_notifyLayoutChange();
}
}
@override
void dispose() {
_isDisposed = true;
super.dispose();
}
}

View File

@@ -0,0 +1,319 @@
import 'package:ainoval/blocs/editor/editor_bloc.dart' as editor_bloc;
import 'package:ainoval/models/novel_structure.dart' as novel_models;
import 'package:ainoval/utils/logger.dart';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
/// 编辑器状态管理器
/// 负责管理编辑器的状态,如字数统计、控制器检查等
class EditorStateManager {
EditorStateManager();
// 控制器检查节流相关变量
DateTime? _lastControllerCheckTime;
static const Duration _controllerCheckInterval = Duration(milliseconds: 500);
static const Duration _controllerLongCheckInterval = Duration(seconds: 5);
editor_bloc.EditorLoaded? _lastEditorState;
// 字数统计缓存
int _cachedWordCount = 0;
String? _wordCountCacheKey;
final Map<String, int> _memoryWordCountCache = {};
// 🔧 新增:模型验证状态跟踪,防止模型操作影响编辑器状态
bool _isModelOperationInProgress = false;
DateTime? _lastModelOperationTime;
static const Duration _modelOperationCooldown = Duration(seconds: 5);
// 🔧 新增:设置模型操作状态
void setModelOperationInProgress(bool inProgress) {
_isModelOperationInProgress = inProgress;
if (inProgress) {
_lastModelOperationTime = DateTime.now();
AppLogger.i('EditorStateManager', '模型操作开始,暂停控制器检查');
} else {
AppLogger.i('EditorStateManager', '模型操作结束');
}
}
// 🔧 新增:检查是否在模型操作冷却期
bool get _isInModelOperationCooldown {
if (_lastModelOperationTime == null) return false;
final now = DateTime.now();
final inCooldown = now.difference(_lastModelOperationTime!) < _modelOperationCooldown;
if (inCooldown) {
AppLogger.d('EditorStateManager', '模型操作冷却期中,跳过控制器检查');
}
return inCooldown;
}
// 清除内存缓存
void clearMemoryCache() {
_memoryWordCountCache.clear();
}
// 计算总字数
int calculateTotalWordCount(novel_models.Novel novel) {
// 生成缓存键:使用更新时间和场景总数作为缓存键
final totalSceneCount = novel.acts.fold(0, (sum, act) =>
sum + act.chapters.fold(0, (sum, chapter) =>
sum + chapter.scenes.length));
final updatedAtMs = novel.updatedAt.millisecondsSinceEpoch ?? 0;
final cacheKey = '${novel.id}_${updatedAtMs}_$totalSceneCount';
// 首先检查内存缓存,这是最快的检查方式
if (_memoryWordCountCache.containsKey(cacheKey)) {
// 完全跳过日志记录以提高性能
return _memoryWordCountCache[cacheKey]!;
}
// 如果持久化缓存有效,直接返回缓存的字数
if (cacheKey == _wordCountCacheKey && _cachedWordCount > 0) {
// 同时更新内存缓存
_memoryWordCountCache[cacheKey] = _cachedWordCount;
return _cachedWordCount;
}
// 检查是否在滚动过程中 - 如果在滚动使用旧缓存或返回0而不是计算
final now = DateTime.now();
if (_lastScrollHandleTime != null &&
now.difference(_lastScrollHandleTime!) < const Duration(seconds: 2)) {
// 在滚动过程中如果有缓存直接用没有就返回0避免计算
if (_cachedWordCount > 0) {
AppLogger.d('EditorStateManager', '滚动中使用缓存字数: $_cachedWordCount');
// 同时更新内存缓存
_memoryWordCountCache[cacheKey] = _cachedWordCount;
return _cachedWordCount;
} else {
AppLogger.d('EditorStateManager', '滚动中跳过字数计算');
return 0; // 返回0避免计算
}
}
// 正常情况下,记录字数计算原因
AppLogger.i('EditorStateManager', '字数统计缓存无效,重新计算。新缓存键: $cacheKey,旧缓存键: ${_wordCountCacheKey ?? ""}');
// 计算总字数(不再重复计算每个场景的字数)
int totalWordCount = 0;
for (final act in novel.acts) {
for (final chapter in act.chapters) {
for (final scene in chapter.scenes) {
// 直接使用存储的字数,不重新计算
totalWordCount += scene.wordCount;
}
}
}
// 更新缓存,并减少日志输出
_wordCountCacheKey = cacheKey;
_cachedWordCount = totalWordCount;
// 同时更新内存缓存
_memoryWordCountCache[cacheKey] = totalWordCount;
AppLogger.i('EditorStateManager', '小说总字数计算结果: $totalWordCount (Acts: ${novel.acts.length}, 更新缓存键: $cacheKey)');
return totalWordCount;
}
// 滚动处理节流
DateTime? _lastScrollHandleTime;
// 检查是否应该重建Quill控制器
bool shouldCheckControllers(editor_bloc.EditorLoaded state, {bool isLayoutOnlyChange = false}) {
if (_isModelOperationInProgress || _isInModelOperationCooldown) {
return false;
}
// 如果是纯布局变化,跳过控制器检查
if (isLayoutOnlyChange) {
if (kDebugMode) {
AppLogger.d('EditorStateManager', '跳过控制器检查 - 原因: 纯布局变化');
}
return false;
}
if (state.lastUpdateSilent) {
return false;
}
// 如果状态对象引用变化,表示小说数据结构可能发生变化,需要检查
final bool stateChanged = _lastEditorState != state;
final now = DateTime.now();
// 检查是否刚完成加载且内容有变化 (最重要的条件)
bool justFinishedLoadingWithChanges = false;
bool contentChanged = false; // Calculate contentChanged regardless of other checks
if (stateChanged && _lastEditorState != null) {
// 检查小说结构是否有实质变化主要比较acts和scenes的数量
final oldNovel = _lastEditorState!.novel;
final newNovel = state.novel;
// 🔧 修复:更严格的内容变化检查,避免将非内容变化误认为内容变化
// 只有在小说结构本身发生变化时才认为是内容变化
// 首先检查小说基本信息是否变化(排除时间戳)
if (oldNovel.id != newNovel.id ||
oldNovel.title != newNovel.title) {
contentChanged = true;
AppLogger.i('EditorStateManager', '检测到小说基本信息变化');
}
// 检查act数量是否变化
else if (oldNovel.acts.length != newNovel.acts.length) {
contentChanged = true;
AppLogger.i('EditorStateManager', '检测到Act数量变化: ${oldNovel.acts.length} -> ${newNovel.acts.length}');
}
else {
// 检查章节和场景数量是否变化
bool structureChanged = false;
for (int i = 0; i < oldNovel.acts.length && i < newNovel.acts.length; i++) {
final oldAct = oldNovel.acts[i];
final newAct = newNovel.acts[i];
// 检查Act基本信息
if (oldAct.id != newAct.id || oldAct.title != newAct.title) {
structureChanged = true;
AppLogger.i('EditorStateManager', '检测到Act[$i]基本信息变化');
break;
}
// 检查章节数量
if (oldAct.chapters.length != newAct.chapters.length) {
structureChanged = true;
AppLogger.i('EditorStateManager', '检测到Act[$i]章节数量变化: ${oldAct.chapters.length} -> ${newAct.chapters.length}');
break;
}
// 检查每个章节的场景数量
for (int j = 0; j < oldAct.chapters.length && j < newAct.chapters.length; j++) {
final oldChapter = oldAct.chapters[j];
final newChapter = newAct.chapters[j];
// 检查Chapter基本信息
if (oldChapter.id != newChapter.id || oldChapter.title != newChapter.title) {
structureChanged = true;
AppLogger.i('EditorStateManager', '检测到Chapter[$i][$j]基本信息变化');
break;
}
// 检查场景数量
if (oldChapter.scenes.length != newChapter.scenes.length) {
structureChanged = true;
AppLogger.i('EditorStateManager', '检测到Chapter[$i][$j]场景数量变化: ${oldChapter.scenes.length} -> ${newChapter.scenes.length}');
break;
}
// 检查场景ID是否变化新增/删除场景)
final oldSceneIds = oldChapter.scenes.map((s) => s.id).toSet();
final newSceneIds = newChapter.scenes.map((s) => s.id).toSet();
if (oldSceneIds.length != newSceneIds.length ||
!oldSceneIds.containsAll(newSceneIds) ||
!newSceneIds.containsAll(oldSceneIds)) {
structureChanged = true;
AppLogger.i('EditorStateManager', '检测到Chapter[$i][$j]场景ID变化');
break;
}
}
if (structureChanged) break;
}
contentChanged = structureChanged;
}
// *** Check if loading just finished and content actually changed ***
if (_lastEditorState!.isLoading && !state.isLoading && contentChanged) {
justFinishedLoadingWithChanges = true;
// 仅在调试模式下记录日志
if (kDebugMode) {
AppLogger.i('EditorStateManager', '检测到加载完成且内容有变化,强制检查控制器。');
}
}
}
// *** Bypass throttle if loading just finished with changes ***
if (justFinishedLoadingWithChanges) {
_lastControllerCheckTime = now;
_lastEditorState = state; // Update state reference
// 仅在调试模式下记录日志
if (kDebugMode) {
AppLogger.i('EditorStateManager', '触发控制器检查 - 原因: 加载完成');
}
return true;
}
// 🔧 修复增加节流时间到15秒减少不必要的控制器检查
// 极端节流如果距离上次检查时间不足15秒且不是刚加载完成绝对不检查
if (_lastControllerCheckTime != null &&
now.difference(_lastControllerCheckTime!) < const Duration(seconds: 15)) {
// 记录日志:禁止频繁检查 (仅在状态变化且调试模式下记录,避免日志刷屏)
if (stateChanged && kDebugMode) {
AppLogger.d('EditorStateManager', '节流: 禁止15秒内重复检查控制器');
}
// 更新状态引用,即使被节流也要更新,以便下次比较
_lastEditorState = state;
return false;
}
// 检查活动元素是否变化
bool activeElementsChanged = false;
if (stateChanged && _lastEditorState != null) {
activeElementsChanged =
_lastEditorState!.activeActId != state.activeActId ||
_lastEditorState!.activeChapterId != state.activeChapterId ||
_lastEditorState!.activeSceneId != state.activeSceneId;
}
// 🔧 修复:只有在以下严格条件下才重建控制器
// 1. 首次加载_lastControllerCheckTime为null
// 2. 确实的内容结构变化(添加/删除场景或章节)
// 3. 活动元素变化
// 4. 长时间间隔超时 (15秒)
final bool timeIntervalExceeded = _lastControllerCheckTime == null ||
now.difference(_lastControllerCheckTime!) > const Duration(seconds: 15);
final bool needsCheck = _lastControllerCheckTime == null ||
contentChanged ||
activeElementsChanged ||
timeIntervalExceeded;
// 更新状态引用,用于下次比较
_lastEditorState = state;
// 如果需要检查,更新最后检查时间
if (needsCheck) {
_lastControllerCheckTime = now;
// 仅在调试模式下记录日志
if (kDebugMode) {
String reason;
if (contentChanged) {
reason = '内容结构变化';
} else if (activeElementsChanged) {
reason = '活动元素变化';
} else if (timeIntervalExceeded) {
reason = '时间间隔超过(15秒)';
} else {
reason = '首次加载';
}
AppLogger.i('EditorStateManager', '触发控制器检查 - 原因: $reason');
}
return true;
}
return false;
}
// 内容更新通知器
final ValueNotifier<String> contentUpdateNotifier = ValueNotifier<String>('');
// 通知内容更新
void notifyContentUpdate(String reason) {
AppLogger.i('EditorStateManager', '通知内容更新: $reason');
contentUpdateNotifier.value = '${DateTime.now().millisecondsSinceEpoch}_$reason';
}
}

View File

@@ -0,0 +1,784 @@
/**
* 文档解析工具类
*
* 用于解析和处理文本内容将其转换为可编辑的Quill文档格式。
* 提供两种解析方法安全解析在UI线程使用和隔离解析在计算隔离中使用
*/
import 'dart:async';
import 'dart:convert';
import 'dart:isolate';
import 'package:flutter/foundation.dart';
import 'package:flutter_quill/flutter_quill.dart';
import 'package:ainoval/utils/logger.dart';
import 'package:ainoval/utils/quill_helper.dart';
/// 优化的文档解析器
///
/// 包含以下优化特性:
/// 1. LRU缓存机制 - 避免重复解析
/// 2. 解析队列和优先级控制 - 减少并发竞争
/// 3. 批量解析 - 提高吞吐量
/// 4. 智能预解析 - 提前准备常用内容
/// 5. 解析结果压缩 - 减少内存占用
class DocumentParser {
static final DocumentParser _instance = DocumentParser._internal();
factory DocumentParser() => _instance;
DocumentParser._internal();
// LRU缓存配置
static const int _maxCacheSize = 50; // 从50
static const int _maxCacheMemoryMB = 200; // 从100MB增加到200MB
// 解析队列配置
static const int _maxConcurrentParsing = 5; // 从3增加到5个并发解析
static const Duration _parseTimeout = Duration(seconds: 8); // 从5秒增加到8秒
// 缓存存储
final Map<String, _CachedDocument> _documentCache = {};
final List<String> _cacheAccessOrder = []; // LRU访问顺序
// 解析队列
final List<_ParseRequest> _parseQueue = [];
int _currentParsingCount = 0;
// 统计信息
int _cacheHits = 0;
int _cacheMisses = 0;
int _totalParseTime = 0;
int _totalParseCount = 0;
/// 解析文档(带缓存和优先级)
static Future<Document> parseDocumentOptimized(
String content, {
int priority = 5, // 优先级 1-1010最高
String? cacheKey,
bool useCache = true,
}) async {
return DocumentParser()._parseWithCache(
content,
priority: priority,
cacheKey: cacheKey,
useCache: useCache,
);
}
/// 原始解析方法(保持兼容性)
static Future<Document> parseDocumentInIsolate(String content) async {
return DocumentParser()._parseWithCache(content, priority: 5);
}
/// 安全解析文档用于UI线程兼容性方法
static Future<Document> parseDocumentSafely(String content) async {
return DocumentParser()._parseWithCache(content, priority: 5, useCache: true);
}
/// 同步解析文档(用于控制器初始化)
///
/// 这个方法用于需要立即返回Document的场景如QuillController初始化
/// 使用简化解析逻辑,避免异步操作
static Document parseDocumentSync(String content) {
return DocumentParser()._parseDocumentSimple(content);
}
/// 批量解析文档
static Future<List<Document>> parseBatchDocuments(
List<String> contents, {
int priority = 5,
List<String>? cacheKeys,
}) async {
return DocumentParser()._parseBatch(contents, priority: priority, cacheKeys: cacheKeys);
}
/// 预加载文档到缓存(增强版)
static Future<void> preloadDocuments(
List<String> contents, {
List<String>? cacheKeys,
int maxPreloadConcurrency = 2, // 限制预加载并发数,避免影响正常解析
}) async {
final parser = DocumentParser();
final futures = <Future<void>>[];
for (int i = 0; i < contents.length; i++) {
final content = contents[i];
final cacheKey = cacheKeys != null && i < cacheKeys.length
? cacheKeys[i]
: parser._generateCacheKey(content);
// 检查是否已缓存
if (!parser._documentCache.containsKey(cacheKey)) {
// 创建预加载Future
final preloadFuture = parser._parseWithCache(
content,
priority: 1, // 最低优先级后台解析
cacheKey: cacheKey,
useCache: true
).then((_) {
AppLogger.d('DocumentParser', '预加载完成: $cacheKey');
}).catchError((e) {
AppLogger.w('DocumentParser', '预加载失败: $cacheKey, $e');
});
futures.add(preloadFuture);
// 控制并发数量每批处理maxPreloadConcurrency个
if (futures.length >= maxPreloadConcurrency) {
await Future.wait(futures);
futures.clear();
// 短暂延迟,避免阻塞主线程
await Future.delayed(const Duration(milliseconds: 10));
}
}
}
// 处理剩余的预加载任务
if (futures.isNotEmpty) {
await Future.wait(futures);
}
AppLogger.i('DocumentParser', '批量预加载完成,处理了${contents.length}个文档');
}
/// 清理缓存
static void clearCache() {
final parser = DocumentParser();
parser._documentCache.clear();
parser._cacheAccessOrder.clear();
parser._cacheHits = 0;
parser._cacheMisses = 0;
parser._totalParseTime = 0;
parser._totalParseCount = 0;
AppLogger.i('DocumentParser', '缓存已清理');
}
/// 获取缓存统计信息
static Map<String, dynamic> getCacheStats() {
final parser = DocumentParser();
final cacheSize = parser._documentCache.length;
final memoryUsageMB = parser._calculateCacheMemoryUsage() / 1024 / 1024;
final hitRate = parser._cacheHits + parser._cacheMisses > 0
? (parser._cacheHits / (parser._cacheHits + parser._cacheMisses) * 100).toStringAsFixed(1) + '%'
: '0.0%';
final avgParseTimeMs = parser._totalParseCount > 0
? (parser._totalParseTime / parser._totalParseCount).toStringAsFixed(1)
: '0.0';
return {
'cacheSize': cacheSize,
'memoryUsageMB': memoryUsageMB.toStringAsFixed(2),
'hitRate': hitRate,
'avgParseTimeMs': avgParseTimeMs,
'queueLength': parser._parseQueue.length,
'currentParsing': parser._currentParsingCount,
'totalHits': parser._cacheHits,
'totalMisses': parser._cacheMisses,
'totalParseCount': parser._totalParseCount,
'maxCacheSize': _maxCacheSize,
'maxMemoryMB': _maxCacheMemoryMB,
};
}
/// 核心解析方法(带缓存)
Future<Document> _parseWithCache(
String content, {
int priority = 5,
String? cacheKey,
bool useCache = true,
}) async {
final key = cacheKey ?? _generateCacheKey(content);
// 🚀 快速路径:空内容直接返回
if (content.isEmpty) {
AppLogger.d('DocumentParser', '快速路径:空内容 $key');
return Document.fromJson([{'insert': '\n'}]);
}
// 尝试从缓存获取
if (useCache && _documentCache.containsKey(key)) {
_updateCacheAccess(key);
_cacheHits++;
AppLogger.d('DocumentParser', '缓存命中: $key');
return _documentCache[key]!.document;
}
_cacheMisses++;
// 🚀 快速路径:内容过大时使用简化解析
if (content.length > 100000) { // 大于100KB使用简化解析
AppLogger.w('DocumentParser', '内容过大($content.length字符),使用简化解析: $key');
try {
final simpleDocument = _parseDocumentSimple(content);
if (useCache) {
_storeInCache(key, simpleDocument, content.length);
}
return simpleDocument;
} catch (e) {
AppLogger.e('DocumentParser', '简化解析失败: $key', e);
return Document.fromJson([{'insert': '内容过大,解析失败\n'}]);
}
}
// 🚀 快速路径:如果是纯文本且不太长,直接解析
if (content.length < 1000 && !content.trim().startsWith('[') && !content.trim().startsWith('{')) {
AppLogger.d('DocumentParser', '快速路径:纯文本解析 $key');
final quickDocument = Document.fromJson([{'insert': '$content\n'}]);
if (useCache) {
_storeInCache(key, quickDocument, content.length);
}
return quickDocument;
}
// 创建解析请求
final completer = Completer<Document>();
final request = _ParseRequest(
content: content,
cacheKey: key,
priority: priority,
completer: completer,
useCache: useCache,
);
_parseQueue.add(request);
_parseQueue.sort((a, b) => b.priority.compareTo(a.priority)); // 按优先级排序
_processParseQueue();
return completer.future;
}
/// 批量解析
Future<List<Document>> _parseBatch(
List<String> contents, {
int priority = 5,
List<String>? cacheKeys,
}) async {
final futures = <Future<Document>>[];
for (int i = 0; i < contents.length; i++) {
final cacheKey = cacheKeys != null && i < cacheKeys.length ? cacheKeys[i] : null;
futures.add(_parseWithCache(contents[i], priority: priority, cacheKey: cacheKey));
}
return Future.wait(futures);
}
/// 处理解析队列
void _processParseQueue() {
while (_parseQueue.isNotEmpty && _currentParsingCount < _maxConcurrentParsing) {
final request = _parseQueue.removeAt(0);
_currentParsingCount++;
_executeParseRequest(request);
}
}
/// 执行解析请求
void _executeParseRequest(_ParseRequest request) async {
final stopwatch = Stopwatch()..start();
try {
// 🚀 预估解析时间,如果内容过大直接使用简化解析
if (request.content.length > 50000) {
AppLogger.w('DocumentParser', '内容较大(${request.content.length}字符),使用简化解析: ${request.cacheKey}');
final document = _parseDocumentSimple(request.content);
stopwatch.stop();
final parseTime = stopwatch.elapsedMilliseconds;
_totalParseTime += parseTime;
_totalParseCount++;
if (request.useCache) {
_storeInCache(request.cacheKey, document, request.content.length);
}
AppLogger.d('DocumentParser', '简化解析完成: ${request.cacheKey}, 耗时: ${parseTime}ms');
request.completer.complete(document);
return;
}
// 正常解析流程
final document = await _parseInIsolateWithTimeout(request.content);
stopwatch.stop();
final parseTime = stopwatch.elapsedMilliseconds;
_totalParseTime += parseTime;
_totalParseCount++;
// 🚨 性能监控:如果解析时间过长,记录警告
if (parseTime > 1000) {
AppLogger.w('DocumentParser', '⚠️ 解析时间过长: ${request.cacheKey}, 耗时: ${parseTime}ms, 内容长度: ${request.content.length}');
}
// 存储到缓存
if (request.useCache) {
_storeInCache(request.cacheKey, document, request.content.length);
}
AppLogger.d('DocumentParser', '解析完成: ${request.cacheKey}, 耗时: ${parseTime}ms');
request.completer.complete(document);
} catch (e, stackTrace) {
stopwatch.stop();
AppLogger.e('DocumentParser', '解析失败: ${request.cacheKey}', e, stackTrace);
// 🚀 解析失败时使用简化解析作为备用方案
try {
AppLogger.i('DocumentParser', '尝试简化解析备用方案: ${request.cacheKey}');
final fallbackDocument = _parseDocumentSimple(request.content);
if (request.useCache) {
_storeInCache(request.cacheKey, fallbackDocument, request.content.length);
}
request.completer.complete(fallbackDocument);
AppLogger.i('DocumentParser', '简化解析备用方案成功: ${request.cacheKey}');
} catch (fallbackError) {
// 最后的备用方案:创建错误文档
final errorDocument = Document.fromJson([
{'insert': '⚠️ 文档解析失败\n内容加载出现问题,请刷新重试。\n\n原始内容预览:\n'},
{'insert': request.content.length > 200 ? '${request.content.substring(0, 200)}...\n' : '${request.content}\n'},
]);
request.completer.complete(errorDocument);
AppLogger.e('DocumentParser', '所有解析方案都失败: ${request.cacheKey}', fallbackError);
}
} finally {
_currentParsingCount--;
_processParseQueue(); // 处理队列中的下一个请求
}
}
/// 在隔离中解析(带超时)
Future<Document> _parseInIsolateWithTimeout(String content) async {
// 🚀 根据内容大小动态调整超时时间
Duration timeout;
if (content.length < 1000) {
timeout = const Duration(seconds: 2); // 小内容2秒超时
} else if (content.length < 10000) {
timeout = const Duration(seconds: 4); // 中等内容4秒超时
} else {
timeout = const Duration(seconds: 6); // 大内容6秒超时不再使用8秒
}
return compute(_isolateParseFunction, content).timeout(
timeout,
onTimeout: () {
AppLogger.w('DocumentParser', '解析超时(${timeout.inSeconds}秒),使用简化解析,内容长度: ${content.length}');
return _parseDocumentSimple(content);
},
);
}
/// 生成缓存键
String _generateCacheKey(String content) {
// 使用内容长度和特征字符生成更稳定的缓存键
final length = content.length;
if (length == 0) return 'doc_empty_0';
// 采样关键字符位置,避免完整内容哈希
final sample1 = content.codeUnitAt(0);
final sample2 = length > 10 ? content.codeUnitAt(length ~/ 4) : 0;
final sample3 = length > 20 ? content.codeUnitAt(length ~/ 2) : 0;
final sample4 = length > 30 ? content.codeUnitAt(length * 3 ~/ 4) : 0;
final sample5 = content.codeUnitAt(length - 1);
// 使用字符码点和生成稳定哈希
int stableHash = length;
stableHash = (stableHash * 31 + sample1) & 0x7FFFFFFF;
stableHash = (stableHash * 31 + sample2) & 0x7FFFFFFF;
stableHash = (stableHash * 31 + sample3) & 0x7FFFFFFF;
stableHash = (stableHash * 31 + sample4) & 0x7FFFFFFF;
stableHash = (stableHash * 31 + sample5) & 0x7FFFFFFF;
return 'doc_${length}_${stableHash}';
}
/// 存储到缓存
void _storeInCache(String key, Document document, int contentSize) {
// 检查缓存大小限制
_enforceCacheLimits();
final cachedDoc = _CachedDocument(
document: document,
contentSize: contentSize,
accessTime: DateTime.now(),
);
_documentCache[key] = cachedDoc;
_updateCacheAccess(key);
}
/// 更新缓存访问顺序
void _updateCacheAccess(String key) {
_cacheAccessOrder.remove(key);
_cacheAccessOrder.add(key); // 移到最后(最近访问)
if (_documentCache.containsKey(key)) {
_documentCache[key]!.accessTime = DateTime.now();
}
}
/// 强制执行缓存限制
void _enforceCacheLimits() {
// 检查数量限制
while (_documentCache.length >= _maxCacheSize && _cacheAccessOrder.isNotEmpty) {
final oldestKey = _cacheAccessOrder.removeAt(0);
_documentCache.remove(oldestKey);
}
// 检查内存限制
while (_calculateCacheMemoryUsage() > _maxCacheMemoryMB * 1024 * 1024 && _cacheAccessOrder.isNotEmpty) {
final oldestKey = _cacheAccessOrder.removeAt(0);
_documentCache.remove(oldestKey);
}
}
/// 计算缓存内存使用量
int _calculateCacheMemoryUsage() {
return _documentCache.values.fold(0, (sum, doc) => sum + doc.contentSize);
}
/// 简化解析方法 - 用于大内容或解析失败的备用方案
Document _parseDocumentSimple(String content) {
try {
// 🚀 快速检查:如果是空内容
if (content.trim().isEmpty) {
return Document.fromJson([{'insert': '\n'}]);
}
// 🚀 快速检查:如果明显是纯文本
final trimmedContent = content.trim();
if (!trimmedContent.startsWith('[') && !trimmedContent.startsWith('{')) {
// 处理纯文本,保留换行
final lines = content.split('\n');
final ops = <Map<String, dynamic>>[];
for (int i = 0; i < lines.length; i++) {
if (lines[i].isNotEmpty) {
ops.add({'insert': lines[i]});
}
if (i < lines.length - 1 || content.endsWith('\n')) {
ops.add({'insert': '\n'});
}
}
if (ops.isEmpty) {
ops.add({'insert': '\n'});
}
return Document.fromJson(ops);
}
// 🚀 尝试快速JSON解析
try {
final jsonData = jsonDecode(content);
if (jsonData is List) {
// 验证是否是有效的Quill操作数组
bool isValidOps = true;
bool hasStyleAttributes = false;
for (final op in jsonData) {
if (op is! Map || !op.containsKey('insert')) {
isValidOps = false;
break;
}
// 检查是否有样式属性
if (op is Map && op.containsKey('attributes')) {
hasStyleAttributes = true;
final attributes = op['attributes'] as Map<String, dynamic>?;
if (attributes != null) {
AppLogger.d('DocumentParser/_parseDocumentSimple',
'🎨 发现样式属性: ${attributes.keys.join(', ')}');
if (attributes.containsKey('color')) {
AppLogger.d('DocumentParser/_parseDocumentSimple',
'🎨 文字颜色: ${attributes['color']}');
}
if (attributes.containsKey('background')) {
AppLogger.d('DocumentParser/_parseDocumentSimple',
'🎨 背景颜色: ${attributes['background']}');
}
}
}
}
if (hasStyleAttributes) {
AppLogger.i('DocumentParser/_parseDocumentSimple',
'🎨 简化解析包含样式属性的内容,操作数量: ${jsonData.length}');
}
if (isValidOps) {
return Document.fromJson(jsonData);
}
} else if (jsonData is Map && jsonData.containsKey('ops')) {
final ops = jsonData['ops'];
if (ops is List) {
// 检查ops中的样式属性
bool hasStyleAttributes = false;
for (final op in ops) {
if (op is Map && op.containsKey('attributes')) {
hasStyleAttributes = true;
final attributes = op['attributes'] as Map<String, dynamic>?;
if (attributes != null) {
AppLogger.d('DocumentParser/_parseDocumentSimple',
'🎨 ops中发现样式属性: ${attributes.keys.join(', ')}');
}
}
}
if (hasStyleAttributes) {
AppLogger.i('DocumentParser/_parseDocumentSimple',
'🎨 简化解析ops格式包含样式属性的内容操作数量: ${ops.length}');
}
return Document.fromJson(ops);
}
}
// 如果JSON格式不正确当作文本处理
return Document.fromJson([
{'insert': '⚠️ 内容格式异常,显示原始内容:\n'},
{'insert': content.length > 1000 ? '${content.substring(0, 1000)}...\n' : '$content\n'}
]);
} catch (jsonError) {
// JSON解析失败当作纯文本处理
AppLogger.d('DocumentParser', '简化解析JSON解析失败当作纯文本处理');
return Document.fromJson([
{'insert': content.length > 10000 ? '${content.substring(0, 10000)}...\n' : '$content\n'}
]);
}
} catch (e) {
AppLogger.w('DocumentParser', '简化解析也失败,使用最基础的文档', e);
return Document.fromJson([
{'insert': '⚠️ 内容解析失败\n'},
{'insert': '内容长度: ${content.length} 字符\n'},
{'insert': '请联系技术支持\n'}
]);
}
}
/// 优化缓存键生成 - 使用更稳定的hash算法
String _generateCacheKeyOptimized(String content) {
// 统一使用新的稳定缓存键生成方法
return _generateCacheKey(content);
}
/// 检查缓存健康状况
static Map<String, dynamic> checkCacheHealth() {
final parser = DocumentParser();
final stats = getCacheStats();
final issues = <String>[];
// 检查缓存命中率
final hitRateNum = parser._cacheHits + parser._cacheMisses > 0
? (parser._cacheHits / (parser._cacheHits + parser._cacheMisses) * 100)
: 0.0;
if (hitRateNum < 30) {
issues.add('缓存命中率过低 (${hitRateNum.toStringAsFixed(1)}%)');
}
// 检查平均解析时间
final avgParseTime = parser._totalParseCount > 0
? (parser._totalParseTime / parser._totalParseCount)
: 0.0;
if (avgParseTime > 500) {
issues.add('平均解析时间过长 (${avgParseTime.toStringAsFixed(1)}ms)');
}
// 检查队列长度
if (parser._parseQueue.length > 10) {
issues.add('解析队列过长 (${parser._parseQueue.length})');
}
return {
'isHealthy': issues.isEmpty,
'issues': issues,
'stats': stats,
'recommendations': _generateRecommendations(issues),
};
}
/// 生成优化建议
static List<String> _generateRecommendations(List<String> issues) {
final recommendations = <String>[];
if (issues.any((issue) => issue.contains('缓存命中率'))) {
recommendations.add('增加预加载范围');
recommendations.add('检查缓存键生成逻辑');
recommendations.add('考虑增加缓存大小');
}
if (issues.any((issue) => issue.contains('解析时间'))) {
recommendations.add('检查内容复杂度');
recommendations.add('考虑内容预处理');
recommendations.add('增加并发解析数量');
}
if (issues.any((issue) => issue.contains('队列'))) {
recommendations.add('减少同时触发的解析请求');
recommendations.add('提高高优先级任务处理速度');
recommendations.add('检查是否有解析死锁');
}
return recommendations;
}
/// 智能缓存预热 - 新增功能
static Future<void> warmupCache({
List<String>? priorityContents,
int warmupSize = 10,
}) async {
final parser = DocumentParser();
AppLogger.i('DocumentParser', '开始缓存预热...');
// 预热常见的文档格式
final commonFormats = [
'[{"insert":"\\n"}]', // 空文档
'[{"insert":"测试文本\\n"}]', // 简单文本
'[{"insert":"测试文本\\n","attributes":{"bold":true}}]', // 带格式文本
'简单纯文本内容', // 纯文本
'{"insert":"旧格式文档\\n"}', // 旧格式
];
// 预热优先内容
if (priorityContents != null) {
await preloadDocuments(
priorityContents.take(warmupSize).toList(),
maxPreloadConcurrency: 3,
);
}
// 预热常见格式
await preloadDocuments(
commonFormats,
cacheKeys: List.generate(commonFormats.length, (i) => 'warmup_format_$i'),
maxPreloadConcurrency: 2,
);
AppLogger.i('DocumentParser', '缓存预热完成');
}
}
/// 隔离中的解析函数
Document _isolateParseFunction(String content) {
try {
if (content.isEmpty) {
return Document.fromJson([{'insert': '\n'}]);
}
// 优化的JSON解析
if (content.trim().startsWith('[') || content.trim().startsWith('{')) {
final jsonData = jsonDecode(content);
List<Map<String, dynamic>> ops;
if (jsonData is List) {
ops = jsonData.cast<Map<String, dynamic>>();
} else if (jsonData is Map && jsonData.containsKey('ops')) {
// 处理 {"ops": [...]} 格式
ops = (jsonData['ops'] as List).cast<Map<String, dynamic>>();
} else if (jsonData is Map) {
ops = [jsonData.cast<String, dynamic>()];
} else {
// 转换为纯文本处理
return Document.fromJson([{'insert': '$content\n'}]);
}
// 🚀 新增:检查和记录样式属性
bool hasStyleAttributes = false;
for (final op in ops) {
if (op.containsKey('attributes')) {
hasStyleAttributes = true;
final attributes = op['attributes'] as Map<String, dynamic>?;
if (attributes != null) {
// 记录发现的样式属性
AppLogger.d('DocumentParser/_isolateParseFunction',
'🎨 发现样式属性: ${attributes.keys.join(', ')}');
// 特别记录颜色属性
if (attributes.containsKey('color')) {
AppLogger.d('DocumentParser/_isolateParseFunction',
'🎨 文字颜色: ${attributes['color']}');
}
if (attributes.containsKey('background')) {
AppLogger.d('DocumentParser/_isolateParseFunction',
'🎨 背景颜色: ${attributes['background']}');
}
}
}
}
if (hasStyleAttributes) {
AppLogger.i('DocumentParser/_isolateParseFunction',
'🎨 解析包含样式属性的内容,操作数量: ${ops.length}');
}
// 确保最后一个操作以换行符结尾
if (ops.isNotEmpty) {
final lastOp = ops.last;
if (lastOp.containsKey('insert')) {
final insertText = lastOp['insert'].toString();
if (!insertText.endsWith('\n')) {
// 如果最后一个insert不以换行符结尾添加一个新的换行符操作
ops.add({'insert': '\n'});
}
} else {
// 如果最后一个操作不包含insert添加换行符
ops.add({'insert': '\n'});
}
} else {
// 如果ops为空添加一个换行符
ops = [{'insert': '\n'}];
}
return Document.fromJson(ops);
}
// 处理普通文本
return Document.fromJson([{'insert': '$content\n'}]);
} catch (e) {
// 解析失败时的备用方案 - 增强错误信息
AppLogger.e('DocumentParser/_isolateParseFunction',
'解析失败,内容长度: ${content.length}, 错误: $e');
return Document.fromJson([
{'insert': '解析错误: ${e.toString()}\n'},
{'insert': content.length > 200 ? '${content.substring(0, 200)}...\n' : '$content\n'},
]);
}
}
/// 缓存的文档数据
class _CachedDocument {
final Document document;
final int contentSize;
DateTime accessTime;
_CachedDocument({
required this.document,
required this.contentSize,
required this.accessTime,
});
}
/// 解析请求
class _ParseRequest {
final String content;
final String cacheKey;
final int priority;
final Completer<Document> completer;
final bool useCache;
_ParseRequest({
required this.content,
required this.cacheKey,
required this.priority,
required this.completer,
required this.useCache,
});
}

View File

@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../blocs/chat/chat_bloc.dart';
import '../../../blocs/chat/chat_event.dart';
import '../../../blocs/chat/chat_state.dart';
/// AI聊天按钮用于在编辑器中打开AI聊天侧边栏
class AIChatButton extends StatelessWidget {
const AIChatButton({
Key? key,
required this.novelId,
this.chapterId,
required this.onPressed,
this.isActive = false,
}) : super(key: key);
final String novelId;
final String? chapterId;
final VoidCallback onPressed;
final bool isActive;
@override
Widget build(BuildContext context) {
return BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
return IconButton(
icon: Stack(
children: [
Icon(
Icons.chat_outlined,
color: isActive ? Colors.blue : Colors.black54,
),
if (state is ChatSessionActive && state.isGenerating)
Positioned(
right: 0,
bottom: 0,
child: Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
),
),
),
],
),
tooltip: '打开AI聊天',
onPressed: () {
// 如果没有活动会话,创建一个新会话
if (state is! ChatSessionActive) {
context.read<ChatBloc>().add(CreateChatSession(
title: 'New Chat',
novelId: novelId,
chapterId: chapterId,
));
}
onPressed();
},
);
},
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,382 @@
import 'package:flutter/material.dart';
import 'package:ainoval/utils/web_theme.dart';
/// AI生成工具栏
/// 在流式输出文本时显示提供Apply、Retry、Discard、Section等操作
class AIGenerationToolbar extends StatefulWidget {
const AIGenerationToolbar({
super.key,
required this.layerLink,
required this.onApply,
required this.onRetry,
required this.onDiscard,
required this.onSection,
required this.wordCount,
required this.modelName,
this.isGenerating = false,
this.onClosed,
this.showAbove = false,
this.onStop,
this.offsetAbove = -60.0,
this.offsetBelow = 30.0,
});
/// 用于定位工具栏的层链接
final LayerLink layerLink;
/// 应用生成的文本
final VoidCallback onApply;
/// 重新生成
final VoidCallback onRetry;
/// 丢弃生成的文本
final VoidCallback onDiscard;
/// 分段功能
final VoidCallback onSection;
/// 停止生成
final VoidCallback? onStop;
/// 生成文本的字数
final int wordCount;
/// 使用的模型名称
final String modelName;
/// 是否正在生成中
final bool isGenerating;
/// 工具栏关闭回调
final VoidCallback? onClosed;
/// 是否显示在上方
final bool showAbove;
/// 上方显示时的Y偏移量
final double offsetAbove;
/// 下方显示时的Y偏移量
final double offsetBelow;
@override
State<AIGenerationToolbar> createState() => _AIGenerationToolbarState();
}
class _AIGenerationToolbarState extends State<AIGenerationToolbar> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final isLight = !isDark;
return CompositedTransformFollower(
link: widget.layerLink,
offset: widget.showAbove ? Offset(0, widget.offsetAbove) : Offset(0, widget.offsetBelow),
followerAnchor: Alignment.topCenter,
targetAnchor: Alignment.topCenter,
showWhenUnlinked: false,
child: MouseRegion(
cursor: SystemMouseCursors.click,
opaque: true,
hitTestBehavior: HitTestBehavior.opaque,
child: Material(
type: MaterialType.transparency,
child: Container(
constraints: const BoxConstraints(maxWidth: 600),
child: _buildToolbarContainer(isLightTheme: isLight),
),
),
),
);
}
/// 构建工具栏容器
Widget _buildToolbarContainer({required bool isLightTheme}) {
return Container(
decoration: BoxDecoration(
// 统一使用 WebTheme 色系
color: isLightTheme ? WebTheme.black : WebTheme.getSurfaceColor(context),
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: WebTheme.getShadowColor(context, opacity: isLightTheme ? 0.3 : 0.1),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
border: Border.all(
color: WebTheme.getSecondaryBorderColor(context),
width: 1,
),
),
child: LayoutBuilder(
builder: (context, constraints) {
// 估算内容总宽度
final contentWidth = _estimateContentWidth();
// 如果空间不足,使用垂直布局
if (contentWidth > constraints.maxWidth && constraints.maxWidth > 0) {
return _buildVerticalLayout(isLightTheme);
} else {
return _buildHorizontalLayout(isLightTheme);
}
},
),
);
}
/// 构建水平布局
Widget _buildHorizontalLayout(bool isLightTheme) {
return IntrinsicWidth(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 操作按钮区域
Flexible(
child: Container(
padding: const EdgeInsets.all(2),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildActionButton(
icon: Icons.check,
label: 'Apply',
tooltip: '应用生成的文本',
onPressed: widget.isGenerating ? null : widget.onApply,
),
if (widget.isGenerating && widget.onStop != null)
_buildActionButton(
icon: Icons.stop,
label: 'Stop',
tooltip: '停止生成',
onPressed: widget.onStop,
)
else
_buildActionButton(
icon: Icons.refresh,
label: 'Retry',
tooltip: '重新生成',
onPressed: widget.isGenerating ? null : widget.onRetry,
),
_buildActionButton(
icon: Icons.close,
label: 'Discard',
tooltip: widget.isGenerating ? '停止并丢弃生成的文本' : '丢弃生成的文本',
onPressed: widget.onDiscard,
),
_buildActionButton(
icon: Icons.crop_free,
label: 'Section',
tooltip: '分段处理',
onPressed: widget.isGenerating ? null : widget.onSection,
),
],
),
),
),
),
// 分隔线
Container(
width: 1,
height: 32,
color: WebTheme.getSecondaryBorderColor(context),
),
// 信息区域
Flexible(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: _buildInfoContent(),
),
),
],
),
);
}
/// 构建垂直布局(当空间不足时)
Widget _buildVerticalLayout(bool isLightTheme) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// 操作按钮区域
Container(
padding: const EdgeInsets.all(2),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildActionButton(
icon: Icons.check,
label: 'Apply',
tooltip: '应用生成的文本',
onPressed: widget.isGenerating ? null : widget.onApply,
),
if (widget.isGenerating && widget.onStop != null)
_buildActionButton(
icon: Icons.stop,
label: 'Stop',
tooltip: '停止生成',
onPressed: widget.onStop,
)
else
_buildActionButton(
icon: Icons.refresh,
label: 'Retry',
tooltip: '重新生成',
onPressed: widget.isGenerating ? null : widget.onRetry,
),
_buildActionButton(
icon: Icons.close,
label: 'Discard',
tooltip: widget.isGenerating ? '停止并丢弃生成的文本' : '丢弃生成的文本',
onPressed: widget.onDiscard,
),
_buildActionButton(
icon: Icons.crop_free,
label: 'Section',
tooltip: '分段处理',
onPressed: widget.isGenerating ? null : widget.onSection,
),
],
),
),
),
// 分隔线
Container(
width: double.infinity,
height: 1,
color: WebTheme.getSecondaryBorderColor(context),
),
// 信息区域
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: _buildInfoContent(),
),
],
);
}
/// 构建信息内容
Widget _buildInfoContent() {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.isGenerating) ...[
const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 1.5,
valueColor: AlwaysStoppedAnimation<Color>(WebTheme.white),
),
),
const SizedBox(width: 8),
Flexible(
child: Text(
'生成中...',
style: const TextStyle(
color: WebTheme.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
],
Flexible(
child: Text(
'${widget.wordCount} Words',
style: const TextStyle(
color: WebTheme.white,
fontSize: 12,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
const Text(
', ',
style: TextStyle(
color: WebTheme.white,
fontSize: 12,
),
),
Flexible(
child: Text(
widget.modelName,
style: const TextStyle(
color: WebTheme.white,
fontSize: 12,
fontStyle: FontStyle.italic,
),
overflow: TextOverflow.ellipsis,
),
),
],
);
}
/// 估算内容总宽度
double _estimateContentWidth() {
// 操作按钮: 4个按钮 * 80px ≈ 320px
// 分隔线: 1px
// 信息区域: 约150px
// 内边距: 约30px
return 320 + 1 + 150 + 30; // ≈ 501px
}
/// 构建操作按钮
Widget _buildActionButton({
required IconData icon,
required String label,
required String tooltip,
required VoidCallback? onPressed,
}) {
final isEnabled = onPressed != null;
return Tooltip(
message: tooltip,
child: MouseRegion(
cursor: isEnabled ? SystemMouseCursors.click : SystemMouseCursors.forbidden,
opaque: true,
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(6),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 14,
color: isEnabled
? WebTheme.white
: WebTheme.white,
),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
color: isEnabled
? WebTheme.white
: WebTheme.white,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,277 @@
import 'package:flutter/material.dart';
import 'package:ainoval/utils/web_theme.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:ainoval/blocs/editor/editor_bloc.dart';
import 'package:ainoval/utils/logger.dart';
/// AI场景生成侧边栏用于显示从摘要生成的场景内容
class AISceneGenerationSidePanel extends StatefulWidget {
const AISceneGenerationSidePanel({
Key? key,
required this.onClose,
required this.onInsert,
}) : super(key: key);
/// 关闭面板时的回调
final VoidCallback onClose;
/// 插入内容到编辑器的回调
final Function(String content) onInsert;
@override
State<AISceneGenerationSidePanel> createState() => _AISceneGenerationSidePanelState();
}
class _AISceneGenerationSidePanelState extends State<AISceneGenerationSidePanel> {
/// 编辑器控制器
final TextEditingController _controller = TextEditingController();
/// 滚动控制器
final ScrollController _scrollController = ScrollController();
/// 是否已滚动到底部
bool _isScrolledToBottom = true;
@override
void initState() {
super.initState();
// 监听滚动事件,判断是否在底部
_scrollController.addListener(_scrollListener);
}
@override
void dispose() {
_controller.dispose();
_scrollController.removeListener(_scrollListener);
_scrollController.dispose();
super.dispose();
}
/// 滚动监听器,判断是否在底部
void _scrollListener() {
if (_scrollController.hasClients) {
final isBottom = _scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 50;
if (isBottom != _isScrolledToBottom) {
setState(() {
_isScrolledToBottom = isBottom;
});
}
}
}
/// 复制内容到剪贴板
void _copyToClipboard() {
Clipboard.setData(ClipboardData(text: _controller.text)).then((_) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('内容已复制到剪贴板')),
);
});
}
@override
Widget build(BuildContext context) {
return BlocConsumer<EditorBloc, EditorState>(
listener: (context, state) {
if (state is EditorLoaded && state.generatedSceneContent != null) {
// 更新编辑器内容
_controller.text = state.generatedSceneContent!;
// 如果用户滚动在底部,自动滚动到最新内容
if (_isScrolledToBottom && _scrollController.hasClients) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
});
}
}
},
builder: (context, state) {
if (state is! EditorLoaded) {
return const Center(child: CircularProgressIndicator());
}
final editorState = state as EditorLoaded;
final isGenerating = editorState.aiSceneGenerationStatus == AIGenerationStatus.generating;
final isCompleted = editorState.aiSceneGenerationStatus == AIGenerationStatus.completed;
final isFailed = editorState.aiSceneGenerationStatus == AIGenerationStatus.failed;
return Container(
width: 350, // 固定宽度
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(-2, 0),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题栏
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: WebTheme.getPrimaryColor(context).withOpacity(0.1),
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor,
width: 0.5,
),
),
),
child: Row(
children: [
Text(
'AI 生成的场景',
style: Theme.of(context).textTheme.titleMedium,
),
const Spacer(),
// 状态显示
if (isGenerating)
Row(
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
WebTheme.getPrimaryColor(context),
),
),
),
const SizedBox(width: 8),
const Text(
'正在生成...',
style: TextStyle(fontSize: 12),
),
],
)
else if (isCompleted)
const Text(
'已完成',
style: TextStyle(fontSize: 12, color: Colors.green),
)
else if (isFailed)
const Text(
'生成失败',
style: TextStyle(fontSize: 12, color: Colors.red),
),
],
),
),
// 内容区域
Expanded(
child: Stack(
children: [
// 文本编辑器
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _controller,
scrollController: _scrollController,
maxLines: null,
expands: true,
decoration: const InputDecoration(
border: InputBorder.none,
hintText: '生成的内容将显示在这里...',
),
style: const TextStyle(
fontSize: 14,
height: 1.5,
),
),
),
// 错误信息
if (isFailed && editorState.aiGenerationError != null)
Positioned(
bottom: 16,
left: 16,
right: 16,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.shade200),
),
child: Text(
'错误: ${editorState.aiGenerationError}',
style: TextStyle(
color: Colors.red.shade800,
fontSize: 12,
),
),
),
),
],
),
),
// 操作栏
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).scaffoldBackgroundColor,
border: Border(
top: BorderSide(
color: Theme.of(context).dividerColor,
width: 0.5,
),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// 复制按钮
IconButton(
icon: const Icon(Icons.copy),
tooltip: '复制内容',
onPressed: _controller.text.isNotEmpty
? _copyToClipboard
: null,
),
// 插入原文按钮
IconButton(
icon: const Icon(Icons.add_circle_outline),
tooltip: '插入到编辑器',
onPressed: (isCompleted || !isGenerating) && _controller.text.isNotEmpty
? () => widget.onInsert(_controller.text)
: null,
),
// 停止生成按钮
if (isGenerating)
IconButton(
icon: const Icon(Icons.stop_circle_outlined),
tooltip: '停止生成',
onPressed: () {
context.read<EditorBloc>().add(const StopSceneGeneration());
},
),
// 关闭按钮
IconButton(
icon: const Icon(Icons.close),
tooltip: '关闭',
onPressed: widget.onClose,
),
],
),
),
],
),
);
},
);
}
}

View File

@@ -0,0 +1,787 @@
// import 'dart:math'; // Added for min function
import 'package:ainoval/screens/editor/widgets/floating_setting_dialogs.dart';
import 'package:flutter/material.dart';
import 'package:ainoval/utils/web_theme.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:ainoval/models/novel_setting_item.dart';
import 'package:ainoval/models/setting_type.dart'; // Your SettingType enum
import 'package:ainoval/blocs/ai_setting_generation/ai_setting_generation_bloc.dart'; // Correct BLoC import
import 'package:ainoval/models/novel_structure.dart'; // Import for Chapter model
import 'package:ainoval/services/api_service/repositories/editor_repository.dart'; // Import EditorRepository
import 'package:ainoval/services/api_service/repositories/novel_ai_repository.dart'; // Needed for BLoC creation
import 'package:ainoval/blocs/setting/setting_bloc.dart';
import 'package:ainoval/utils/logger.dart';
// Removed placeholder BLoC, State, and Event definitions
class AISettingGenerationPanel extends StatelessWidget {
final String novelId;
final VoidCallback onClose;
final bool isCardMode;
final EditorRepository editorRepository; // Added
final NovelAIRepository novelAIRepository; // Added
const AISettingGenerationPanel({
Key? key,
required this.novelId,
required this.onClose,
required this.editorRepository, // Added
required this.novelAIRepository, // Added
this.isCardMode = false,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return BlocProvider<AISettingGenerationBloc>(
create: (context) => AISettingGenerationBloc(
editorRepository: editorRepository, // Changed from context.read
novelAIRepository: novelAIRepository, // Changed from context.read
)..add(LoadInitialDataForAISettingPanel(novelId)),
child: AISettingGenerationView(novelId: novelId),
);
}
}
class AISettingGenerationView extends StatefulWidget {
final String novelId;
const AISettingGenerationView({Key? key, required this.novelId}) : super(key: key);
@override
State<AISettingGenerationView> createState() => _AISettingGenerationViewState();
}
// 章节选择项数据模型
class ChapterOption {
final String id;
final String title;
final int order;
final int globalOrder; // 全局排序序号
final String actTitle;
final int actOrder;
ChapterOption({
required this.id,
required this.title,
required this.order,
required this.globalOrder,
required this.actTitle,
required this.actOrder,
});
String get displayTitle {
final chapterTitle = title.isNotEmpty ? title : '无标题章节';
return '${globalOrder}$chapterTitle';
}
String get actDisplayTitle {
return actTitle.isNotEmpty ? actTitle : '${actOrder}';
}
}
class _AISettingGenerationViewState extends State<AISettingGenerationView> {
String? _selectedStartChapterId;
String? _selectedEndChapterId;
final List<SettingTypeOption> _settingTypeOptions =
SettingType.values.map((type) => SettingTypeOption(type)).toList();
final _maxSettingsController = TextEditingController(text: '3');
final _instructionsController = TextEditingController();
final _formKey = GlobalKey<FormState>();
// 生成排序后的章节选项列表
List<ChapterOption> _generateChapterOptions(List<Chapter> chapters, Novel? novel) {
List<ChapterOption> options = [];
int globalOrder = 1;
if (novel == null) {
// 回退方案没有Novel信息时简单排序
chapters.sort((a, b) => a.order.compareTo(b.order));
for (final chapter in chapters) {
options.add(ChapterOption(
id: chapter.id,
title: chapter.title,
order: chapter.order,
globalOrder: globalOrder++,
actTitle: '',
actOrder: 1,
));
}
} else {
// 有Novel信息时按Act和章节顺序正确排序
final sortedActs = novel.acts..sort((a, b) => a.order.compareTo(b.order));
for (final act in sortedActs) {
final sortedChapters = act.chapters..sort((a, b) => a.order.compareTo(b.order));
for (final chapter in sortedChapters) {
// 只处理在chapters列表中的章节可能有过滤
if (chapters.any((c) => c.id == chapter.id)) {
options.add(ChapterOption(
id: chapter.id,
title: chapter.title,
order: chapter.order,
globalOrder: globalOrder++,
actTitle: act.title,
actOrder: act.order,
));
}
}
}
}
return options;
}
@override
void dispose() {
_maxSettingsController.dispose();
_instructionsController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(top: 0), // Changed from 24, assuming MultiAIPanelView handles top padding for header
child: Column(
children: [
_buildConfigurationArea(context, theme),
const Divider(height: 1, thickness: 1),
Expanded(child: _buildResultsArea(context, theme)),
],
),
);
}
Widget _buildConfigurationArea(BuildContext context, ThemeData theme) {
return Padding(
padding: const EdgeInsets.all(12.0),
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
BlocBuilder<AISettingGenerationBloc, AISettingGenerationState>(
builder: (context, state) {
List<Chapter> chapters = [];
Novel? novel;
bool isLoadingChapters = true;
String? chapterLoadingError;
if (state is AISettingGenerationDataLoaded) {
chapters = state.chapters;
novel = state.novel;
isLoadingChapters = false;
} else if (state is AISettingGenerationSuccess) {
chapters = state.chapters;
novel = state.novel;
isLoadingChapters = false;
} else if (state is AISettingGenerationFailure) {
chapters = state.chapters; // Might still have chapters from a previous successful load
novel = state.novel;
isLoadingChapters = false;
if(chapters.isEmpty) chapterLoadingError = state.error; // Only show error if no chapters displayed
} else if (state is AISettingGenerationLoadingChapters || state is AISettingGenerationInitial) {
isLoadingChapters = true;
} else {
isLoadingChapters = false;
}
if (isLoadingChapters) {
return const Center(child: Padding(
padding: EdgeInsets.symmetric(vertical: 24.0),
child: CircularProgressIndicator(strokeWidth: 2),
));
}
if (chapterLoadingError != null) {
return Center(child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Text('加载章节失败: $chapterLoadingError', style: TextStyle(color: theme.colorScheme.error)),
));
}
if (chapters.isEmpty) {
return const Center(child: Padding(
padding: EdgeInsets.symmetric(vertical: 24.0),
child: Text('没有可用的章节。'),
));
}
final chapterOptions = _generateChapterOptions(chapters, novel);
return Column(
children: [
_buildChapterDropdown(
context: context,
theme: theme,
label: '起始章节',
value: _selectedStartChapterId,
options: chapterOptions,
onChanged: (value) {
setState(() {
_selectedStartChapterId = value;
if (_selectedEndChapterId != null && _selectedStartChapterId != null) {
final startOption = chapterOptions.firstWhere((opt) => opt.id == _selectedStartChapterId);
final endOption = chapterOptions.firstWhere((opt) => opt.id == _selectedEndChapterId);
if (endOption.globalOrder < startOption.globalOrder) {
_selectedEndChapterId = null;
}
}
});
},
validator: (value) => value == null ? '请选择起始章节' : null,
),
const SizedBox(height: 12),
_buildChapterDropdown(
context: context,
theme: theme,
label: '结束章节 (可选)',
value: _selectedEndChapterId,
options: chapterOptions.where((option) {
if (_selectedStartChapterId == null) return true;
final startOption = chapterOptions.firstWhere((opt) => opt.id == _selectedStartChapterId);
return option.globalOrder >= startOption.globalOrder;
}).toList(),
onChanged: (value) {
setState(() {
_selectedEndChapterId = value;
});
},
hasDefaultOption: true,
),
],
);
},
),
const SizedBox(height: 16),
Text('希望生成的设定类型:', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500)),
const SizedBox(height: 8),
Wrap(
spacing: 8.0,
runSpacing: 4.0,
children: _settingTypeOptions.map((option) {
return FilterChip(
label: Text(option.type.displayName, style: const TextStyle(fontSize: 12)),
selected: option.isSelected,
onSelected: (selected) {
setState(() {
option.isSelected = selected;
});
},
checkmarkColor: option.isSelected ? theme.colorScheme.onPrimary : null,
selectedColor: WebTheme.getPrimaryColor(context),
labelStyle: TextStyle(
color: option.isSelected ? theme.colorScheme.onPrimary : theme.textTheme.bodySmall?.color,
fontWeight: option.isSelected ? FontWeight.bold : FontWeight.normal),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: option.isSelected ? WebTheme.getPrimaryColor(context) : theme.colorScheme.outline,
width: 1.0,
),
),
);
}).toList(),
),
const SizedBox(height: 16),
TextFormField(
controller: _maxSettingsController,
decoration: const InputDecoration(
labelText: '每类生成数量 (1-5)',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
validator: (value) {
if (value == null || value.isEmpty) return '请输入数量';
final num = int.tryParse(value);
if (num == null || num < 1 || num > 5) return '请输入1到5之间的数字';
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _instructionsController,
decoration: const InputDecoration(
labelText: '其他说明或风格引导 (可选)',
hintText: '例如:希望角色更神秘,或侧重描写地点的历史感',
border: OutlineInputBorder(),
alignLabelWithHint: true,
),
maxLines: 2,
maxLength: 200,
),
const SizedBox(height: 20),
Center(
child: BlocBuilder<AISettingGenerationBloc, AISettingGenerationState>(
builder: (context, state) {
bool isLoading = state is AISettingGenerationInProgress;
return ElevatedButton.icon(
icon: isLoading
? const SizedBox(width:16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Icon(Icons.auto_awesome_outlined, size: 18),
label: Text(isLoading ? '生成中...' : '开始生成设定'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
textStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)
),
onPressed: isLoading ? null : () {
if (_formKey.currentState!.validate()) {
final selectedTypes = _settingTypeOptions
.where((opt) => opt.isSelected)
.map((opt) => opt.type.value)
.toList();
if (selectedTypes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请至少选择一个设定类型'), backgroundColor: Colors.orange)
);
return;
}
if (_selectedStartChapterId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请选择起始章节'), backgroundColor: Colors.orange)
);
return;
}
context.read<AISettingGenerationBloc>().add(GenerateSettingsRequested(
novelId: widget.novelId,
startChapterId: _selectedStartChapterId!,
endChapterId: _selectedEndChapterId,
settingTypes: selectedTypes,
maxSettingsPerType: int.parse(_maxSettingsController.text),
additionalInstructions: _instructionsController.text,
));
}
},
);
}
),
),
const SizedBox(height: 12), // Add some bottom padding
],
),
),
),
);
}
Widget _buildChapterDropdown({
required BuildContext context,
required ThemeData theme,
required String label,
required String? value,
required List<ChapterOption> options,
required ValueChanged<String?> onChanged,
String? Function(String?)? validator,
bool hasDefaultOption = false,
}) {
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.dividerColor),
),
child: DropdownButtonFormField<String>(
decoration: InputDecoration(
labelText: label,
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
labelStyle: TextStyle(
color: theme.colorScheme.onSurfaceVariant,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
value: value,
isExpanded: true, // 确保下拉框内容完全显示
icon: Icon(Icons.keyboard_arrow_down, color: theme.colorScheme.onSurfaceVariant),
items: [
if (hasDefaultOption)
DropdownMenuItem<String>(
value: null,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Icon(Icons.auto_awesome, size: 18, color: WebTheme.getPrimaryColor(context)),
const SizedBox(width: 8),
Text(
'到最新章节 (默认)',
style: TextStyle(
color: WebTheme.getPrimaryColor(context),
fontWeight: FontWeight.w500,
fontSize: 14,
),
),
],
),
),
),
...options.map((option) {
return DropdownMenuItem<String>(
value: option.id,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
option.displayTitle,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
if (option.actTitle.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
option.actDisplayTitle,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w400,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
],
),
),
);
}).toList(),
],
onChanged: onChanged,
validator: validator,
selectedItemBuilder: (BuildContext context) {
return [
if (hasDefaultOption)
Text(
'到最新章节 (默认)',
style: TextStyle(
color: theme.colorScheme.onSurface,
fontSize: 14,
),
overflow: TextOverflow.ellipsis,
),
...options.map((option) {
return Text(
option.displayTitle,
style: TextStyle(
color: theme.colorScheme.onSurface,
fontSize: 14,
),
overflow: TextOverflow.ellipsis,
);
}).toList(),
];
},
dropdownColor: theme.cardColor,
borderRadius: BorderRadius.circular(12),
elevation: 8,
menuMaxHeight: 300, // 限制下拉菜单最大高度
),
);
}
Widget _buildResultsArea(BuildContext context, ThemeData theme) {
return BlocBuilder<AISettingGenerationBloc, AISettingGenerationState>(
builder: (context, state) {
if (state is AISettingGenerationInProgress) {
return const Center(child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('正在分析章节并生成设定,请稍候...')
],
));
}
if (state is AISettingGenerationSuccess) {
if (state.generatedSettings.isEmpty) {
return const Center(child: Padding(
padding: EdgeInsets.all(16.0),
child: Text('AI未能根据您的选择生成任何设定请尝试调整选项或章节内容后再试。', textAlign: TextAlign.center,)
));
}
return ListView.builder(
padding: const EdgeInsets.all(8.0),
itemCount: state.generatedSettings.length,
itemBuilder: (context, index) {
return NovelSettingItemCard(
settingItem: state.generatedSettings[index],
novelId: widget.novelId,
);
},
);
}
if (state is AISettingGenerationFailure) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, color: theme.colorScheme.error, size: 48),
const SizedBox(height:16),
Text('生成设定时出错:', style: theme.textTheme.titleMedium),
const SizedBox(height:8),
Text(state.error, style: TextStyle(color: theme.colorScheme.error), textAlign: TextAlign.center,),
const SizedBox(height:16),
ElevatedButton.icon(
icon: const Icon(Icons.refresh, size: 18),
label: const Text('重试'),
onPressed: (){
if (_formKey.currentState!.validate()) {
final selectedTypes = _settingTypeOptions
.where((opt) => opt.isSelected)
.map((opt) => opt.type.value)
.toList();
if (selectedTypes.isEmpty || _selectedStartChapterId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请确保已选择起始章节和至少一个设定类型再重试。'), backgroundColor: Colors.orange)
);
return;
}
context.read<AISettingGenerationBloc>().add(GenerateSettingsRequested(
novelId: widget.novelId,
startChapterId: _selectedStartChapterId!,
endChapterId: _selectedEndChapterId,
settingTypes: selectedTypes,
maxSettingsPerType: int.parse(_maxSettingsController.text),
additionalInstructions: _instructionsController.text,
));
}
}
)
],
)
),
);
}
// Initial or other states
return const Center(child: Padding(
padding: EdgeInsets.all(16.0),
child: Text('请选择起始章节和希望生成的设定类型,然后点击"开始生成设定"按钮。', textAlign: TextAlign.center,)
));
},
);
}
}
class NovelSettingItemCard extends StatefulWidget {
final NovelSettingItem settingItem;
final String novelId;
const NovelSettingItemCard({
Key? key,
required this.settingItem,
required this.novelId,
}) : super(key: key);
@override
State<NovelSettingItemCard> createState() => _NovelSettingItemCardState();
}
class _NovelSettingItemCardState extends State<NovelSettingItemCard> {
bool _isExpanded = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final typeEnum = SettingType.fromValue(widget.settingItem.type ?? 'OTHER');
final itemAttributes = widget.settingItem.attributes; // Store in a local variable
final itemTags = widget.settingItem.tags; // Store in a local variable
return Card(
margin: const EdgeInsets.symmetric(vertical: 6.0),
elevation: 1.5,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)), // Softer corners
clipBehavior: Clip.antiAlias, // Ensures content respects border radius
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start, // Align items to the top
children: [
Expanded(
child: Text(
widget.settingItem.name,
style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold, fontSize: 15),
),
),
const SizedBox(width: 8),
Chip(
label: Text(typeEnum.displayName, style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w500)),
backgroundColor: _getTypeColor(typeEnum).withOpacity(0.15),
labelStyle: TextStyle(color: _getTypeColor(typeEnum)),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: const VisualDensity(horizontal: 0.0, vertical: -2), // Compact chip
),
],
),
const SizedBox(height: 8),
Text(
widget.settingItem.description ?? '无描述',
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant, fontSize: 13, height: 1.4),
maxLines: _isExpanded ? null : 3, // Show a bit more before expanding
overflow: _isExpanded ? TextOverflow.visible : TextOverflow.ellipsis,
),
if ((widget.settingItem.description?.length ?? 0) > 120) // Show expand if description is somewhat long
Align(
alignment: Alignment.centerRight,
child: TextButton(
style: TextButton.styleFrom(padding: EdgeInsets.zero, minimumSize: const Size(50,30), visualDensity: VisualDensity.compact),
child: Text(_isExpanded ? '收起' : '展开', style: TextStyle(fontSize: 12, color: WebTheme.getPrimaryColor(context))),
onPressed: () => setState(() => _isExpanded = !_isExpanded)),
),
if ((itemAttributes?.isNotEmpty ?? false) || (itemTags?.isNotEmpty ?? false)) ...[
const SizedBox(height: 6),
Divider(thickness: 0.5, color: theme.dividerColor.withOpacity(0.5)),
const SizedBox(height: 6),
if (itemAttributes?.isNotEmpty ?? false)
Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Wrap(
spacing: 6,
runSpacing: 4,
children: itemAttributes!.entries.map((e) => Chip(
label: Text('${e.key}: ${e.value}', style: const TextStyle(fontSize: 10)),
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
backgroundColor: theme.colorScheme.surfaceVariant.withOpacity(0.7),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
)).toList(),
),
),
if (itemTags?.isNotEmpty ?? false)
Wrap(
spacing: 6,
runSpacing: 4,
children: itemTags!.map((tag) => Chip(
label: Text(tag, style: const TextStyle(fontSize: 10)),
backgroundColor: theme.colorScheme.secondaryContainer.withOpacity(0.6),
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
)).toList(),
),
],
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
icon: const Icon(Icons.add_circle_outline, size: 16),
label: const Text('采纳到设定组', style: TextStyle(fontSize: 12)),
style: TextButton.styleFrom(
foregroundColor: WebTheme.getPrimaryColor(context),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
),
onPressed: () {
_showAdoptDialog(context, widget.settingItem, widget.novelId);
},
),
],
),
],
),
),
);
}
Color _getTypeColor(SettingType type) {
switch (type) {
case SettingType.character: return Colors.blue.shade600;
case SettingType.location: return Colors.green.shade600;
case SettingType.item: return Colors.orange.shade700;
case SettingType.lore: return Colors.purple.shade600;
case SettingType.event: return Colors.red.shade600;
case SettingType.concept: return Colors.teal.shade600;
case SettingType.faction: return Colors.indigo.shade600;
case SettingType.creature: return Colors.brown.shade600;
case SettingType.magicSystem: return Colors.cyan.shade600;
case SettingType.technology: return Colors.blueGrey.shade600;
case SettingType.culture: return Colors.deepOrange.shade600;
case SettingType.history: return Colors.brown.shade600;
case SettingType.organization: return Colors.indigo.shade600;
case SettingType.worldview: return Colors.purple.shade600;
case SettingType.pleasurePoint: return Colors.redAccent.shade200;
case SettingType.anticipationHook: return Colors.teal.shade400;
case SettingType.theme: return Colors.blueGrey.shade500;
case SettingType.tone: return Colors.amber.shade700;
case SettingType.style: return Colors.cyan.shade700;
case SettingType.trope: return Colors.pink.shade400;
case SettingType.plotDevice: return Colors.green.shade600;
case SettingType.powerSystem: return Colors.orange.shade700;
case SettingType.timeline: return Colors.blue.shade600;
case SettingType.religion: return Colors.deepPurple.shade600;
case SettingType.politics: return Colors.red.shade700;
case SettingType.economy: return Colors.lightGreen.shade700;
case SettingType.geography: return Colors.lightBlue.shade700;
default: return Colors.grey.shade600;
}
}
void _showAdoptDialog(BuildContext context, NovelSettingItem itemToAdopt, String novelId) {
final settingBloc = context.read<SettingBloc>();
AppLogger.i("AISettingGenerationPanel", "准备采纳设定: ${itemToAdopt.name}, 描述长度: ${itemToAdopt.description?.length ?? 0}, 标签数量: ${itemToAdopt.tags?.length ?? 0}, 属性数量: ${itemToAdopt.attributes?.length ?? 0}");
FloatingSettingDialogs.showSettingGroupSelection(
context: context,
novelId: novelId,
onGroupSelected: (groupId, groupName) {
// 显示操作提示
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('正在将 "${itemToAdopt.name}" 添加到 "$groupName"...'))
);
// 确保类型值使用正确的枚举value值
final typeValue = itemToAdopt.type;
// 准备创建的设定条目
NovelSettingItem itemForCreation = itemToAdopt.copyWith(
id: null,
isAiSuggestion: false,
status: 'ACTIVE',
type: typeValue, // 确保使用原始的value值
// 明确设置content和description确保不会丢失
content: "", // 不再使用content字段
description: itemToAdopt.description, // 保留description作为主要描述字段
attributes: itemToAdopt.attributes, // 确保属性被保留
tags: itemToAdopt.tags, // 确保标签被保留
generatedBy: "AI设定生成器" // 明确标记生成来源
);
// 在安全的上下文环境中创建并添加到组
WidgetsBinding.instance.addPostFrameCallback((_) {
settingBloc.add(CreateSettingItemAndAddToGroup(
novelId: novelId,
item: itemForCreation,
groupId: groupId,
));
});
},
);
}
}

View File

@@ -0,0 +1,653 @@
import 'package:ainoval/blocs/editor/editor_bloc.dart';
import 'package:ainoval/utils/logger.dart';
import 'package:flutter/material.dart';
import 'package:ainoval/utils/web_theme.dart';
import 'package:flutter/services.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'dart:async';
/// AI流式生成内容显示组件
/// 在编辑器右侧面板中展示流式生成的内容,使用打字机效果
class AIStreamGenerationDisplay extends StatefulWidget {
const AIStreamGenerationDisplay({
Key? key,
required this.onClose,
this.onOpenInEditor,
}) : super(key: key);
/// 关闭面板的回调
final VoidCallback onClose;
/// 在编辑器中打开内容的回调
final Function(String content)? onOpenInEditor;
@override
State<AIStreamGenerationDisplay> createState() => _AIStreamGenerationDisplayState();
}
class _AIStreamGenerationDisplayState extends State<AIStreamGenerationDisplay> {
final ScrollController _scrollController = ScrollController();
Timer? _autoScrollTimer;
final TextEditingController _summaryController = TextEditingController();
final TextEditingController _styleController = TextEditingController();
bool _userScrolled = false;
bool _showGeneratePanel = false;
@override
void initState() {
super.initState();
// 初始化时检查是否有正在进行的生成,如有则自动滚动
WidgetsBinding.instance.addPostFrameCallback((_) {
final state = context.read<EditorBloc>().state;
if (state is EditorLoaded &&
state.aiSceneGenerationStatus == AIGenerationStatus.generating &&
state.generatedSceneContent != null &&
state.generatedSceneContent!.isNotEmpty) {
_scrollToBottom();
AppLogger.i('AIStreamGenerationDisplay', '初始化时检测到生成内容,自动滚动到底部');
}
});
// 启动定期滚动更新
_startAutoScrollTimer();
// 监听滚动事件,检测用户是否主动滚动
_scrollController.addListener(_handleUserScroll);
}
void _handleUserScroll() {
if (_scrollController.hasClients) {
// 如果用户向上滚动(滚动位置不在底部),标记为用户滚动
if (_scrollController.position.pixels <
_scrollController.position.maxScrollExtent - 50) {
_userScrolled = true;
}
// 如果用户滚动到底部,重置标记
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 10) {
_userScrolled = false;
}
}
}
void _startAutoScrollTimer() {
// 每500毫秒检查一次是否需要滚动
_autoScrollTimer = Timer.periodic(const Duration(milliseconds: 500), (_) {
final state = context.read<EditorBloc>().state;
if (state is EditorLoaded &&
state.isStreamingGeneration &&
state.aiSceneGenerationStatus == AIGenerationStatus.generating &&
!_userScrolled) { // 只有在用户没有主动滚动时自动滚动
_scrollToBottom();
}
});
}
@override
void dispose() {
_autoScrollTimer?.cancel();
_scrollController.removeListener(_handleUserScroll);
_scrollController.dispose();
_summaryController.dispose();
_styleController.dispose();
super.dispose();
}
/// 自动滚动到底部
void _scrollToBottom() {
if (!_scrollController.hasClients) {
AppLogger.d('AIStreamGenerationDisplay', '滚动控制器还没有客户端,延迟滚动');
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToBottom();
});
return;
}
try {
AppLogger.d('AIStreamGenerationDisplay', '执行滚动到底部');
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
} catch (e) {
AppLogger.e('AIStreamGenerationDisplay', '滚动到底部失败', e);
}
}
/// 复制内容到剪贴板
void _copyToClipboard(String content) {
Clipboard.setData(ClipboardData(text: content)).then((_) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('内容已复制到剪贴板')),
);
});
}
/// 生成场景
void _generateScene(BuildContext context) {
if (_summaryController.text.isEmpty) return;
try {
final state = context.read<EditorBloc>().state;
if (state is! EditorLoaded) return;
// 触发场景生成请求
context.read<EditorBloc>().add(
GenerateSceneFromSummaryRequested(
novelId: state.novel.id,
summary: _summaryController.text,
chapterId: state.activeChapterId,
styleInstructions: _styleController.text.isNotEmpty
? _styleController.text
: null,
useStreamingMode: true,
),
);
// 隐藏生成面板
setState(() {
_showGeneratePanel = false;
});
// 重置用户滚动标记
_userScrolled = false;
} catch (e) {
AppLogger.e('AIStreamGenerationDisplay', '生成场景错误', e);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('启动AI生成时出错: ${e.toString()}')),
);
}
}
@override
Widget build(BuildContext context) {
return BlocConsumer<EditorBloc, EditorState>(
listener: (context, state) {
if (state is EditorLoaded &&
state.isStreamingGeneration &&
state.generatedSceneContent != null &&
state.generatedSceneContent!.isNotEmpty &&
!_userScrolled) {
_scrollToBottom();
}
},
builder: (context, state) {
if (state is! EditorLoaded) {
return const Center(child: CircularProgressIndicator());
}
final isGenerating = state.aiSceneGenerationStatus == AIGenerationStatus.generating;
final hasGenerated = state.aiSceneGenerationStatus == AIGenerationStatus.completed;
final hasFailed = state.aiSceneGenerationStatus == AIGenerationStatus.failed;
final content = state.generatedSceneContent ?? '';
return Container(
width: 350, // 固定宽度
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(-2, 0),
),
],
),
child: Column(
children: [
// 标题栏
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.7),
border: Border(
bottom: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.5,
),
),
),
child: Row(
children: [
Text(
'AI 生成助手',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const Spacer(),
// 状态指示器
if (isGenerating)
Row(
children: [
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
WebTheme.getPrimaryColor(context),
),
),
),
const SizedBox(width: 8),
Text(
'正在流式生成...',
style: TextStyle(
fontSize: 12,
color: WebTheme.getPrimaryColor(context),
),
),
],
)
else if (hasGenerated)
Row(
children: [
Icon(
Icons.check_circle,
size: 14,
color: Colors.green.shade600,
),
const SizedBox(width: 8),
Text(
'生成完成',
style: TextStyle(
fontSize: 12,
color: Colors.green.shade600,
),
),
],
)
else if (hasFailed)
Row(
children: [
Icon(
Icons.error,
size: 14,
color: Colors.red.shade600,
),
const SizedBox(width: 8),
Text(
'生成失败',
style: TextStyle(
fontSize: 12,
color: Colors.red.shade600,
),
),
],
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.close, size: 20),
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
padding: const EdgeInsets.all(4),
onPressed: widget.onClose,
tooltip: '关闭',
),
],
),
),
// 内容标签
if (!_showGeneratePanel)
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(
bottom: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.5,
),
),
),
child: Row(
children: [
TabPageSelector(
selectedColor: WebTheme.getPrimaryColor(context),
color: Theme.of(context).colorScheme.outlineVariant,
controller: TabController(
initialIndex: 0,
length: 2,
vsync: const _TickerProviderImpl(),
),
),
const Spacer(),
// 添加生成场景按钮
if (!isGenerating) // 只在不生成时显示
TextButton.icon(
onPressed: () {
setState(() {
_showGeneratePanel = true;
});
},
icon: const Icon(Icons.add, size: 16),
label: const Text('生成新场景'),
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 12),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
),
),
],
),
),
// 生成面板 (新增)
if (_showGeneratePanel)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(
bottom: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.5,
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'创建新场景',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 12),
TextField(
controller: _summaryController,
maxLines: 4,
decoration: InputDecoration(
labelText: '场景摘要/大纲',
hintText: '请输入场景大纲或摘要...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.all(12),
),
),
const SizedBox(height: 12),
TextField(
controller: _styleController,
decoration: InputDecoration(
labelText: '风格指令(可选)',
hintText: '多对话,少描写,悬疑风格...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.all(12),
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: (_summaryController.text.isNotEmpty || content.isNotEmpty)
? () => _generateScene(context)
: null,
icon: const Icon(Icons.auto_awesome, size: 16),
label: const Text('开始生成'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
const SizedBox(width: 8),
OutlinedButton(
onPressed: () {
setState(() {
_showGeneratePanel = false;
});
},
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: const Text('取消'),
),
],
),
],
),
),
// 内容区域
Expanded(
child: Stack(
children: [
if (content.isNotEmpty)
Padding(
padding: const EdgeInsets.all(16.0),
child: SingleChildScrollView(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(), // 允许滚动
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
content,
style: TextStyle(
height: 1.8,
fontSize: 15,
color: Theme.of(context).colorScheme.onSurface,
),
),
// 底部空间
if (isGenerating)
const SizedBox(height: 40),
],
),
),
)
else if (!isGenerating && !hasFailed)
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.description_outlined,
size: 64,
color: Theme.of(context).colorScheme.outline.withOpacity(0.5),
),
const SizedBox(height: 16),
Text(
'生成的内容将显示在这里',
style: TextStyle(
color: Theme.of(context).colorScheme.outline,
fontSize: 14,
),
),
],
),
)
else if (isGenerating && content.isEmpty)
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
),
),
const SizedBox(height: 16),
Text(
'正在准备内容...',
style: TextStyle(
color: WebTheme.getPrimaryColor(context),
fontSize: 14,
),
),
],
),
),
// 生成指示器 (流式生成时在底部显示小提示)
if (isGenerating && content.isNotEmpty)
Positioned(
bottom: 0,
right: 0,
left: 0,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Theme.of(context).colorScheme.surface.withOpacity(0),
Theme.of(context).colorScheme.surface,
],
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(3),
),
),
const SizedBox(width: 8),
Text(
'正在生成中...',
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 12,
),
),
],
),
),
),
// 错误信息
if (hasFailed && state.aiGenerationError != null)
Positioned(
bottom: 16,
left: 16,
right: 16,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.shade200),
),
child: Text(
'错误: ${state.aiGenerationError}',
style: TextStyle(
color: Colors.red.shade800,
fontSize: 12,
),
),
),
),
],
),
),
// 底部操作栏
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5),
border: Border(
top: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.5,
),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// 左侧按钮
if (isGenerating)
TextButton.icon(
onPressed: () {
context.read<EditorBloc>().add(StopSceneGeneration());
},
icon: const Icon(Icons.stop, size: 16),
label: const Text('停止生成'),
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 13),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
)
else
FilledButton.icon(
onPressed: hasGenerated && content.isNotEmpty
? () {
// 创建新场景并使用生成的内容
if (widget.onOpenInEditor != null) {
widget.onOpenInEditor!(content);
AppLogger.i('AIStreamGenerationDisplay', '在编辑器中打开生成内容');
widget.onClose();
}
}
: null,
icon: const Icon(Icons.save, size: 16),
label: const Text('保存为场景'),
style: FilledButton.styleFrom(
textStyle: const TextStyle(fontSize: 13),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
),
// 右侧按钮
Row(
children: [
if (!isGenerating && hasGenerated)
IconButton(
onPressed: () {
setState(() {
_showGeneratePanel = true;
});
},
icon: const Icon(Icons.refresh, size: 18),
tooltip: '重新生成',
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
padding: const EdgeInsets.all(8),
),
IconButton(
onPressed: hasGenerated && content.isNotEmpty
? () => _copyToClipboard(content)
: null,
icon: const Icon(Icons.copy, size: 18),
tooltip: '复制全部内容',
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
padding: const EdgeInsets.all(8),
),
],
),
],
),
),
],
),
);
},
);
}
}
/// 简单的TickerProvider实现用于TabController
class _TickerProviderImpl extends TickerProvider {
const _TickerProviderImpl();
@override
Ticker createTicker(TickerCallback onTick) => Ticker(onTick);
}

View File

@@ -0,0 +1,973 @@
import 'package:ainoval/blocs/editor/editor_bloc.dart';
import 'package:ainoval/blocs/universal_ai/universal_ai_bloc.dart';
import 'package:ainoval/blocs/universal_ai/universal_ai_event.dart';
import 'package:ainoval/blocs/universal_ai/universal_ai_state.dart';
import 'package:ainoval/blocs/ai_config/ai_config_bloc.dart';
import 'package:ainoval/blocs/public_models/public_models_bloc.dart';
import 'package:ainoval/models/novel_structure.dart';
import 'package:ainoval/models/unified_ai_model.dart';
import 'package:ainoval/models/ai_request_models.dart';
// import 'package:ainoval/screens/editor/managers/editor_layout_manager.dart';
import 'package:ainoval/widgets/common/unified_ai_model_dropdown.dart';
import 'package:ainoval/models/context_selection_models.dart';
import 'package:ainoval/widgets/common/form_dialog_template.dart';
import 'package:ainoval/screens/editor/components/ai_dialog_common_logic.dart';
import 'package:ainoval/widgets/common/scene_selector.dart';
import 'package:ainoval/config/app_config.dart';
import 'package:ainoval/utils/logger.dart';
import 'package:ainoval/utils/quill_helper.dart';
import 'package:flutter/material.dart';
import 'package:ainoval/utils/web_theme.dart';
import 'package:flutter/services.dart';
import 'package:ainoval/widgets/common/top_toast.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
/// AI摘要生成面板提供根据场景内容生成摘要的功能
class AISummaryPanel extends StatefulWidget {
const AISummaryPanel({
Key? key,
required this.novelId,
required this.onClose,
this.isCardMode = false,
}) : super(key: key);
final String novelId;
final VoidCallback onClose;
final bool isCardMode; // 是否以卡片模式显示
@override
State<AISummaryPanel> createState() => _AISummaryPanelState();
}
class _AISummaryPanelState extends State<AISummaryPanel> with AIDialogCommonLogic {
final ScrollController _scrollController = ScrollController();
final TextEditingController _summaryController = TextEditingController();
final LayerLink _layerLink = LayerLink();
UnifiedAIModel? _selectedModel;
bool _enableSmartContext = true;
// bool _userScrolled = false; // 未使用,先注释避免警告
// bool _contentEdited = false; // 未使用,先注释避免警告
bool _isGenerating = false;
bool _thisInstanceIsGenerating = false; // 标记是否是当前实例发起的生成请求
late ContextSelectionData _contextSelectionData;
String? _selectedPromptTemplateId;
// 临时自定义提示词
String? _customSystemPrompt;
String? _customUserPrompt;
bool _contextInitialized = false;
@override
void initState() {
super.initState();
// _contentEdited = false;
// 监听滚动事件,检测用户是否主动滚动
_scrollController.addListener(_handleUserScroll);
// 初始化默认模型配置
WidgetsBinding.instance.addPostFrameCallback((_) {
_initializeDefaultModel();
_initializeContextData();
});
}
void _initializeDefaultModel() {
final aiConfigState = context.read<AiConfigBloc>().state;
final publicModelsState = context.read<PublicModelsBloc>().state;
// 合并私有模型和公共模型
final allModels = _combineModels(aiConfigState, publicModelsState);
if (allModels.isNotEmpty && _selectedModel == null) {
// 优先选择默认配置
UnifiedAIModel? defaultModel;
// 首先查找私有模型中的默认配置
for (final model in allModels) {
if (!model.isPublic && (model as PrivateAIModel).userConfig.isDefault) {
defaultModel = model;
break;
}
}
// 如果没有默认私有模型,选择第一个公共模型
defaultModel ??= allModels.firstWhere(
(model) => model.isPublic,
orElse: () => allModels.first,
);
setState(() {
_selectedModel = defaultModel;
});
}
}
/// 合并私有模型和公共模型
List<UnifiedAIModel> _combineModels(AiConfigState aiState, PublicModelsState publicState) {
final List<UnifiedAIModel> allModels = [];
// 添加已验证的私有模型
final validatedConfigs = aiState.validatedConfigs;
for (final config in validatedConfigs) {
allModels.add(PrivateAIModel(config));
}
// 添加公共模型
if (publicState is PublicModelsLoaded) {
for (final publicModel in publicState.models) {
allModels.add(PublicAIModel(publicModel));
}
}
return allModels;
}
void _initializeContextData() {
if (_contextInitialized) return;
final editorState = context.read<EditorBloc>().state;
if (editorState is EditorLoaded) {
_contextSelectionData = ContextSelectionDataBuilder.fromNovel(editorState.novel);
_contextInitialized = true;
}
}
@override
void dispose() {
_scrollController.removeListener(_handleUserScroll);
_scrollController.dispose();
_summaryController.dispose();
super.dispose();
}
void _handleUserScroll() {}
/// 复制内容到剪贴板
void _copyToClipboard(String content) {
Clipboard.setData(ClipboardData(text: content)).then((_) {
TopToast.success(context, '摘要已复制到剪贴板');
});
}
@override
Widget build(BuildContext context) {
return BlocBuilder<EditorBloc, EditorState>(
builder: (context, editorState) {
if (editorState is! EditorLoaded) {
return const Center(child: CircularProgressIndicator());
}
return BlocConsumer<UniversalAIBloc, UniversalAIState>(
listener: (context, state) {
// 只处理摘要生成相关的状态变化
if (state is UniversalAIStreaming) {
// 检查是否是摘要生成请求
if (_isSummaryRequest(state)) {
setState(() {
_isGenerating = true;
_summaryController.text = state.partialResponse;
// _contentEdited = false;
});
}
} else if (state is UniversalAISuccess) {
// 检查是否是摘要生成请求
if (_isSummaryRequest(state)) {
setState(() {
_isGenerating = false;
_thisInstanceIsGenerating = false; // 重置实例生成标记
_summaryController.text = state.response.content;
// _contentEdited = false;
});
}
} else if (state is UniversalAIError) {
// 检查是否是摘要生成请求
if (_isSummaryRequest(state)) {
setState(() {
_isGenerating = false;
_thisInstanceIsGenerating = false; // 重置实例生成标记
});
TopToast.error(context, '生成摘要失败: ${state.message}');
}
} else if (state is UniversalAILoading) {
// 检查是否是摘要生成请求
if (_isSummaryRequest(state)) {
setState(() {
_isGenerating = true;
});
}
}
},
builder: (context, universalAIState) {
return Column(
children: [
// 面板标题栏
_buildHeader(context, editorState),
// 面板内容
Expanded(
child: _buildSummaryContentPanel(context, editorState),
),
],
);
},
);
},
);
}
Widget _buildHeader(BuildContext context, EditorLoaded state) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 10.0),
decoration: BoxDecoration(
color: WebTheme.getSurfaceColor(context),
border: Border(
bottom: BorderSide(
color: WebTheme.getSecondaryBorderColor(context),
width: 1,
),
),
),
child: Column(
children: [
// 标题行
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: WebTheme.getPrimaryColor(context),
borderRadius: BorderRadius.circular(4),
),
child: Icon(
Icons.summarize,
size: 14,
color: WebTheme.white,
),
),
const SizedBox(width: 8),
Text(
'AI摘要助手',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: WebTheme.getTextColor(context),
),
),
],
),
Row(
children: [
// 状态指示器
if (_isGenerating) ...[
const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.black),
),
),
const SizedBox(width: 6),
Text(
'正在生成...',
style: TextStyle(
fontSize: 11,
color: WebTheme.getTextColor(context),
),
),
const SizedBox(width: 8),
],
// 帮助按钮
Tooltip(
message: '使用说明',
child: IconButton(
icon: Icon(
Icons.help_outline,
size: 16,
color: WebTheme.getSecondaryTextColor(context),
),
padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
onPressed: () {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: WebTheme.getCardColor(context),
surfaceTintColor: Colors.transparent,
title: Text(
'AI摘要生成说明',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: WebTheme.getTextColor(context),
),
),
content: const SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'1. 选择要生成摘要的场景',
style: TextStyle(fontSize: 12, color: Colors.black87),
),
SizedBox(height: 6),
Text(
'2. 选择AI模型和配置',
style: TextStyle(fontSize: 12, color: Colors.black87),
),
SizedBox(height: 6),
Text(
'3. 点击"生成摘要"按钮',
style: TextStyle(fontSize: 12, color: Colors.black87),
),
SizedBox(height: 6),
Text(
'4. 生成完成后,可以直接编辑摘要内容',
style: TextStyle(fontSize: 12, color: Colors.black87),
),
SizedBox(height: 6),
Text(
'5. 点击"保存摘要"按钮将摘要保存到场景',
style: TextStyle(fontSize: 12, color: Colors.black87),
),
],
),
),
actions: [
ElevatedButton(
onPressed: () => Navigator.pop(context),
style: ElevatedButton.styleFrom(
backgroundColor: WebTheme.getPrimaryColor(context),
foregroundColor: WebTheme.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('了解了', style: TextStyle(fontSize: 12)),
),
],
),
);
},
),
),
const SizedBox(width: 2),
IconButton(
icon: Icon(Icons.close, size: 16, color: WebTheme.getSecondaryTextColor(context)),
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
padding: const EdgeInsets.all(4),
onPressed: widget.onClose,
tooltip: '关闭',
),
],
),
],
),
// 当前场景信息行
const SizedBox(height: 8),
_buildCurrentSceneSelector(context, state),
],
),
);
}
Widget _buildCurrentSceneSelector(BuildContext context, EditorLoaded state) {
return SceneSelector(
novel: state.novel,
activeSceneId: state.activeSceneId,
onSceneSelected: (sceneId, actId, chapterId) {
// 更新活跃场景
context.read<EditorBloc>().add(SetActiveScene(
actId: actId,
chapterId: chapterId,
sceneId: sceneId,
));
},
onSummaryLoaded: (summary) {
// 加载场景摘要到输入框
setState(() {
_summaryController.text = summary;
});
},
);
}
// 构建摘要内容面板
Widget _buildSummaryContentPanel(BuildContext context, EditorLoaded state) {
return Padding(
padding: const EdgeInsets.all(10.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 模型配置区域
_buildModelConfigSection(context, state),
const SizedBox(height: 10),
// 分割线
Container(
height: 1,
color: WebTheme.getSecondaryBorderColor(context),
),
const SizedBox(height: 10),
// 生成的摘要区域
Expanded(
child: _buildSummarySection(context, state),
),
],
),
);
}
Widget _buildModelConfigSection(BuildContext context, EditorLoaded state) {
return Container(
decoration: BoxDecoration(
color: WebTheme.getSurfaceColor(context),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: WebTheme.getSecondaryBorderColor(context),
width: 1,
),
),
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'模型设置',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: WebTheme.getTextColor(context),
),
),
const SizedBox(height: 8),
// 统一模型选择器
_buildUnifiedModelSelector(context, state),
const SizedBox(height: 12),
// 智能上下文开关
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'智能上下文',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: WebTheme.getTextColor(context),
),
),
const SizedBox(height: 2),
Text(
'启用后将自动检索相关的小说设定和背景信息',
style: TextStyle(
fontSize: 10,
color: WebTheme.getSecondaryTextColor(context),
),
),
],
),
),
Transform.scale(
scale: 0.8,
child: Switch(
value: _enableSmartContext,
activeColor: WebTheme.getPrimaryColor(context),
activeTrackColor: WebTheme.getSecondaryBorderColor(context),
inactiveThumbColor: WebTheme.getCardColor(context),
inactiveTrackColor: WebTheme.getSecondaryBorderColor(context),
onChanged: (value) {
setState(() {
_enableSmartContext = value;
});
},
),
),
],
),
const SizedBox(height: 12),
// 上下文选择
if (_contextInitialized)
FormFieldFactory.createContextSelectionField(
contextData: _contextSelectionData,
onSelectionChanged: (newData) {
setState(() {
_contextSelectionData = newData;
});
},
title: '附加上下文',
description: '选择要包含在生成中的上下文信息',
onReset: () {
setState(() {
_contextSelectionData = ContextSelectionDataBuilder.fromNovel(state.novel);
});
},
dropdownWidth: 400,
initialChapterId: state.activeChapterId,
initialSceneId: state.activeSceneId,
),
if (_contextInitialized) const SizedBox(height: 12),
// 关联提示词模板
FormFieldFactory.createPromptTemplateSelectionField(
selectedTemplateId: _selectedPromptTemplateId,
onTemplateSelected: (templateId) {
setState(() {
_selectedPromptTemplateId = templateId;
});
},
aiFeatureType: 'SCENE_TO_SUMMARY',
title: '关联提示词模板',
description: '可选,选择一个提示词模板优化摘要生成',
onReset: () {
setState(() {
_selectedPromptTemplateId = null;
});
},
onTemporaryPromptsSaved: (sys, user) {
setState(() {
_customSystemPrompt = sys.trim().isEmpty ? null : sys.trim();
_customUserPrompt = user.trim().isEmpty ? null : user.trim();
});
},
),
const SizedBox(height: 12),
// 生成按钮
SizedBox(
width: double.infinity,
height: 36,
child: ElevatedButton.icon(
onPressed: (_getActiveScene(state) == null ||
_getActiveScene(state)!.content.isEmpty ||
_selectedModel == null ||
_isGenerating)
? null
: () => _generateSummary(context, state),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
disabledBackgroundColor: Colors.grey[300],
disabledForegroundColor: Colors.grey[600],
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
),
icon: _isGenerating
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Icon(Icons.auto_awesome, size: 14),
label: Text(
_isGenerating ? '生成中...' : '生成摘要',
style: const TextStyle(fontSize: 13),
),
),
),
],
),
);
}
/// 构建统一模型选择器
Widget _buildUnifiedModelSelector(BuildContext context, EditorLoaded state) {
return BlocBuilder<AiConfigBloc, AiConfigState>(
builder: (context, aiState) {
return BlocBuilder<PublicModelsBloc, PublicModelsState>(
builder: (context, publicState) {
final allModels = _combineModels(aiState, publicState);
return CompositedTransformTarget(
link: _layerLink,
child: InkWell(
onTap: () {
_showModelDropdown(context, state, allModels);
},
borderRadius: BorderRadius.circular(6),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.grey[300]!, width: 1),
),
child: Row(
children: [
Expanded(
child: _selectedModel != null
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_selectedModel!.displayName,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
),
const SizedBox(height: 2),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: _selectedModel!.isPublic ? Colors.green[50] : Colors.blue[50],
borderRadius: BorderRadius.circular(3),
border: Border.all(
color: _selectedModel!.isPublic ? Colors.green[200]! : Colors.blue[200]!,
width: 0.5,
),
),
child: Text(
_selectedModel!.isPublic ? '系统' : '私有',
style: TextStyle(
fontSize: 10,
color: _selectedModel!.isPublic ? Colors.green[700] : Colors.blue[700],
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 6),
Text(
_selectedModel!.provider,
style: TextStyle(
fontSize: 10,
color: Colors.grey[600],
),
),
],
),
],
)
: const Text(
'选择AI模型',
style: TextStyle(
fontSize: 13,
color: Colors.black54,
),
),
),
const Icon(
Icons.arrow_drop_down,
color: Colors.black54,
size: 20,
),
],
),
),
),
);
},
);
},
);
}
/// 显示模型选择下拉菜单
void _showModelDropdown(BuildContext context, EditorLoaded state, List<UnifiedAIModel> allModels) {
UnifiedAIModelDropdown.show(
context: context,
layerLink: _layerLink,
selectedModel: _selectedModel,
onModelSelected: (model) {
setState(() {
_selectedModel = model;
});
},
showSettingsButton: false,
maxHeight: 300,
novel: state.novel,
);
}
Widget _buildSummarySection(BuildContext context, EditorLoaded state) {
final hasContent = _summaryController.text.isNotEmpty;
final activeScene = _getActiveScene(state);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'生成的摘要',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.black,
),
),
if (hasContent && !_isGenerating) ...[
Row(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey[300]!),
borderRadius: BorderRadius.circular(4),
),
child: IconButton(
icon: const Icon(Icons.copy, size: 14, color: Colors.black),
tooltip: '复制到剪贴板',
constraints: const BoxConstraints(),
padding: EdgeInsets.zero,
onPressed: () {
_copyToClipboard(_summaryController.text);
},
),
),
const SizedBox(width: 6),
if (activeScene != null) ...[
SizedBox(
height: 28,
child: ElevatedButton(
onPressed: _summaryController.text.trim().isEmpty
? null
: () => _saveSummary(context, state, activeScene),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
disabledBackgroundColor: Colors.grey[200],
disabledForegroundColor: Colors.grey,
side: BorderSide(color: Colors.grey[300]!),
padding: const EdgeInsets.symmetric(horizontal: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
),
child: const Text(
'保存摘要',
style: TextStyle(fontSize: 12),
),
),
),
],
],
),
],
],
),
const SizedBox(height: 8),
Expanded(
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: Colors.grey[300]!,
width: 1,
),
),
clipBehavior: Clip.antiAlias,
child: _isGenerating && _summaryController.text.isEmpty
? const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.black),
),
),
SizedBox(height: 12),
Text(
'正在生成摘要...',
style: TextStyle(
fontSize: 13,
color: Colors.black,
),
),
],
),
)
: !hasContent && !_isGenerating
? const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.summarize,
color: Colors.grey,
size: 32,
),
SizedBox(height: 12),
Text(
'点击"生成摘要"按钮开始生成',
style: TextStyle(
fontSize: 13,
color: Colors.grey,
),
),
],
),
)
: TextField(
controller: _summaryController,
maxLines: null,
expands: true,
decoration: const InputDecoration(
contentPadding: EdgeInsets.all(12),
border: InputBorder.none,
hintText: '生成的摘要将显示在这里',
hintStyle: TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
style: const TextStyle(
fontSize: 13,
height: 1.4,
color: Colors.black,
),
onChanged: (_) {
setState(() {
// _contentEdited = true;
});
},
),
),
),
],
);
}
/// 检查是否是摘要生成请求
bool _isSummaryRequest(UniversalAIState state) {
// 对于流式响应状态,只有当前实例发起的请求才处理
if (state is UniversalAIStreaming) {
return _thisInstanceIsGenerating;
}
// 对于成功状态,检查请求类型
else if (state is UniversalAISuccess) {
return state.response.requestType == AIRequestType.sceneSummary;
}
// 对于错误和加载状态,检查当前实例是否有生成任务
else if (state is UniversalAIError || state is UniversalAILoading) {
return _thisInstanceIsGenerating;
}
return false;
}
/// 生成摘要
void _generateSummary(BuildContext context, EditorLoaded state) {
final activeScene = _getActiveScene(state);
if (activeScene == null || _selectedModel == null) return;
// 清空现有内容
_summaryController.clear();
AppLogger.i('AISummaryPanel', '开始生成摘要场景ID: ${activeScene.id}');
// 使用公共逻辑创建模型配置(公共模型会被包装为临时配置)
final modelConfig = createModelConfig(_selectedModel!);
// 构建AI请求先将Quill内容转换为纯文本
final String plainSceneText = QuillHelper.deltaToText(activeScene.content);
// 构建元数据(包含公共模型标识)
final metadata = createModelMetadata(_selectedModel!, {
'actId': state.activeActId,
'chapterId': state.activeChapterId,
'sceneId': state.activeSceneId,
'sceneTitle': activeScene.title,
'wordCount': activeScene.wordCount,
'action': 'scene_summary',
'source': 'ai_summary_panel',
});
final request = UniversalAIRequest(
requestType: AIRequestType.sceneSummary,
userId: AppConfig.userId ?? 'unknown',
novelId: widget.novelId,
modelConfig: modelConfig,
selectedText: plainSceneText, // 使用纯文本作为输入
instructions: '请为这个小说场景生成一个准确、简洁的摘要,突出关键情节和重要细节。',
contextSelections: _contextSelectionData,
enableSmartContext: _enableSmartContext,
parameters: {
'temperature': 0.7,
'maxTokens': 500,
'promptTemplateId': _selectedPromptTemplateId,
if (_customSystemPrompt != null) 'customSystemPrompt': _customSystemPrompt,
if (_customUserPrompt != null) 'customUserPrompt': _customUserPrompt,
},
metadata: metadata,
);
// 公共模型预估积分并确认
if (_selectedModel!.isPublic) {
handlePublicModelCreditConfirmation(_selectedModel!, request).then((ok) {
if (!ok) return;
setState(() { _thisInstanceIsGenerating = true; });
context.read<UniversalAIBloc>().add(SendAIStreamRequestEvent(request));
});
return;
}
// 发送流式请求(私有模型直接发送)
setState(() { _thisInstanceIsGenerating = true; });
context.read<UniversalAIBloc>().add(SendAIStreamRequestEvent(request));
}
void _saveSummary(BuildContext context, EditorLoaded state, Scene activeScene) {
final summary = _summaryController.text.trim();
if (summary.isEmpty) return;
// 保存摘要到场景
context.read<EditorBloc>().add(
UpdateSummary(
novelId: widget.novelId,
actId: state.activeActId!,
chapterId: state.activeChapterId!,
sceneId: activeScene.id,
summary: summary,
),
);
// 显示保存成功提示
TopToast.success(context, '摘要已保存');
// 已移除未使用的编辑状态标记
AppLogger.i('AISummaryPanel', '摘要已保存: ${activeScene.id}');
}
// 获取当前活动场景
Scene? _getActiveScene(EditorLoaded state) {
if (state.activeSceneId != null && state.activeActId != null && state.activeChapterId != null) {
// 获取完整的场景对象而不仅仅是ID
final scene = state.novel.getScene(state.activeActId!, state.activeChapterId!, sceneId: state.activeSceneId);
return scene;
}
return null;
}
}

View File

@@ -0,0 +1,408 @@
import 'package:ainoval/models/user_ai_model_config_model.dart';
import 'package:ainoval/services/api_service/repositories/user_ai_model_config_repository.dart';
import 'package:ainoval/utils/logger.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:ainoval/widgets/common/top_toast.dart';
/// 自动续写表单组件
class ContinueWritingForm extends StatefulWidget {
const ContinueWritingForm({
super.key,
required this.novelId,
required this.userId,
required this.onCancel,
required this.onSubmit,
required this.userAiModelConfigRepository,
});
final String novelId;
final String userId;
final VoidCallback onCancel;
final Function(Map<String, dynamic> parameters) onSubmit;
final UserAIModelConfigRepository userAiModelConfigRepository;
@override
State<ContinueWritingForm> createState() => _ContinueWritingFormState();
}
class _ContinueWritingFormState extends State<ContinueWritingForm> {
final _formKey = GlobalKey<FormState>();
final _numberOfChaptersController = TextEditingController(text: '1');
final _contextChapterCountController = TextEditingController(text: '3');
final _customContextController = TextEditingController();
final _writingStyleController = TextEditingController();
List<UserAIModelConfigModel> _aiConfigs = [];
bool _isLoadingConfigs = true;
bool _isSubmitting = false;
String? _selectedSummaryConfigId;
String? _selectedContentConfigId;
String _startContextMode = 'AUTO'; // 默认为自动模式
@override
void initState() {
super.initState();
_loadAiConfigs();
}
@override
void dispose() {
_numberOfChaptersController.dispose();
_contextChapterCountController.dispose();
_customContextController.dispose();
_writingStyleController.dispose();
super.dispose();
}
Future<void> _loadAiConfigs() async {
setState(() {
_isLoadingConfigs = true;
});
try {
final configs = await widget.userAiModelConfigRepository.listConfigurations(
userId: widget.userId,
validatedOnly: true,
);
setState(() {
_aiConfigs = configs;
_isLoadingConfigs = false;
// 如果有配置,预选第一个
if (configs.isNotEmpty) {
_selectedSummaryConfigId = configs.first.id;
_selectedContentConfigId = configs.first.id;
}
});
} catch (e) {
AppLogger.e('ContinueWritingForm', '加载AI配置失败', e);
setState(() {
_isLoadingConfigs = false;
});
if (mounted) {
TopToast.error(context, '加载AI配置失败: ${e.toString()}');
}
}
}
void _submitForm() async {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() {
_isSubmitting = true;
});
try {
final parameters = <String, dynamic>{
'novelId': widget.novelId,
'numberOfChapters': int.parse(_numberOfChaptersController.text),
'aiConfigIdSummary': _selectedSummaryConfigId,
'aiConfigIdContent': _selectedContentConfigId,
'startContextMode': _startContextMode,
};
// 根据上下文模式添加对应参数
if (_startContextMode == 'LAST_N_CHAPTERS') {
parameters['contextChapterCount'] = int.parse(_contextChapterCountController.text);
} else if (_startContextMode == 'CUSTOM') {
parameters['customContext'] = _customContextController.text;
}
// 添加写作风格参数(如果有)
if (_writingStyleController.text.isNotEmpty) {
parameters['writingStyle'] = _writingStyleController.text;
}
// 提交表单
widget.onSubmit(parameters);
} catch (e) {
AppLogger.e('ContinueWritingForm', '提交表单失败', e);
if (mounted) {
TopToast.error(context, '提交失败: ${e.toString()}');
}
} finally {
setState(() {
_isSubmitting = false;
});
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 3,
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'自动续写设置',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: widget.onCancel,
splashRadius: 20,
),
],
),
const SizedBox(height: 16),
// 表单
Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 续写章节数
TextFormField(
controller: _numberOfChaptersController,
decoration: const InputDecoration(
labelText: '续写章节数',
helperText: '设置要自动续写的章节数量',
prefixIcon: Icon(Icons.book_outlined),
),
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
validator: (value) {
if (value == null || value.isEmpty) {
return '请输入续写章节数';
}
final number = int.tryParse(value);
if (number == null || number <= 0) {
return '请输入有效的章节数';
}
return null;
},
),
const SizedBox(height: 16),
// 摘要模型选择
_isLoadingConfigs
? const Center(child: CircularProgressIndicator())
: DropdownButtonFormField<String>(
decoration: const InputDecoration(
labelText: '摘要生成模型',
helperText: '选择用于生成章节摘要的AI模型',
prefixIcon: Icon(Icons.summarize_outlined),
),
value: _selectedSummaryConfigId,
items: _aiConfigs
.map((config) => DropdownMenuItem<String>(
value: config.id,
child: Text(config.alias ?? config.modelName),
))
.toList(),
onChanged: (value) {
setState(() {
_selectedSummaryConfigId = value;
});
},
validator: (value) {
if (value == null || value.isEmpty) {
return '请选择摘要生成模型';
}
return null;
},
),
const SizedBox(height: 16),
// 内容模型选择
_isLoadingConfigs
? const Center(child: CircularProgressIndicator())
: DropdownButtonFormField<String>(
decoration: const InputDecoration(
labelText: '内容生成模型',
helperText: '选择用于生成章节内容的AI模型',
prefixIcon: Icon(Icons.text_fields),
),
value: _selectedContentConfigId,
items: _aiConfigs
.map((config) => DropdownMenuItem<String>(
value: config.id,
child: Text(config.alias ?? config.modelName),
))
.toList(),
onChanged: (value) {
setState(() {
_selectedContentConfigId = value;
});
},
validator: (value) {
if (value == null || value.isEmpty) {
return '请选择内容生成模型';
}
return null;
},
),
const SizedBox(height: 16),
// 上下文模式选择
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'上下文模式',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
),
const SizedBox(height: 8),
// 上下文模式单选组
Wrap(
spacing: 8,
children: [
_buildContextModeRadio('自动', 'AUTO'),
_buildContextModeRadio('最近N章', 'LAST_N_CHAPTERS'),
_buildContextModeRadio('自定义', 'CUSTOM'),
],
),
const SizedBox(height: 4),
Text(
'选择AI续写时使用的上下文模式',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.5),
),
),
],
),
const SizedBox(height: 16),
// 上下文章节数仅当模式为LAST_N_CHAPTERS时显示
if (_startContextMode == 'LAST_N_CHAPTERS')
TextFormField(
controller: _contextChapterCountController,
decoration: const InputDecoration(
labelText: '上下文章节数',
helperText: '设置AI生成时参考的最近章节数量',
prefixIcon: Icon(Icons.format_list_numbered),
),
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
validator: (value) {
if (value == null || value.isEmpty) {
return '请输入上下文章节数';
}
final number = int.tryParse(value);
if (number == null || number <= 0) {
return '请输入有效的章节数';
}
return null;
},
),
// 自定义上下文仅当模式为CUSTOM时显示
if (_startContextMode == 'CUSTOM')
TextFormField(
controller: _customContextController,
decoration: const InputDecoration(
labelText: '自定义上下文',
helperText: '输入AI生成时参考的自定义上下文内容',
prefixIcon: Icon(Icons.description_outlined),
),
maxLines: 3,
validator: (value) {
if (value == null || value.isEmpty) {
return '请输入自定义上下文';
}
if (value.length < 10) {
return '上下文内容过短,请提供更详细的信息';
}
return null;
},
),
const SizedBox(height: 16),
// 写作风格(可选)
TextFormField(
controller: _writingStyleController,
decoration: const InputDecoration(
labelText: '写作风格提示 (可选)',
helperText: '描述期望的写作风格,例如:悬疑、浪漫、幽默等',
prefixIcon: Icon(Icons.style),
),
maxLines: 1,
),
const SizedBox(height: 24),
// 提交按钮
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
OutlinedButton(
onPressed: _isSubmitting ? null : widget.onCancel,
child: const Text('取消'),
),
const SizedBox(width: 16),
ElevatedButton(
onPressed: _isSubmitting ? null : _submitForm,
child: _isSubmitting
? Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: theme.colorScheme.onPrimary,
),
),
const SizedBox(width: 8),
const Text('提交中...'),
],
)
: const Text('开始任务'),
),
],
),
],
),
),
],
),
),
);
}
// 构建上下文模式单选按钮
Widget _buildContextModeRadio(String label, String value) {
return FilterChip(
label: Text(label),
selected: _startContextMode == value,
onSelected: (selected) {
if (selected) {
setState(() {
_startContextMode = value;
});
}
},
);
}
}

View File

@@ -0,0 +1,354 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// 通用下拉菜单组件,用于替换项目中的三点水下拉菜单
class CustomDropdown extends StatefulWidget {
/// 触发下拉菜单的小部件
final Widget trigger;
/// 下拉菜单内容
final Widget child;
/// 下拉菜单宽度
final double width;
/// 下拉菜单对齐方式 ('left' 或 'right')
final String align;
/// 是否为暗色主题
final bool isDarkTheme;
/// 菜单出现/消失的动画时长
final Duration animationDuration;
const CustomDropdown({
Key? key,
required this.trigger,
required this.child,
this.width = 240,
this.align = 'left',
this.isDarkTheme = false,
this.animationDuration = const Duration(milliseconds: 150),
}) : super(key: key);
@override
State<CustomDropdown> createState() => _CustomDropdownState();
}
class _CustomDropdownState extends State<CustomDropdown> {
bool isOpen = false;
final LayerLink _layerLink = LayerLink();
OverlayEntry? _overlayEntry;
final FocusNode _focusNode = FocusNode();
@override
void initState() {
super.initState();
_focusNode.addListener(_onFocusChange);
}
@override
void dispose() {
_removeOverlay();
_focusNode.removeListener(_onFocusChange);
_focusNode.dispose();
super.dispose();
}
void _onFocusChange() {
if (!_focusNode.hasFocus && isOpen) {
_closeDropdown();
}
}
void _toggleDropdown() {
if (isOpen) {
_closeDropdown();
} else {
_openDropdown();
}
}
void _closeDropdown() {
_removeOverlay();
setState(() {
isOpen = false;
});
}
void _openDropdown() {
_showOverlay();
setState(() {
isOpen = true;
});
_focusNode.requestFocus();
}
void _removeOverlay() {
_overlayEntry?.remove();
_overlayEntry = null;
}
void _showOverlay() {
_overlayEntry = _createOverlayEntry();
Overlay.of(context).insert(_overlayEntry!);
}
OverlayEntry _createOverlayEntry() {
RenderBox renderBox = context.findRenderObject() as RenderBox;
var size = renderBox.size;
var offset = renderBox.localToGlobal(Offset.zero);
return OverlayEntry(
builder: (context) => GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: _closeDropdown,
child: Stack(
children: [
Positioned(
left: widget.align == 'left' ? offset.dx : null,
right: widget.align == 'right' ? (MediaQuery.of(context).size.width - offset.dx - size.width) : null,
top: offset.dy + size.height + 4,
width: widget.width,
child: CompositedTransformFollower(
link: _layerLink,
followerAnchor: widget.align == 'left' ? Alignment.topLeft : Alignment.topRight,
targetAnchor: widget.align == 'left' ? Alignment.bottomLeft : Alignment.bottomRight,
offset: const Offset(0, 4),
child: TweenAnimationBuilder<double>(
duration: widget.animationDuration,
curve: Curves.easeOutCubic,
tween: Tween<double>(begin: 0.0, end: 1.0),
builder: (context, value, child) => Transform.scale(
scale: 0.95 + (0.05 * value),
alignment: widget.align == 'left'
? Alignment.topLeft
: Alignment.topRight,
child: Opacity(
opacity: value,
child: child,
),
),
child: Material(
elevation: 8,
borderRadius: BorderRadius.circular(8),
color: widget.isDarkTheme ? Colors.grey[850] : Colors.white,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 4),
child: _wrapChildWithCloseCallback(widget.child),
),
),
),
),
),
],
),
),
);
}
Widget _wrapChildWithCloseCallback(Widget child) {
if (child is Column) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: child.children.map((item) {
if (item is DropdownItem) {
return DropdownItem(
icon: item.icon,
label: item.label,
onTap: item.onTap,
hasSubmenu: item.hasSubmenu,
disabled: item.disabled,
isDarkTheme: item.isDarkTheme,
isDangerous: item.isDangerous,
onClose: _closeDropdown,
);
}
if (item is DropdownSection) {
return DropdownSection(
title: item.title,
children: item.children.map((sectionItem) {
if (sectionItem is DropdownItem) {
return DropdownItem(
icon: sectionItem.icon,
label: sectionItem.label,
onTap: sectionItem.onTap,
hasSubmenu: sectionItem.hasSubmenu,
disabled: sectionItem.disabled,
isDarkTheme: sectionItem.isDarkTheme,
isDangerous: sectionItem.isDangerous,
onClose: _closeDropdown,
);
}
return sectionItem;
}).toList(),
isDarkTheme: item.isDarkTheme,
dividerAtBottom: item.dividerAtBottom,
);
}
return item;
}).toList(),
);
}
return child;
}
@override
Widget build(BuildContext context) {
return KeyboardListener(
focusNode: _focusNode,
onKeyEvent: (keyEvent) {
if (keyEvent is KeyDownEvent && keyEvent.logicalKey == LogicalKeyboardKey.escape) {
_closeDropdown();
}
},
child: CompositedTransformTarget(
link: _layerLink,
child: GestureDetector(
onTap: _toggleDropdown,
child: widget.trigger,
),
),
);
}
}
/// 下拉菜单项
class DropdownItem extends StatelessWidget {
final IconData icon;
final String label;
final Future<void> Function()? onTap;
final bool hasSubmenu;
final bool disabled;
final bool isDarkTheme;
final bool isDangerous;
final VoidCallback? onClose;
const DropdownItem({
Key? key,
required this.icon,
required this.label,
this.onTap,
this.hasSubmenu = false,
this.disabled = false,
this.isDarkTheme = false,
this.isDangerous = false,
this.onClose,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return InkWell(
onTap: disabled
? null
: () async {
if (onTap != null) {
await onTap!();
}
onClose?.call();
},
child: Opacity(
opacity: disabled ? 0.5 : 1.0,
child: Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Icon(
icon,
size: 20,
color: isDangerous
? Colors.red.shade700
: (isDarkTheme ? Colors.white70 : Colors.black87)
),
const SizedBox(width: 12),
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 14,
color: isDangerous
? Colors.red.shade700
: (isDarkTheme ? Colors.white : Colors.black87),
),
),
),
if (hasSubmenu)
Icon(
Icons.chevron_right,
size: 16,
color: isDarkTheme ? Colors.white38 : Colors.black45,
),
],
),
),
),
);
}
}
/// 下拉菜单分区
class DropdownSection extends StatelessWidget {
final String? title;
final List<Widget> children;
final bool isDarkTheme;
final bool dividerAtBottom;
const DropdownSection({
Key? key,
this.title,
required this.children,
this.isDarkTheme = false,
this.dividerAtBottom = true,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (title != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Text(
title!,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: isDarkTheme ? Colors.white54 : Colors.black54,
letterSpacing: 0.5,
),
),
),
...children,
if (dividerAtBottom)
Divider(
height: 8,
thickness: 1,
color: isDarkTheme ? Colors.white12 : Colors.black12,
),
],
);
}
}
/// 下拉菜单分隔线
class DropdownDivider extends StatelessWidget {
final bool isDarkTheme;
const DropdownDivider({
Key? key,
this.isDarkTheme = false,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Divider(
height: 8,
thickness: 1,
color: isDarkTheme ? Colors.white12 : Colors.black12,
);
}
}

View File

@@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
/// 对话框工具类
///
/// 用于创建和显示各种常用对话框
class DialogUtils {
/// 显示确认对话框
static Future<bool> showConfirmDialog({
required BuildContext context,
required String title,
required String message,
String confirmText = '确认',
String cancelText = '取消',
bool isDangerous = false,
}) async {
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(cancelText),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text(confirmText),
style: TextButton.styleFrom(
foregroundColor: isDangerous ? Colors.red : null,
),
),
],
),
);
return result ?? false;
}
/// 显示危险操作确认对话框
static Future<bool> showDangerousConfirmDialog({
required BuildContext context,
required String title,
required String message,
String confirmText = '删除',
String cancelText = '取消',
}) async {
return showConfirmDialog(
context: context,
title: title,
message: message,
confirmText: confirmText,
cancelText: cancelText,
isDangerous: true,
);
}
/// 显示删除确认对话框
static Future<bool> showDeleteConfirmDialog({
required BuildContext context,
required String itemType,
String? itemName,
}) async {
final title = '删除$itemType';
final message = itemName != null
? '确定要删除"$itemName"吗?此操作不可撤销。'
: '确定要删除这个$itemType吗?此操作不可撤销。';
return showDangerousConfirmDialog(
context: context,
title: title,
message: message,
);
}
/// 显示输入对话框
static Future<String?> showInputDialog({
required BuildContext context,
required String title,
String? initialValue,
String hintText = '',
String confirmText = '确认',
String cancelText = '取消',
}) async {
final controller = TextEditingController(text: initialValue);
final result = await showDialog<String?>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: TextField(
controller: controller,
decoration: InputDecoration(
hintText: hintText,
),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, null),
child: Text(cancelText),
),
TextButton(
onPressed: () => Navigator.pop(context, controller.text),
child: Text(confirmText),
),
],
),
);
return result;
}
/// 显示重命名对话框
static Future<String?> showRenameDialog({
required BuildContext context,
required String itemType,
required String currentName,
}) async {
return showInputDialog(
context: context,
title: '重命名$itemType',
initialValue: currentName,
hintText: '输入新的名称',
);
}
}

View File

@@ -0,0 +1,469 @@
import 'package:ainoval/blocs/editor/editor_bloc.dart';
import 'package:ainoval/screens/editor/widgets/custom_dropdown.dart';
import 'package:ainoval/screens/editor/widgets/menu_definitions.dart';
import 'package:ainoval/screens/editor/widgets/preset_menu_definitions.dart';
import 'package:ainoval/services/ai_preset_service.dart';
import 'package:ainoval/models/preset_models.dart';
import 'package:flutter/material.dart';
/// 下拉菜单管理器
///
/// 用于统一构建和管理所有下拉菜单包括Act、Chapter、Scene和Model的菜单
class DropdownManager {
/// 菜单构建上下文
final BuildContext context;
/// 编辑器状态管理模型菜单时可为null
final EditorBloc? editorBloc;
/// 菜单显示设置
final DropdownDisplaySettings displaySettings;
DropdownManager({
required this.context,
required this.editorBloc,
this.displaySettings = const DropdownDisplaySettings(),
});
/// 构建Act菜单
Widget buildActMenu({
required String actId,
Function()? onRenamePressed,
IconData? icon,
String? tooltip,
}) {
return _buildMenu(
menuItems: ActMenuDefinitions.getMenuItems(),
id: actId,
secondaryId: null,
tertiaryId: null,
onRenamePressed: onRenamePressed,
icon: icon ?? Icons.more_vert,
tooltip: tooltip ?? 'Act操作',
width: displaySettings.actMenuWidth,
align: displaySettings.actMenuAlign,
);
}
/// 构建Chapter菜单
Widget buildChapterMenu({
required String actId,
required String chapterId,
Function()? onRenamePressed,
IconData? icon,
String? tooltip,
}) {
// 动态统计该章节下的场景数量,用作菜单顶部信息
int? sceneCount;
try {
final state = editorBloc?.state;
if (state is EditorLoaded) {
final novel = state.novel;
for (final act in novel.acts) {
if (act.id == actId) {
for (final chapter in act.chapters) {
if (chapter.id == chapterId) {
sceneCount = chapter.scenes.length;
break;
}
}
break;
}
}
}
} catch (_) {}
// 构建带有“章节信息共N个场景”的菜单项放在最前面
final List<dynamic> items = [];
if (sceneCount != null) {
items.add(MenuItemData(
icon: Icons.info_outline,
label: '${sceneCount}个场景',
onTap: null,
disabled: true,
));
items.add("divider");
}
items.addAll(ChapterMenuDefinitions.getMenuItems());
return _buildMenu(
menuItems: items,
id: actId,
secondaryId: chapterId,
tertiaryId: null,
onRenamePressed: onRenamePressed,
icon: icon ?? Icons.more_vert,
tooltip: tooltip ?? '章节操作',
width: displaySettings.chapterMenuWidth,
align: displaySettings.chapterMenuAlign,
);
}
/// 构建Scene菜单
Widget buildSceneMenu({
required String actId,
required String chapterId,
required String sceneId,
IconData? icon,
String? tooltip,
}) {
return _buildMenu(
menuItems: SceneMenuDefinitions.getMenuItems(),
id: actId,
secondaryId: chapterId,
tertiaryId: sceneId,
icon: icon ?? Icons.more_horiz,
tooltip: tooltip ?? '场景操作',
width: displaySettings.sceneMenuWidth,
align: displaySettings.sceneMenuAlign,
);
}
/// 构建Model菜单
Widget buildModelMenu({
required String configId,
required bool isValidated,
required bool isDefault,
required Future<void> Function(String) onValidate,
required Future<void> Function(String) onSetDefault,
required Future<void> Function(String) onEdit,
required Future<void> Function(String) onDelete,
IconData? icon,
String? tooltip,
}) {
final menuItems = ModelMenuDefinitions.getMenuItems(
isValidated: isValidated,
isDefault: isDefault,
onValidate: onValidate,
onSetDefault: onSetDefault,
onEdit: onEdit,
onDelete: onDelete,
);
return _buildModelMenu(
menuItems: menuItems,
configId: configId,
icon: icon ?? Icons.more_vert,
tooltip: tooltip ?? '模型操作',
width: displaySettings.modelMenuWidth,
align: displaySettings.modelMenuAlign,
);
}
/// 构建预设菜单
Widget buildPresetMenu({
required String featureType,
required Function() onCreatePreset,
required Function() onManagePresets,
required Function(AIPromptPreset preset) onPresetSelected,
IconData? icon,
String? tooltip,
}) {
return CustomDropdown(
width: displaySettings.presetMenuWidth,
align: displaySettings.presetMenuAlign,
trigger: IconButton(
icon: Icon(icon ?? Icons.bookmark_border, size: 18),
onPressed: null, // 由CustomDropdown处理点击
tooltip: tooltip ?? '预设管理',
color: Theme.of(context).colorScheme.onSurfaceVariant,
splashRadius: 20,
),
child: FutureBuilder<List<dynamic>>(
future: PresetMenuDefinitions.getDynamicMenuItems(
featureType: featureType,
onCreatePreset: onCreatePreset,
onManagePresets: onManagePresets,
onPresetSelected: onPresetSelected,
),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Padding(
padding: EdgeInsets.all(16.0),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
final menuItems = snapshot.data ?? [];
return Column(
mainAxisSize: MainAxisSize.min,
children: _buildPresetMenuItemWidgets(
menuItems,
featureType,
),
);
},
),
);
}
/// 内部方法:构建通用菜单
Widget _buildMenu({
required List<dynamic> menuItems,
required String id,
String? secondaryId,
String? tertiaryId,
Function()? onRenamePressed,
required IconData icon,
required String tooltip,
double width = 240,
String align = 'left',
}) {
return CustomDropdown(
width: width,
align: align,
trigger: IconButton(
icon: Icon(icon, size: 20),
onPressed: null, // 由CustomDropdown处理点击
tooltip: tooltip,
color: Theme.of(context).colorScheme.onSurfaceVariant,
splashRadius: 20,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: _buildMenuItemWidgets(
menuItems,
id,
secondaryId,
tertiaryId,
onRenamePressed,
),
),
);
}
/// 内部方法:构建模型菜单
Widget _buildModelMenu({
required List<dynamic> menuItems,
required String configId,
required IconData icon,
required String tooltip,
double width = 180,
String align = 'right',
}) {
return CustomDropdown(
width: width,
align: align,
trigger: IconButton(
icon: Icon(icon, size: 16),
onPressed: null, // 由CustomDropdown处理点击
tooltip: tooltip,
color: Theme.of(context).colorScheme.onSurfaceVariant,
splashRadius: 20,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: _buildModelMenuItemWidgets(
menuItems,
configId,
),
),
);
}
/// 构建菜单项列表
List<Widget> _buildMenuItemWidgets(
List<dynamic> menuItems,
String id,
String? secondaryId,
String? tertiaryId,
Function()? onRenamePressed,
) {
final List<Widget> widgets = [];
for (final item in menuItems) {
if (item is String && item == "divider") {
widgets.add(const DropdownDivider());
} else if (item is MenuSectionData) {
widgets.add(
DropdownSection(
title: item.title,
children: item.items.map((menuItem) {
return _buildSingleMenuItem(
menuItem,
id,
secondaryId,
tertiaryId,
onRenamePressed,
);
}).toList(),
),
);
} else if (item is MenuItemData) {
widgets.add(
_buildSingleMenuItem(
item,
id,
secondaryId,
tertiaryId,
onRenamePressed,
),
);
}
}
return widgets;
}
/// 构建模型菜单项列表
List<Widget> _buildModelMenuItemWidgets(
List<dynamic> menuItems,
String configId,
) {
final List<Widget> widgets = [];
for (final item in menuItems) {
if (item is String && item == "divider") {
widgets.add(const DropdownDivider());
} else if (item is ModelMenuSectionData) {
widgets.add(
DropdownSection(
title: item.title,
children: item.items.map((menuItem) {
return _buildSingleModelMenuItem(menuItem, configId);
}).toList(),
),
);
} else if (item is ModelMenuItemData) {
widgets.add(_buildSingleModelMenuItem(item, configId));
}
}
return widgets;
}
/// 构建单个菜单项
Widget _buildSingleMenuItem(
MenuItemData item,
String id,
String? secondaryId,
String? tertiaryId,
Function()? onRenamePressed,
) {
// 特殊处理重命名操作因为需要直接访问State
Future<void> Function()? onTapHandler;
if (item.label == '重命名Act' || item.label == '重命名章节') {
onTapHandler = null;
} else if (item.onTap != null) {
onTapHandler = () async {
await item.onTap!(context, editorBloc!, id, secondaryId, tertiaryId);
};
}
return DropdownItem(
icon: item.icon,
label: item.label,
hasSubmenu: item.hasSubmenu,
disabled: item.disabled,
isDangerous: item.isDangerous,
onTap: onTapHandler,
);
}
/// 构建单个模型菜单项
Widget _buildSingleModelMenuItem(
ModelMenuItemData item,
String configId,
) {
Future<void> Function()? onTapHandler;
if (item.onTap != null) {
onTapHandler = () async {
await item.onTap!(configId);
};
}
return DropdownItem(
icon: item.icon,
label: item.label,
hasSubmenu: item.hasSubmenu,
disabled: item.disabled,
isDangerous: item.isDangerous,
onTap: onTapHandler,
);
}
/// 构建预设菜单项列表
List<Widget> _buildPresetMenuItemWidgets(
List<dynamic> menuItems,
String featureType,
) {
final List<Widget> widgets = [];
final presetService = AIPresetService();
for (final item in menuItems) {
if (item is String && item == "divider") {
widgets.add(const DropdownDivider());
} else if (item is PresetMenuSectionData) {
widgets.add(
DropdownSection(
title: item.title,
children: item.items.map((menuItem) {
return _buildSinglePresetMenuItem(menuItem, presetService, featureType);
}).toList(),
dividerAtBottom: item.dividerAtBottom,
),
);
} else if (item is PresetMenuItemData) {
widgets.add(_buildSinglePresetMenuItem(item, presetService, featureType));
}
}
return widgets;
}
/// 构建单个预设菜单项
Widget _buildSinglePresetMenuItem(
PresetMenuItemData item,
AIPresetService presetService,
String featureType,
) {
Future<void> Function()? onTapHandler;
if (item.onTap != null) {
onTapHandler = () async {
await item.onTap!(context, presetService, featureType);
};
}
return DropdownItem(
icon: item.icon,
label: item.label,
hasSubmenu: item.hasSubmenu,
disabled: item.disabled,
isDangerous: item.isDangerous,
onTap: onTapHandler,
);
}
}
/// 下拉菜单显示设置
class DropdownDisplaySettings {
final double actMenuWidth;
final double chapterMenuWidth;
final double sceneMenuWidth;
final double modelMenuWidth;
final double presetMenuWidth;
final String actMenuAlign;
final String chapterMenuAlign;
final String sceneMenuAlign;
final String modelMenuAlign;
final String presetMenuAlign;
const DropdownDisplaySettings({
this.actMenuWidth = 240,
this.chapterMenuWidth = 240,
this.sceneMenuWidth = 240,
this.modelMenuWidth = 180,
this.presetMenuWidth = 280,
this.actMenuAlign = 'left',
this.chapterMenuAlign = 'right',
this.sceneMenuAlign = 'right',
this.modelMenuAlign = 'right',
this.presetMenuAlign = 'right',
});
}

View File

@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:ainoval/models/novel_setting_item.dart';
import 'package:ainoval/models/setting_group.dart';
import 'package:ainoval/screens/editor/widgets/novel_setting_detail.dart';
import 'package:ainoval/screens/editor/widgets/novel_setting_group_dialog.dart';
import 'package:ainoval/screens/editor/widgets/novel_setting_group_selection_dialog.dart';
import 'package:ainoval/screens/editor/widgets/novel_setting_relationship_dialog.dart';
/// 统一的浮动设定对话框管理器
class FloatingSettingDialogs {
/// 显示设定详情编辑卡片
static void showSettingDetail({
required BuildContext context,
String? itemId,
required String novelId,
String? groupId,
bool isEditing = false,
required Function(NovelSettingItem, String?) onSave,
required VoidCallback onCancel,
}) {
// 使用浮动设定详情管理器
FloatingNovelSettingDetail.show(
context: context,
itemId: itemId,
novelId: novelId,
groupId: groupId,
isEditing: isEditing,
onSave: onSave,
onCancel: onCancel,
);
}
/// 显示设定组管理卡片
static void showSettingGroup({
required BuildContext context,
required String novelId,
SettingGroup? group,
required Function(SettingGroup) onSave,
}) {
// 使用浮动设定组管理器
FloatingNovelSettingGroupDialog.show(
context: context,
novelId: novelId,
group: group,
onSave: onSave,
);
}
/// 显示设定组选择卡片
static void showSettingGroupSelection({
required BuildContext context,
required String novelId,
required Function(String groupId, String groupName) onGroupSelected,
}) {
// 使用浮动设定组选择管理器
FloatingNovelSettingGroupSelectionDialog.show(
context: context,
novelId: novelId,
onGroupSelected: onGroupSelected,
);
}
/// 显示设定关系创建卡片
static void showSettingRelationship({
required BuildContext context,
required String novelId,
required String sourceItemId,
required String sourceName,
required List<NovelSettingItem> availableTargets,
required Function(String relationType, String targetItemId, String? description) onSave,
}) {
// 使用浮动设定关系管理器
FloatingNovelSettingRelationshipDialog.show(
context: context,
novelId: novelId,
sourceItemId: sourceItemId,
sourceName: sourceName,
availableTargets: availableTargets,
onSave: onSave,
);
}
}

View File

@@ -0,0 +1,160 @@
import 'package:flutter/material.dart';
import 'package:ainoval/models/novel_structure.dart';
/// 生成场景对话框结果
class GenerateSceneDialogResult {
final String summary;
final String? chapterId;
final String? styleInstructions;
GenerateSceneDialogResult({
required this.summary,
this.chapterId,
this.styleInstructions,
});
}
/// 生成场景对话框,用于输入摘要/大纲然后触发AI生成场景内容
class GenerateSceneDialog extends StatefulWidget {
const GenerateSceneDialog({
Key? key,
required this.novel,
this.initialSummary = '',
this.initialChapterId,
}) : super(key: key);
/// 当前小说
final Novel novel;
/// 初始摘要文本
final String initialSummary;
/// 初始章节ID
final String? initialChapterId;
@override
State<GenerateSceneDialog> createState() => _GenerateSceneDialogState();
}
class _GenerateSceneDialogState extends State<GenerateSceneDialog> {
final TextEditingController _summaryController = TextEditingController();
final TextEditingController _styleController = TextEditingController();
String? _selectedChapterId;
@override
void initState() {
super.initState();
_summaryController.text = widget.initialSummary;
_selectedChapterId = widget.initialChapterId;
}
@override
void dispose() {
_summaryController.dispose();
_styleController.dispose();
super.dispose();
}
/// 准备章节列表,包含篇章>章节层级
List<DropdownMenuItem<String>> _buildChapterItems() {
final items = <DropdownMenuItem<String>>[];
// 空选项
items.add(const DropdownMenuItem<String>(
value: null,
child: Text('(无指定章节)'),
));
// 遍历篇章和章节
for (final act in widget.novel.acts) {
for (final chapter in act.chapters) {
items.add(DropdownMenuItem<String>(
value: chapter.id,
child: Text('${act.title} > ${chapter.title}'),
));
}
}
return items;
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('AI 生成场景内容'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 摘要/大纲输入
TextField(
controller: _summaryController,
maxLines: 5,
decoration: const InputDecoration(
labelText: '场景摘要/大纲 *',
hintText: '请输入场景的摘要或大纲AI将根据此内容生成详细场景',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
// 章节选择
DropdownButtonFormField<String>(
value: _selectedChapterId,
decoration: const InputDecoration(
labelText: '选择章节(可选)',
border: OutlineInputBorder(),
),
items: _buildChapterItems(),
onChanged: (value) {
setState(() {
_selectedChapterId = value;
});
},
),
const SizedBox(height: 16),
// 风格指令
TextField(
controller: _styleController,
decoration: const InputDecoration(
labelText: '风格指令(可选)',
hintText: '例如:多对话,少描写,悬疑风格',
border: OutlineInputBorder(),
),
),
],
),
),
actions: [
// 取消按钮
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('取消'),
),
// 生成按钮
ElevatedButton(
onPressed: _summaryController.text.trim().isEmpty
? null
: () {
// 返回生成结果
Navigator.of(context).pop(
GenerateSceneDialogResult(
summary: _summaryController.text.trim(),
chapterId: _selectedChapterId,
styleInstructions: _styleController.text.trim().isNotEmpty
? _styleController.text.trim()
: null,
),
);
},
child: const Text('生成'),
),
],
);
}
}

View File

@@ -0,0 +1,116 @@
import 'package:ainoval/blocs/editor/editor_bloc.dart';
import 'package:ainoval/screens/editor/widgets/dropdown_manager.dart';
import 'package:flutter/material.dart';
/// 通用菜单构建器
/// 用于构建Act、Chapter、Scene和Model的下拉菜单
class MenuBuilder {
/// 构建Act菜单
static Widget buildActMenu({
required BuildContext context,
required EditorBloc editorBloc,
required String actId,
required Function()? onRenamePressed,
double width = 240,
String align = 'left',
}) {
final dropdownManager = DropdownManager(
context: context,
editorBloc: editorBloc,
displaySettings: DropdownDisplaySettings(
actMenuWidth: width,
actMenuAlign: align,
),
);
return dropdownManager.buildActMenu(
actId: actId,
onRenamePressed: onRenamePressed,
);
}
/// 构建Chapter菜单
static Widget buildChapterMenu({
required BuildContext context,
required EditorBloc editorBloc,
required String actId,
required String chapterId,
required Function()? onRenamePressed,
double width = 240,
String align = 'right',
}) {
final dropdownManager = DropdownManager(
context: context,
editorBloc: editorBloc,
displaySettings: DropdownDisplaySettings(
chapterMenuWidth: width,
chapterMenuAlign: align,
),
);
return dropdownManager.buildChapterMenu(
actId: actId,
chapterId: chapterId,
onRenamePressed: onRenamePressed,
);
}
/// 构建Scene菜单
static Widget buildSceneMenu({
required BuildContext context,
required EditorBloc editorBloc,
required String actId,
required String chapterId,
required String sceneId,
double width = 240,
String align = 'right',
}) {
final dropdownManager = DropdownManager(
context: context,
editorBloc: editorBloc,
displaySettings: DropdownDisplaySettings(
sceneMenuWidth: width,
sceneMenuAlign: align,
),
);
return dropdownManager.buildSceneMenu(
actId: actId,
chapterId: chapterId,
sceneId: sceneId,
);
}
/// 构建Model菜单
static Widget buildModelMenu({
required BuildContext context,
required String configId,
required bool isValidated,
required bool isDefault,
required Future<void> Function(String) onValidate,
required Future<void> Function(String) onSetDefault,
required Future<void> Function(String) onEdit,
required Future<void> Function(String) onDelete,
double width = 180,
String align = 'right',
}) {
final dropdownManager = DropdownManager(
context: context,
editorBloc: null, // 模型菜单不需要EditorBloc
displaySettings: DropdownDisplaySettings(
modelMenuWidth: width,
modelMenuAlign: align,
),
);
return dropdownManager.buildModelMenu(
configId: configId,
isValidated: isValidated,
isDefault: isDefault,
onValidate: onValidate,
onSetDefault: onSetDefault,
onEdit: onEdit,
onDelete: onDelete,
);
}
}

View File

@@ -0,0 +1,356 @@
import 'package:ainoval/blocs/editor/editor_bloc.dart';
import 'package:ainoval/utils/logger.dart';
import 'package:ainoval/screens/editor/widgets/dialogs.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:ainoval/screens/editor/controllers/editor_screen_controller.dart';
/// 通用菜单项数据模型
class MenuItemData {
final IconData icon;
final String label;
final Future<void> Function(BuildContext, EditorBloc, String, String?, String?)? onTap;
final bool hasSubmenu;
final bool disabled;
final bool isDangerous;
const MenuItemData({
required this.icon,
required this.label,
this.onTap,
this.hasSubmenu = false,
this.disabled = false,
this.isDangerous = false,
});
}
/// 模型菜单项数据模型(扩展用于模型操作)
class ModelMenuItemData {
final IconData icon;
final String label;
final Future<void> Function(String configId)? onTap;
final bool hasSubmenu;
final bool disabled;
final bool isDangerous;
const ModelMenuItemData({
required this.icon,
required this.label,
this.onTap,
this.hasSubmenu = false,
this.disabled = false,
this.isDangerous = false,
});
}
/// 菜单分区数据模型
class MenuSectionData {
final String title;
final List<MenuItemData> items;
const MenuSectionData({
required this.title,
required this.items,
});
}
/// 模型菜单分区数据模型
class ModelMenuSectionData {
final String title;
final List<ModelMenuItemData> items;
const ModelMenuSectionData({
required this.title,
required this.items,
});
}
/// Model菜单定义
class ModelMenuDefinitions {
static List<dynamic> getMenuItems({
required bool isValidated,
required bool isDefault,
required Future<void> Function(String) onValidate,
required Future<void> Function(String) onSetDefault,
required Future<void> Function(String) onEdit,
required Future<void> Function(String) onDelete,
}) {
return [
// 验证操作
ModelMenuItemData(
icon: isValidated ? Icons.verified : Icons.wifi_protected_setup,
label: isValidated ? '重新验证' : '验证连接',
onTap: onValidate,
),
// 设为默认(如果不是默认模型)
if (!isDefault)
ModelMenuItemData(
icon: Icons.star,
label: '设为默认',
onTap: onSetDefault,
),
// 编辑操作
ModelMenuItemData(
icon: Icons.edit,
label: '编辑',
onTap: onEdit,
),
// 分隔线
"divider",
// 危险操作
ModelMenuItemData(
icon: Icons.delete_outline,
label: '删除',
isDangerous: true,
onTap: onDelete,
),
];
}
}
/// Act菜单定义
class ActMenuDefinitions {
static List<dynamic> getMenuItems() {
return [
// 基本操作
MenuItemData(
icon: Icons.add,
label: '添加新章节',
onTap: (context, editorBloc, actId, _, __) async {
editorBloc.add(AddNewChapter(
novelId: editorBloc.novelId,
actId: actId,
title: '新章节 ${DateTime.now().millisecondsSinceEpoch % 100}',
));
},
),
MenuItemData(
icon: Icons.edit,
label: '重命名Act',
onTap: null,
),
// 导出选项
MenuSectionData(
title: '导出选项',
items: [
MenuItemData(
icon: Icons.file_download,
label: '导出为PDF',
onTap: (context, editorBloc, actId, _, __) async {
// 实现导出为PDF功能
},
),
MenuItemData(
icon: Icons.file_download,
label: '导出为Word',
onTap: (context, editorBloc, actId, _, __) async {
// 实现导出为Word功能
},
),
],
),
// 危险操作
MenuItemData(
icon: Icons.delete_outline,
label: '删除Act',
isDangerous: true,
onTap: (context, editorBloc, actId, _, __) async {
final confirmed = await _confirmAndDelete(
context,
'删除Act',
'确定要删除这个Act吗此操作不可撤销。',
);
if (confirmed) {
editorBloc.add(DeleteAct(
novelId: editorBloc.novelId,
actId: actId,
));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('正在删除卷...'),
duration: Duration(seconds: 2),
),
);
}
},
),
];
}
}
/// Chapter菜单定义
class ChapterMenuDefinitions {
static List<dynamic> getMenuItems() {
return [
// 基本操作
MenuItemData(
icon: Icons.add,
label: '添加新场景',
onTap: (context, editorBloc, actId, chapterId, _) async {
_addNewScene(context, editorBloc, actId, chapterId!);
},
),
MenuItemData(
icon: Icons.edit,
label: '重命名章节',
onTap: null,
),
// 分隔线
"divider",
// 额外功能
MenuItemData(
icon: Icons.tag,
label: '禁用编号',
onTap: (context, editorBloc, actId, chapterId, _) async {
// 实现禁用编号功能
},
),
MenuItemData(
icon: Icons.content_copy,
label: '复制所有场景内容',
onTap: (context, editorBloc, actId, chapterId, _) async {
// 实现复制场景内容功能
},
),
// 分隔线
"divider",
// 危险操作
MenuItemData(
icon: Icons.delete_outline,
label: '删除章节',
isDangerous: true,
onTap: (context, editorBloc, actId, chapterId, _) async {
final confirmed = await _confirmAndDelete(
context,
'删除章节',
'确定要删除这个章节吗?此操作不可撤销,章节内的所有场景都将被删除。',
);
if (confirmed) {
// 实现删除章节功能
editorBloc.add(DeleteChapter(
novelId: editorBloc.novelId,
actId: actId,
chapterId: chapterId!,
));
// 显示操作反馈
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('正在删除章节...'),
duration: Duration(seconds: 2),
),
);
}
},
),
];
}
/// 添加新场景
static void _addNewScene(BuildContext context, EditorBloc editorBloc, String actId, String chapterId) {
final newSceneId = DateTime.now().millisecondsSinceEpoch.toString();
AppLogger.i('Chapter', '添加新场景actId=$actId, chapterId=$chapterId, sceneId=$newSceneId');
editorBloc.add(AddNewScene(
novelId: editorBloc.novelId,
actId: actId,
chapterId: chapterId,
sceneId: newSceneId,
));
}
}
/// Scene菜单定义
class SceneMenuDefinitions {
static List<dynamic> getMenuItems() {
return [
MenuItemData(
icon: Icons.copy_outlined,
label: '复制场景',
onTap: (context, editorBloc, actId, chapterId, sceneId) async {
// 实现复制场景功能
// editorBloc.add(DuplicateScene(
// novelId: editorBloc.novelId,
// actId: actId,
// chapterId: chapterId!,
// sceneId: sceneId!,
// ));
},
),
MenuItemData(
icon: Icons.splitscreen_outlined,
label: '拆分场景',
onTap: (context, editorBloc, actId, chapterId, sceneId) async {
// 实现拆分场景功能
},
),
MenuSectionData(
title: 'AI功能',
items: [
MenuItemData(
icon: Icons.auto_awesome,
label: '生成摘要',
onTap: (context, editorBloc, actId, chapterId, sceneId) async {
editorBloc.add(GenerateSceneSummaryRequested(
sceneId: sceneId!,
));
},
),
MenuItemData(
icon: Icons.psychology,
label: '改进内容',
onTap: (context, editorBloc, actId, chapterId, sceneId) async {
// 实现AI改进内容功能
},
),
],
),
// 分隔线
"divider",
// 危险操作
MenuItemData(
icon: Icons.delete_outline,
label: '删除场景',
isDangerous: true,
onTap: (context, editorBloc, actId, chapterId, sceneId) async {
final confirmed = await _confirmAndDelete(
context,
'删除场景',
'确定要删除这个场景吗?此操作不可撤销。',
);
if (confirmed) {
editorBloc.add(DeleteScene(
novelId: editorBloc.novelId,
actId: actId,
chapterId: chapterId!,
sceneId: sceneId!,
));
}
},
),
];
}
}
/// 通用确认删除对话框
Future<bool> _confirmAndDelete(BuildContext context, String title, String message) async {
final confirmed = await DialogUtils.showDangerousConfirmDialog(
context: context,
title: title,
message: message,
);
return confirmed;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,397 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:ainoval/models/setting_group.dart';
import 'package:ainoval/utils/logger.dart';
import 'package:ainoval/screens/editor/managers/editor_layout_manager.dart';
import 'package:ainoval/widgets/common/floating_card.dart';
import 'package:ainoval/utils/web_theme.dart';
import 'package:ainoval/widgets/common/top_toast.dart';
/// 浮动设定组管理器
class FloatingNovelSettingGroupDialog {
static bool _isShowing = false;
/// 显示浮动设定组卡片
static void show({
required BuildContext context,
required String novelId,
SettingGroup? group, // 若为null则表示创建新组
required Function(SettingGroup) onSave, // 保存回调
}) {
if (_isShowing) {
hide();
}
// 获取布局信息
final layoutManager = Provider.of<EditorLayoutManager>(context, listen: false);
final sidebarWidth = layoutManager.isEditorSidebarVisible ? layoutManager.editorSidebarWidth : 0.0;
AppLogger.d('FloatingNovelSettingGroupDialog', '显示浮动卡片,侧边栏宽度: $sidebarWidth');
// 计算卡片大小
final screenSize = MediaQuery.of(context).size;
final cardWidth = (screenSize.width * 0.25).clamp(400.0, 600.0);
final cardHeight = (screenSize.height * 0.5).clamp(350.0, 500.0);
FloatingCard.show(
context: context,
position: FloatingCardPosition(
left: sidebarWidth + 16.0,
top: 80.0,
),
config: FloatingCardConfig(
width: cardWidth,
height: cardHeight,
showCloseButton: false,
enableBackgroundTap: false,
animationDuration: const Duration(milliseconds: 300),
animationCurve: Curves.easeOutCubic,
borderRadius: BorderRadius.circular(12),
padding: EdgeInsets.zero,
),
child: _NovelSettingGroupDialogContent(
novelId: novelId,
group: group,
onSave: (settingGroup) {
onSave(settingGroup);
hide();
},
onCancel: hide,
),
onClose: hide,
);
_isShowing = true;
}
/// 隐藏浮动卡片
static void hide() {
if (_isShowing) {
FloatingCard.hide();
_isShowing = false;
}
}
/// 检查是否正在显示
static bool get isShowing => _isShowing;
}
/// 小说设定组对话框内容
///
/// 用于创建或编辑设定组
class _NovelSettingGroupDialogContent extends StatefulWidget {
final String novelId;
final SettingGroup? group; // 若为null则表示创建新组
final Function(SettingGroup) onSave; // 保存回调
final VoidCallback onCancel; // 取消回调
const _NovelSettingGroupDialogContent({
Key? key,
required this.novelId,
this.group,
required this.onSave,
required this.onCancel,
}) : super(key: key);
@override
State<_NovelSettingGroupDialogContent> createState() => _NovelSettingGroupDialogContentState();
}
class _NovelSettingGroupDialogContentState extends State<_NovelSettingGroupDialogContent> {
final _formKey = GlobalKey<FormState>();
// 表单控制器
final _nameController = TextEditingController();
final _descriptionController = TextEditingController();
// 激活状态
bool _isActiveContext = false;
// 保存状态
bool _isSaving = false;
@override
void initState() {
super.initState();
// 若为编辑模式,填充表单
if (widget.group != null) {
_nameController.text = widget.group!.name;
if (widget.group!.description != null) {
_descriptionController.text = widget.group!.description!;
}
if (widget.group!.isActiveContext != null) {
_isActiveContext = widget.group!.isActiveContext!;
}
}
}
@override
void dispose() {
_nameController.dispose();
_descriptionController.dispose();
super.dispose();
}
// 保存设定组
Future<void> _saveSettingGroup() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_isSaving = true;
});
try {
// 构建设定组对象
final settingGroup = SettingGroup(
id: widget.group?.id,
novelId: widget.novelId,
name: _nameController.text,
description: _descriptionController.text.isNotEmpty
? _descriptionController.text
: null,
isActiveContext: _isActiveContext,
itemIds: widget.group?.itemIds,
);
// 调用保存回调
widget.onSave(settingGroup);
setState(() {
_isSaving = false;
});
// 注意:不在这里关闭对话框,因为 FloatingNovelSettingGroupDialog.show() 的 onSave 回调会调用 hide()
} catch (e, stackTrace) {
AppLogger.e('NovelSettingGroupDialog', '保存设定组失败', e, stackTrace);
setState(() {
_isSaving = false;
});
// 显示错误提示
if (context.mounted) {
TopToast.error(context, '保存失败: ${e.toString()}');
}
}
}
@override
Widget build(BuildContext context) {
final isDark = WebTheme.isDarkMode(context);
final isCreating = widget.group == null;
return Container(
decoration: BoxDecoration(
color: isDark ? WebTheme.darkBackground : WebTheme.lightBackground,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isDark ? WebTheme.darkGrey300 : WebTheme.grey300,
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 头部
_buildHeader(isDark, isCreating),
// 内容区域
Expanded(
child: _buildContent(isDark, isCreating),
),
],
),
);
}
Widget _buildHeader(bool isDark, bool isCreating) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: isDark ? WebTheme.darkGrey300 : WebTheme.grey300,
width: 1,
),
),
),
child: Row(
children: [
Expanded(
child: Text(
isCreating ? '创建设定组' : '编辑设定组',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: WebTheme.getTextColor(context),
),
),
),
IconButton(
onPressed: widget.onCancel,
icon: Icon(
Icons.close,
size: 20,
color: WebTheme.getSecondaryTextColor(context),
),
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(32, 32),
),
),
],
),
);
}
Widget _buildContent(bool isDark, bool isCreating) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题
Text(
isCreating ? '创建设定组' : '编辑设定组',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: WebTheme.getTextColor(context),
),
),
const SizedBox(height: 16),
// 表单
Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 名称
TextFormField(
controller: _nameController,
autofocus: true,
maxLength: 30,
decoration: WebTheme.getBorderedInputDecoration(
labelText: '名称',
hintText: '输入设定组名称 (30 字以内)',
context: context,
),
validator: (value) {
if (value == null || value.isEmpty) {
return '请输入设定组名称';
}
return null;
},
),
const SizedBox(height: 16),
// 描述
TextFormField(
controller: _descriptionController,
maxLines: 3,
maxLength: 200,
decoration: WebTheme.getBorderedInputDecoration(
labelText: '描述',
hintText: '输入设定组描述可选200 字以内)',
context: context,
),
),
const SizedBox(height: 16),
// 激活状态
Container(
decoration: BoxDecoration(
border: Border.all(
color: isDark ? WebTheme.darkGrey300 : WebTheme.grey300,
),
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: Row(
children: [
Switch(
value: _isActiveContext,
onChanged: (value) {
setState(() {
_isActiveContext = value;
});
},
activeColor: WebTheme.getTextColor(context),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'设为活跃上下文',
style: TextStyle(
fontWeight: FontWeight.w500,
color: WebTheme.getTextColor(context),
),
),
Text(
'活跃上下文中的设定将用于AI生成和提示',
style: TextStyle(
fontSize: 12,
color: WebTheme.getSecondaryTextColor(context),
),
),
],
),
),
],
),
),
],
),
),
const SizedBox(height: 24),
// 按钮区域
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: widget.onCancel,
style: WebTheme.getSecondaryButtonStyle(context),
child: const Text('取消'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _isSaving ? null : _saveSettingGroup,
style: WebTheme.getPrimaryButtonStyle(context),
child: _isSaving
? Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
color: isDark ? WebTheme.darkBackground : WebTheme.lightBackground,
strokeWidth: 2,
),
),
const SizedBox(width: 8),
Text(isCreating ? '创建中...' : '保存中...'),
],
)
: Text(isCreating ? '创建' : '保存'),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,320 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
import 'package:ainoval/models/setting_group.dart';
import 'package:ainoval/blocs/setting/setting_bloc.dart';
import 'package:ainoval/screens/editor/widgets/novel_setting_group_dialog.dart';
import 'package:ainoval/utils/logger.dart';
import 'package:ainoval/screens/editor/managers/editor_layout_manager.dart';
import 'package:ainoval/widgets/common/floating_card.dart';
import 'package:ainoval/utils/web_theme.dart';
/// 浮动设定组选择管理器
class FloatingNovelSettingGroupSelectionDialog {
static bool _isShowing = false;
/// 显示浮动设定组选择卡片
static void show({
required BuildContext context,
required String novelId,
required Function(String groupId, String groupName) onGroupSelected,
}) {
if (_isShowing) {
hide();
}
// 获取布局信息
final layoutManager = Provider.of<EditorLayoutManager>(context, listen: false);
final sidebarWidth = layoutManager.isEditorSidebarVisible ? layoutManager.editorSidebarWidth : 0.0;
AppLogger.d('FloatingNovelSettingGroupSelectionDialog', '显示浮动卡片,侧边栏宽度: $sidebarWidth');
// 计算卡片大小
final screenSize = MediaQuery.of(context).size;
final cardWidth = (screenSize.width * 0.3).clamp(400.0, 600.0);
final cardHeight = (screenSize.height * 0.6).clamp(400.0, 600.0);
// 获取当前的 Provider 实例
final settingBloc = context.read<SettingBloc>();
FloatingCard.show(
context: context,
position: FloatingCardPosition(
left: sidebarWidth + 16.0,
top: 80.0,
),
config: FloatingCardConfig(
width: cardWidth,
height: cardHeight,
showCloseButton: false,
enableBackgroundTap: false,
animationDuration: const Duration(milliseconds: 300),
animationCurve: Curves.easeOutCubic,
borderRadius: BorderRadius.circular(12),
padding: EdgeInsets.zero,
),
child: MultiProvider(
providers: [
Provider<EditorLayoutManager>.value(value: layoutManager),
BlocProvider<SettingBloc>.value(value: settingBloc),
],
child: _NovelSettingGroupSelectionDialogContent(
novelId: novelId,
onGroupSelected: onGroupSelected,
onCancel: hide,
),
),
onClose: hide,
);
_isShowing = true;
}
/// 隐藏浮动卡片
static void hide() {
if (_isShowing) {
FloatingCard.hide();
_isShowing = false;
}
}
/// 检查是否正在显示
static bool get isShowing => _isShowing;
}
/// 小说设定组选择对话框内容
///
/// 用于选择现有设定组或创建新设定组
class _NovelSettingGroupSelectionDialogContent extends StatefulWidget {
final String novelId;
final Function(String groupId, String groupName) onGroupSelected;
final VoidCallback onCancel;
const _NovelSettingGroupSelectionDialogContent({
Key? key,
required this.novelId,
required this.onGroupSelected,
required this.onCancel,
}) : super(key: key);
@override
State<_NovelSettingGroupSelectionDialogContent> createState() => _NovelSettingGroupSelectionDialogContentState();
}
class _NovelSettingGroupSelectionDialogContentState extends State<_NovelSettingGroupSelectionDialogContent> {
@override
void initState() {
super.initState();
// 加载设定组列表
context.read<SettingBloc>().add(LoadSettingGroups(widget.novelId));
}
@override
Widget build(BuildContext context) {
final isDark = WebTheme.isDarkMode(context);
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 5,
child: Container(
width: 400,
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'选择设定组',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
// 设定组列表
BlocBuilder<SettingBloc, SettingState>(
builder: (context, state) {
if (state.groupsStatus == SettingStatus.loading) {
return const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 24.0),
child: CircularProgressIndicator(),
),
);
}
if (state.groupsStatus == SettingStatus.failure) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 24.0),
child: Text(
'加载设定组失败:${state.error}',
style: const TextStyle(color: Colors.red),
textAlign: TextAlign.center,
),
),
);
}
if (state.groupsStatus == SettingStatus.success && state.groups.isEmpty) {
return const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 24.0),
child: Text(
'没有可用的设定组,请创建新设定组',
textAlign: TextAlign.center,
),
),
);
}
if (state.groupsStatus == SettingStatus.success) {
return SizedBox(
height: 300,
child: ListView.builder(
itemCount: state.groups.length,
itemBuilder: (context, index) {
final group = state.groups[index];
return ListTile(
title: Text(group.name),
subtitle: group.description != null && group.description!.isNotEmpty
? Text(
group.description!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: null,
leading: Icon(
Icons.folder_outlined,
color: group.isActiveContext == true
? Colors.blue
: Colors.grey,
),
onTap: () {
// 正确关闭浮动卡片而不是使用Navigator.pop()
// 使用Future.microtask确保回调在对话框处理之后执行
Future.microtask(() {
// 关闭浮动卡片
FloatingNovelSettingGroupSelectionDialog.hide();
// 延迟调用回调
Future.delayed(Duration.zero, () {
widget.onGroupSelected(group.id!, group.name);
});
});
},
);
},
),
);
}
return const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 24.0),
child: Text('请加载设定组'),
),
);
},
),
const SizedBox(height: 16),
// 操作按钮
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ElevatedButton.icon(
icon: const Icon(Icons.add, size: 16),
label: const Text('创建新设定组'),
onPressed: () {
_showCreateGroupDialog(context);
},
style: ElevatedButton.styleFrom(
backgroundColor: WebTheme.getPrimaryColor(context),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
),
TextButton(
onPressed: widget.onCancel,
child: const Text('取消'),
),
],
),
],
),
),
);
}
// 显示创建设定组对话框
void _showCreateGroupDialog(BuildContext context) {
FloatingNovelSettingGroupDialog.show(
context: context,
novelId: widget.novelId,
onSave: (SettingGroup group) {
AppLogger.i('NovelSettingGroupSelectionDialog', '创建设定组:${group.name}');
// 保存设定组
context.read<SettingBloc>().add(CreateSettingGroup(
novelId: widget.novelId,
group: group,
));
// 监听状态变化,找到新创建的设定组,但不要直接调用导航回调
final settingBloc = context.read<SettingBloc>();
late final subscription;
subscription = settingBloc.stream.listen((state) {
if (state.groupsStatus == SettingStatus.success) {
// 检查是否有新添加的设定组
final newGroup = state.groups.where((g) => g.name == group.name).lastOrNull;
if (newGroup != null && newGroup.id != null) {
subscription.cancel(); // 先停止监听
// 只显示成功提示,不执行选择回调,让用户手动选择
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('设定组 "${newGroup.name}" 创建成功!'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
);
}
// 刷新当前对话框的设定组列表
if (context.mounted) {
context.read<SettingBloc>().add(LoadSettingGroups(widget.novelId));
}
}
}
if (state.groupsStatus == SettingStatus.failure) {
subscription.cancel();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('创建设定组失败:${state.error}'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
);
}
}
});
// 一段时间后如果没有成功,取消订阅
Future.delayed(const Duration(seconds: 10), () {
subscription.cancel();
});
},
);
}
}

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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