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