马良AI写作初始化仓库
This commit is contained in:
237
AINoval/lib/screens/editor/components/act_section.dart
Normal file
237
AINoval/lib/screens/editor/components/act_section.dart
Normal 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,
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
120
AINoval/lib/screens/editor/components/add_act_button.dart
Normal file
120
AINoval/lib/screens/editor/components/add_act_button.dart
Normal 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;
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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('确认生成'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
1112
AINoval/lib/screens/editor/components/chapter_directory_tab.dart
Normal file
1112
AINoval/lib/screens/editor/components/chapter_directory_tab.dart
Normal file
File diff suppressed because it is too large
Load Diff
220
AINoval/lib/screens/editor/components/chapter_section.dart
Normal file
220
AINoval/lib/screens/editor/components/chapter_section.dart
Normal 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统一管理
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
67
AINoval/lib/screens/editor/components/draggable_divider.dart
Normal file
67
AINoval/lib/screens/editor/components/draggable_divider.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
481
AINoval/lib/screens/editor/components/editor_app_bar.dart
Normal file
481
AINoval/lib/screens/editor/components/editor_app_bar.dart
Normal 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);
|
||||
}
|
||||
239
AINoval/lib/screens/editor/components/editor_data_manager.dart
Normal file
239
AINoval/lib/screens/editor/components/editor_data_manager.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
802
AINoval/lib/screens/editor/components/editor_layout.dart
Normal file
802
AINoval/lib/screens/editor/components/editor_layout.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1341
AINoval/lib/screens/editor/components/editor_main_area.dart
Normal file
1341
AINoval/lib/screens/editor/components/editor_main_area.dart
Normal file
File diff suppressed because it is too large
Load Diff
664
AINoval/lib/screens/editor/components/editor_sidebar.dart
Normal file
664
AINoval/lib/screens/editor/components/editor_sidebar.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
1391
AINoval/lib/screens/editor/components/expansion_dialog.dart
Normal file
1391
AINoval/lib/screens/editor/components/expansion_dialog.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
236
AINoval/lib/screens/editor/components/indexed_map.dart
Normal file
236
AINoval/lib/screens/editor/components/indexed_map.dart
Normal 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)';
|
||||
}
|
||||
89
AINoval/lib/screens/editor/components/loading_overlay.dart
Normal file
89
AINoval/lib/screens/editor/components/loading_overlay.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
492
AINoval/lib/screens/editor/components/multi_ai_panel_view.dart
Normal file
492
AINoval/lib/screens/editor/components/multi_ai_panel_view.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
1527
AINoval/lib/screens/editor/components/plan_view.dart
Normal file
1527
AINoval/lib/screens/editor/components/plan_view.dart
Normal file
File diff suppressed because it is too large
Load Diff
1029
AINoval/lib/screens/editor/components/refactor_dialog.dart
Normal file
1029
AINoval/lib/screens/editor/components/refactor_dialog.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
3391
AINoval/lib/screens/editor/components/scene_editor.dart
Normal file
3391
AINoval/lib/screens/editor/components/scene_editor.dart
Normal file
File diff suppressed because it is too large
Load Diff
1047
AINoval/lib/screens/editor/components/summary_dialog.dart
Normal file
1047
AINoval/lib/screens/editor/components/summary_dialog.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
// 文本生成对话框统一导出文件
|
||||
// 集中导出扩写、重构、缩写三个对话框组件
|
||||
|
||||
export 'expansion_dialog.dart';
|
||||
export 'refactor_dialog.dart';
|
||||
export 'summary_dialog.dart';
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user