马良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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
2022
AINoval/lib/screens/editor/controllers/editor_screen_controller.dart
Normal file
2022
AINoval/lib/screens/editor/controllers/editor_screen_controller.dart
Normal file
File diff suppressed because it is too large
Load Diff
164
AINoval/lib/screens/editor/editor_screen.dart
Normal file
164
AINoval/lib/screens/editor/editor_screen.dart
Normal file
@@ -0,0 +1,164 @@
|
||||
import 'package:ainoval/blocs/auth/auth_bloc.dart';
|
||||
import 'package:ainoval/blocs/sidebar/sidebar_bloc.dart';
|
||||
// import 'package:ainoval/config/app_config.dart';
|
||||
// import 'package:ainoval/models/novel_structure.dart' as novel_models;
|
||||
import 'package:ainoval/models/novel_summary.dart';
|
||||
import 'package:ainoval/screens/editor/components/editor_layout.dart';
|
||||
import 'package:ainoval/screens/editor/controllers/editor_screen_controller.dart';
|
||||
import 'package:ainoval/screens/editor/managers/editor_layout_manager.dart';
|
||||
import 'package:ainoval/screens/editor/managers/editor_state_manager.dart';
|
||||
// import 'package:ainoval/screens/editor/widgets/continue_writing_form.dart';
|
||||
// import 'package:ainoval/services/api_service/repositories/editor_repository.dart';
|
||||
// import 'package:ainoval/services/api_service/repositories/user_ai_model_config_repository.dart';
|
||||
import 'package:ainoval/services/api_service/base/api_client.dart';
|
||||
// import 'package:ainoval/utils/logger.dart';
|
||||
// import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
// import 'package:ainoval/blocs/setting/setting_bloc.dart';
|
||||
import 'package:ainoval/services/api_service/repositories/novel_setting_repository.dart';
|
||||
import 'package:ainoval/services/api_service/repositories/impl/novel_setting_repository_impl.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:ainoval/blocs/prompt_new/prompt_new_bloc.dart';
|
||||
// import 'package:ainoval/services/api_service/repositories/prompt_repository.dart';
|
||||
// import 'package:ainoval/services/api_service/repositories/impl/prompt_repository_impl.dart';
|
||||
// import 'package:ainoval/screens/prompt/prompt_screen.dart';
|
||||
|
||||
/// 编辑器屏幕
|
||||
/// 使用设计模式重构后的编辑器屏幕,将功能拆分为多个组件
|
||||
class EditorScreen extends StatefulWidget {
|
||||
const EditorScreen({
|
||||
super.key,
|
||||
required this.novel,
|
||||
});
|
||||
final NovelSummary novel;
|
||||
|
||||
@override
|
||||
State<EditorScreen> createState() => _EditorScreenState();
|
||||
}
|
||||
|
||||
class _EditorScreenState extends State<EditorScreen> with SingleTickerProviderStateMixin {
|
||||
late final EditorScreenController _controller;
|
||||
late final EditorLayoutManager _layoutManager;
|
||||
late final EditorStateManager _stateManager;
|
||||
late final PromptNewBloc _promptNewBloc;
|
||||
|
||||
|
||||
|
||||
late final SidebarBloc _sidebarBloc;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = EditorScreenController(
|
||||
novel: widget.novel,
|
||||
vsync: this,
|
||||
);
|
||||
_layoutManager = EditorLayoutManager();
|
||||
_stateManager = EditorStateManager();
|
||||
|
||||
// 初始化 SidebarBloc
|
||||
_sidebarBloc = SidebarBloc(
|
||||
editorRepository: _controller.editorRepository,
|
||||
);
|
||||
|
||||
// 初始化 PromptNewBloc
|
||||
_promptNewBloc = PromptNewBloc(
|
||||
promptRepository: _controller.promptRepository,
|
||||
);
|
||||
|
||||
// 加载小说结构数据
|
||||
_sidebarBloc.add(LoadNovelStructure(widget.novel.id));
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 自动续写对话框显示控制
|
||||
void _showAutoContinueWritingDialog() {
|
||||
// 暂时留空,功能待实现
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// 关闭SidebarBloc
|
||||
_sidebarBloc.close();
|
||||
|
||||
// 关闭PromptNewBloc
|
||||
_promptNewBloc.close();
|
||||
|
||||
// 尝试同步当前小说数据
|
||||
_controller.syncCurrentNovel();
|
||||
|
||||
// 通知小说列表页面刷新数据
|
||||
_controller.notifyNovelListRefresh(context);
|
||||
|
||||
// 释放控制器资源
|
||||
_controller.dispose();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
return BlocListener<AuthBloc, AuthState>(
|
||||
listenWhen: (prev, curr) => curr is AuthUnauthenticated,
|
||||
listener: (context, state) {
|
||||
// 监听认证状态变化,当用户未认证时导航回登录页面
|
||||
if (state is AuthUnauthenticated) {
|
||||
// 确保在widget仍然挂载时执行导航
|
||||
if (mounted) {
|
||||
// 使用pushAndRemoveUntil清除导航栈并导航到登录页面
|
||||
// Navigator.of(context).pushAndRemoveUntil(
|
||||
// MaterialPageRoute(builder: (context) => const LoginScreen()),
|
||||
// (route) => false, // 清除所有现有路由
|
||||
// );
|
||||
}
|
||||
}
|
||||
},
|
||||
child: MultiRepositoryProvider(
|
||||
providers: [
|
||||
RepositoryProvider<NovelSettingRepository>(
|
||||
create: (context) => NovelSettingRepositoryImpl(
|
||||
apiClient: ApiClient(),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: MultiBlocProvider(
|
||||
providers: [
|
||||
// 确保AuthBloc在编辑器中可用
|
||||
BlocProvider.value(value: context.read<AuthBloc>()),
|
||||
BlocProvider.value(value: _controller.editorBloc),
|
||||
BlocProvider.value(value: _sidebarBloc),
|
||||
BlocProvider.value(value: _promptNewBloc),
|
||||
ChangeNotifierProvider.value(value: _controller),
|
||||
ChangeNotifierProvider.value(value: _layoutManager),
|
||||
BlocProvider.value(value: _controller.settingBlocInstance),
|
||||
],
|
||||
child: ValueListenableBuilder<String>(
|
||||
valueListenable: WebTheme.variantListenable,
|
||||
builder: (context, variant, _) {
|
||||
// 通过监听变体,确保本地Theme随全局主题变更而重建
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
// 使用全局主题的颜色,随变体变更
|
||||
scaffoldBackgroundColor: Theme.of(context).scaffoldBackgroundColor, // 使用正确的背景色
|
||||
cardColor: Theme.of(context).colorScheme.surface, // 使用动态卡片背景色
|
||||
),
|
||||
child: EditorLayout(
|
||||
controller: _controller,
|
||||
layoutManager: _layoutManager,
|
||||
stateManager: _stateManager,
|
||||
onAutoContinueWritingPressed: _showAutoContinueWritingDialog,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
150
AINoval/lib/screens/editor/managers/editor_dialog_manager.dart
Normal file
150
AINoval/lib/screens/editor/managers/editor_dialog_manager.dart
Normal file
@@ -0,0 +1,150 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 编辑器对话框管理器
|
||||
/// 负责管理编辑器中的各种对话框
|
||||
class EditorDialogManager {
|
||||
// 显示编辑器侧边栏宽度调整对话框
|
||||
static void showEditorSidebarWidthDialog(
|
||||
BuildContext context,
|
||||
double currentWidth,
|
||||
double minWidth,
|
||||
double maxWidth,
|
||||
ValueChanged<double> onWidthChanged,
|
||||
VoidCallback onSave,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return _buildWidthAdjustmentDialog(
|
||||
context,
|
||||
'调整侧边栏宽度',
|
||||
currentWidth,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
onWidthChanged,
|
||||
onSave,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 显示聊天侧边栏宽度调整对话框
|
||||
static void showChatSidebarWidthDialog(
|
||||
BuildContext context,
|
||||
double currentWidth,
|
||||
double minWidth,
|
||||
double maxWidth,
|
||||
ValueChanged<double> onWidthChanged,
|
||||
VoidCallback onSave,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return _buildWidthAdjustmentDialog(
|
||||
context,
|
||||
'调整聊天侧边栏宽度',
|
||||
currentWidth,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
onWidthChanged,
|
||||
onSave,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 构建宽度调整对话框
|
||||
static Widget _buildWidthAdjustmentDialog(
|
||||
BuildContext context,
|
||||
String title,
|
||||
double currentWidth,
|
||||
double minWidth,
|
||||
double maxWidth,
|
||||
ValueChanged<double> onWidthChanged,
|
||||
VoidCallback onSave,
|
||||
) {
|
||||
return AlertDialog(
|
||||
title: Text(title),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('当前宽度: ${currentWidth.toInt()} 像素'),
|
||||
const SizedBox(height: 16),
|
||||
StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return Slider(
|
||||
value: currentWidth,
|
||||
min: minWidth,
|
||||
max: maxWidth,
|
||||
divisions: 8,
|
||||
label: currentWidth.toInt().toString(),
|
||||
onChanged: (value) {
|
||||
onWidthChanged(value);
|
||||
setState(() {});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
onSave();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 显示登录提示对话框
|
||||
static Widget buildLoginRequiredPanel(BuildContext context, VoidCallback onClose) {
|
||||
return Material(
|
||||
elevation: 4.0,
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
child: Container(
|
||||
width: 400, // Smaller width for message
|
||||
height: 200, // Smaller height for message
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.lock_outline,
|
||||
size: 40, color: Theme.of(context).colorScheme.error),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'需要登录', // TODO: Localize
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'请先登录以访问和管理 AI 配置。', // TODO: Localize
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
// TODO: Implement navigation to login screen
|
||||
onClose(); // Close panel for now
|
||||
},
|
||||
child: const Text('前往登录'), // TODO: Localize
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
551
AINoval/lib/screens/editor/managers/editor_layout_manager.dart
Normal file
551
AINoval/lib/screens/editor/managers/editor_layout_manager.dart
Normal file
@@ -0,0 +1,551 @@
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:collection/collection.dart'; // For firstWhereOrNull
|
||||
|
||||
/// 编辑器布局管理器
|
||||
/// 负责管理编辑器的布局和尺寸
|
||||
class EditorLayoutManager extends ChangeNotifier {
|
||||
EditorLayoutManager() {
|
||||
_loadSavedDimensions();
|
||||
}
|
||||
|
||||
// 对象dispose状态跟踪
|
||||
bool _isDisposed = false;
|
||||
|
||||
// 侧边栏可见性状态
|
||||
bool isEditorSidebarVisible = true;
|
||||
bool isAIChatSidebarVisible = false;
|
||||
bool isSettingsPanelVisible = false;
|
||||
bool isNovelSettingsVisible = false;
|
||||
bool isAISummaryPanelVisible = false;
|
||||
bool isAISceneGenerationPanelVisible = false;
|
||||
bool isAIContinueWritingPanelVisible = false;
|
||||
bool isAISettingGenerationPanelVisible = false;
|
||||
bool isPromptViewVisible = false;
|
||||
|
||||
// 多面板显示时的顺序和位置
|
||||
final List<String> visiblePanels = [];
|
||||
static const String aiChatPanel = 'aiChatPanel';
|
||||
static const String aiSummaryPanel = 'aiSummaryPanel';
|
||||
static const String aiScenePanel = 'aiScenePanel';
|
||||
static const String aiContinueWritingPanel = 'aiContinueWritingPanel';
|
||||
static const String aiSettingGenerationPanel = 'aiSettingGenerationPanel';
|
||||
|
||||
// 侧边栏宽度
|
||||
double editorSidebarWidth = 400;
|
||||
double chatSidebarWidth = 380;
|
||||
|
||||
// 多面板模式下的单个面板宽度
|
||||
Map<String, double> panelWidths = {
|
||||
aiChatPanel: 600, // 聊天侧边栏默认最大宽度打开
|
||||
aiSummaryPanel: 350, // 其他侧边栏保持当前宽度
|
||||
aiScenePanel: 350,
|
||||
aiContinueWritingPanel: 350,
|
||||
aiSettingGenerationPanel: 350,
|
||||
};
|
||||
|
||||
// 侧边栏宽度限制
|
||||
static const double minEditorSidebarWidth = 220;
|
||||
static const double maxEditorSidebarWidth = 400;
|
||||
static const double minChatSidebarWidth = 280;
|
||||
static const double maxChatSidebarWidth = 500;
|
||||
static const double minPanelWidth = 280;
|
||||
static const double maxPanelWidth = 600; // 提升二分之一:400 * 1.5 = 600
|
||||
|
||||
// 持久化键
|
||||
static const String editorSidebarWidthPrefKey = 'editor_sidebar_width';
|
||||
static const String chatSidebarWidthPrefKey = 'chat_sidebar_width';
|
||||
static const String panelWidthsPrefKey = 'multi_panel_widths';
|
||||
static const String visiblePanelsPrefKey = 'visible_panels';
|
||||
static const String lastHiddenPanelsPrefKey = 'last_hidden_panels';
|
||||
|
||||
// 保存隐藏前的面板配置
|
||||
List<String> _lastHiddenPanelsConfig = [];
|
||||
|
||||
// 布局变化标志 - 用于标识当前变化是否为纯布局变化
|
||||
bool _isLayoutOnlyChange = false;
|
||||
|
||||
// 操作节流控制
|
||||
DateTime? _lastLayoutChangeTime;
|
||||
static const Duration _layoutChangeThrottle = Duration(milliseconds: 200);
|
||||
|
||||
// 获取是否为纯布局变化
|
||||
bool get isLayoutOnlyChange => _isLayoutOnlyChange;
|
||||
|
||||
// 重置布局变化标志
|
||||
void resetLayoutChangeFlag() {
|
||||
_isLayoutOnlyChange = false;
|
||||
}
|
||||
|
||||
// 🔧 优化:更严格的节流通知机制,避免在关键操作期间触发不必要的布局变化
|
||||
void _notifyLayoutChange() {
|
||||
if (_isDisposed) return; // 防止在dispose后调用
|
||||
|
||||
final now = DateTime.now();
|
||||
|
||||
// 🔧 修复:更严格的节流控制,避免过于频繁的布局变化通知
|
||||
if (_lastLayoutChangeTime != null &&
|
||||
now.difference(_lastLayoutChangeTime!) < _layoutChangeThrottle) {
|
||||
// 在节流期间,仍然设置布局变化标志,但不触发通知
|
||||
_isLayoutOnlyChange = true;
|
||||
AppLogger.d('EditorLayoutManager', '节流: 跳过布局变化通知');
|
||||
return;
|
||||
}
|
||||
|
||||
_lastLayoutChangeTime = now;
|
||||
_isLayoutOnlyChange = true;
|
||||
|
||||
AppLogger.d('EditorLayoutManager', '触发布局变化通知');
|
||||
|
||||
// 立即通知监听器
|
||||
notifyListeners();
|
||||
|
||||
// 🔧 修复:延长标志重置时间,确保下游组件有足够时间处理布局变化
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (!_isDisposed) { // 检查对象是否仍然有效
|
||||
_isLayoutOnlyChange = false;
|
||||
AppLogger.d('EditorLayoutManager', '重置布局变化标志');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 加载保存的尺寸
|
||||
Future<void> _loadSavedDimensions() async {
|
||||
await _loadSavedEditorSidebarWidth();
|
||||
await _loadSavedChatSidebarWidth();
|
||||
await _loadSavedPanelWidths();
|
||||
await _loadSavedVisiblePanels();
|
||||
await _loadLastHiddenPanelsConfig();
|
||||
}
|
||||
|
||||
// 加载保存的编辑器侧边栏宽度
|
||||
Future<void> _loadSavedEditorSidebarWidth() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedWidth = prefs.getDouble(editorSidebarWidthPrefKey);
|
||||
if (savedWidth != null) {
|
||||
if (savedWidth >= minEditorSidebarWidth &&
|
||||
savedWidth <= maxEditorSidebarWidth) {
|
||||
editorSidebarWidth = savedWidth;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.e('EditorLayoutManager', '加载编辑器侧边栏宽度失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存编辑器侧边栏宽度
|
||||
Future<void> saveEditorSidebarWidth() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setDouble(editorSidebarWidthPrefKey, editorSidebarWidth);
|
||||
} catch (e) {
|
||||
AppLogger.e('EditorLayoutManager', '保存编辑器侧边栏宽度失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载保存的聊天侧边栏宽度
|
||||
Future<void> _loadSavedChatSidebarWidth() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedWidth = prefs.getDouble(chatSidebarWidthPrefKey);
|
||||
if (savedWidth != null) {
|
||||
if (savedWidth >= minChatSidebarWidth &&
|
||||
savedWidth <= maxChatSidebarWidth) {
|
||||
chatSidebarWidth = savedWidth;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.e('EditorLayoutManager', '加载侧边栏宽度失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载保存的面板宽度
|
||||
Future<void> _loadSavedPanelWidths() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedWidthsString = prefs.getString(panelWidthsPrefKey);
|
||||
if (savedWidthsString != null) {
|
||||
final savedWidthsList = savedWidthsString.split(',');
|
||||
if (savedWidthsList.isNotEmpty) {
|
||||
// 聊天面板保持新的默认值(600),其他面板加载保存的值
|
||||
if (savedWidthsList.isNotEmpty && savedWidthsList[0].isNotEmpty) {
|
||||
final savedChatWidth = double.tryParse(savedWidthsList.elementAtOrNull(0) ?? '');
|
||||
if (savedChatWidth != null) {
|
||||
panelWidths[aiChatPanel] = savedChatWidth.clamp(minPanelWidth, maxPanelWidth);
|
||||
}
|
||||
}
|
||||
panelWidths[aiSummaryPanel] = double.tryParse(savedWidthsList.elementAtOrNull(1) ?? panelWidths[aiSummaryPanel].toString())!.clamp(minPanelWidth, maxPanelWidth);
|
||||
panelWidths[aiScenePanel] = double.tryParse(savedWidthsList.elementAtOrNull(2) ?? panelWidths[aiScenePanel].toString())!.clamp(minPanelWidth, maxPanelWidth);
|
||||
if (savedWidthsList.length > 3) {
|
||||
panelWidths[aiContinueWritingPanel] = double.tryParse(savedWidthsList.elementAtOrNull(3) ?? panelWidths[aiContinueWritingPanel].toString())!.clamp(minPanelWidth, maxPanelWidth);
|
||||
}
|
||||
if (savedWidthsList.length > 4) {
|
||||
panelWidths[aiSettingGenerationPanel] = double.tryParse(savedWidthsList.elementAtOrNull(4) ?? panelWidths[aiSettingGenerationPanel].toString())!.clamp(minPanelWidth, maxPanelWidth);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.e('EditorLayoutManager', '加载面板宽度失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载保存的可见面板
|
||||
Future<void> _loadSavedVisiblePanels() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedPanels = prefs.getStringList(visiblePanelsPrefKey);
|
||||
if (savedPanels != null) {
|
||||
visiblePanels.clear();
|
||||
visiblePanels.addAll(savedPanels);
|
||||
|
||||
// 更新各面板的可见性状态
|
||||
isAIChatSidebarVisible = visiblePanels.contains(aiChatPanel);
|
||||
isAISummaryPanelVisible = visiblePanels.contains(aiSummaryPanel);
|
||||
isAISceneGenerationPanelVisible = visiblePanels.contains(aiScenePanel);
|
||||
isAIContinueWritingPanelVisible = visiblePanels.contains(aiContinueWritingPanel);
|
||||
isAISettingGenerationPanelVisible = visiblePanels.contains(aiSettingGenerationPanel);
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.e('EditorLayoutManager', '加载可见面板失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存聊天侧边栏宽度
|
||||
Future<void> saveChatSidebarWidth() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setDouble(chatSidebarWidthPrefKey, chatSidebarWidth);
|
||||
} catch (e) {
|
||||
AppLogger.e('EditorLayoutManager', '保存侧边栏宽度失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存面板宽度
|
||||
Future<void> savePanelWidths() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final widthsString = [
|
||||
panelWidths[aiChatPanel],
|
||||
panelWidths[aiSummaryPanel],
|
||||
panelWidths[aiScenePanel],
|
||||
panelWidths[aiContinueWritingPanel],
|
||||
panelWidths[aiSettingGenerationPanel]
|
||||
].join(',');
|
||||
await prefs.setString(panelWidthsPrefKey, widthsString);
|
||||
} catch (e) {
|
||||
AppLogger.e('EditorLayoutManager', '保存面板宽度失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存可见面板
|
||||
Future<void> saveVisiblePanels() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(visiblePanelsPrefKey, visiblePanels);
|
||||
} catch (e) {
|
||||
AppLogger.e('EditorLayoutManager', '保存可见面板失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载隐藏前的面板配置
|
||||
Future<void> _loadLastHiddenPanelsConfig() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedConfig = prefs.getStringList(lastHiddenPanelsPrefKey);
|
||||
if (savedConfig != null) {
|
||||
_lastHiddenPanelsConfig = savedConfig;
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.e('EditorLayoutManager', '加载隐藏面板配置失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存隐藏前的面板配置
|
||||
Future<void> _saveLastHiddenPanelsConfig() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(lastHiddenPanelsPrefKey, _lastHiddenPanelsConfig);
|
||||
} catch (e) {
|
||||
AppLogger.e('EditorLayoutManager', '保存隐藏面板配置失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新编辑器侧边栏宽度
|
||||
void updateEditorSidebarWidth(double delta) {
|
||||
editorSidebarWidth = (editorSidebarWidth + delta).clamp(
|
||||
minEditorSidebarWidth,
|
||||
maxEditorSidebarWidth,
|
||||
);
|
||||
_notifyLayoutChange(); // 使用布局专用的通知方法
|
||||
}
|
||||
|
||||
// 更新聊天侧边栏宽度
|
||||
void updateChatSidebarWidth(double delta) {
|
||||
chatSidebarWidth = (chatSidebarWidth - delta).clamp(
|
||||
minChatSidebarWidth,
|
||||
maxChatSidebarWidth,
|
||||
);
|
||||
_notifyLayoutChange(); // 修复:添加missing的notifyListeners调用
|
||||
}
|
||||
|
||||
// 更新指定面板宽度
|
||||
void updatePanelWidth(String panelId, double delta) {
|
||||
if (panelWidths.containsKey(panelId)) {
|
||||
panelWidths[panelId] = (panelWidths[panelId]! - delta).clamp(
|
||||
minPanelWidth,
|
||||
maxPanelWidth,
|
||||
);
|
||||
_notifyLayoutChange(); // 使用布局专用的通知方法
|
||||
}
|
||||
}
|
||||
|
||||
// 切换编辑器侧边栏可见性
|
||||
void toggleEditorSidebar() {
|
||||
isEditorSidebarVisible = !isEditorSidebarVisible;
|
||||
_notifyLayoutChange(); // 使用布局专用的通知方法
|
||||
}
|
||||
|
||||
// 抽屉模式切换:当宽度小于阈值时展开到最大,当宽度大于等于阈值时收起到抽屉阈值
|
||||
void toggleEditorSidebarCompactMode() {
|
||||
const double drawerThreshold = 260.0;
|
||||
if (editorSidebarWidth < drawerThreshold) {
|
||||
expandEditorSidebarToMax();
|
||||
} else {
|
||||
collapseEditorSidebarToDrawer();
|
||||
}
|
||||
}
|
||||
|
||||
// 收起到抽屉(通过设置较小宽度触发精简抽屉UI)
|
||||
void collapseEditorSidebarToDrawer() {
|
||||
editorSidebarWidth = minEditorSidebarWidth; // e.g. 220,会触发 < 260 的精简抽屉
|
||||
_notifyLayoutChange();
|
||||
saveEditorSidebarWidth();
|
||||
}
|
||||
|
||||
// 展开到最大宽度
|
||||
void expandEditorSidebarToMax() {
|
||||
editorSidebarWidth = maxEditorSidebarWidth; // e.g. 400
|
||||
_notifyLayoutChange();
|
||||
saveEditorSidebarWidth();
|
||||
}
|
||||
|
||||
// 显示编辑器侧边栏(幂等)
|
||||
void showEditorSidebar() {
|
||||
if (!isEditorSidebarVisible) {
|
||||
isEditorSidebarVisible = true;
|
||||
_notifyLayoutChange();
|
||||
}
|
||||
}
|
||||
|
||||
// 隐藏编辑器侧边栏(幂等)
|
||||
void hideEditorSidebar() {
|
||||
if (isEditorSidebarVisible) {
|
||||
isEditorSidebarVisible = false;
|
||||
_notifyLayoutChange();
|
||||
}
|
||||
}
|
||||
|
||||
// 切换AI聊天侧边栏可见性
|
||||
void toggleAIChatSidebar() {
|
||||
// 在多面板模式下
|
||||
if (visiblePanels.contains(aiChatPanel)) {
|
||||
// 如果已经可见,则移除
|
||||
visiblePanels.remove(aiChatPanel);
|
||||
isAIChatSidebarVisible = false;
|
||||
} else {
|
||||
// 如果不可见,则添加
|
||||
visiblePanels.add(aiChatPanel);
|
||||
isAIChatSidebarVisible = true;
|
||||
}
|
||||
saveVisiblePanels();
|
||||
_notifyLayoutChange(); // 使用布局专用的通知方法
|
||||
}
|
||||
|
||||
// 切换AI场景生成面板可见性
|
||||
void toggleAISceneGenerationPanel() {
|
||||
// 在多面板模式下
|
||||
if (visiblePanels.contains(aiScenePanel)) {
|
||||
// 如果已经可见,则移除
|
||||
visiblePanels.remove(aiScenePanel);
|
||||
isAISceneGenerationPanelVisible = false;
|
||||
} else {
|
||||
// 如果不可见,则添加
|
||||
visiblePanels.add(aiScenePanel);
|
||||
isAISceneGenerationPanelVisible = true;
|
||||
}
|
||||
saveVisiblePanels();
|
||||
_notifyLayoutChange(); // 使用布局专用的通知方法
|
||||
}
|
||||
|
||||
// 切换AI摘要面板可见性
|
||||
void toggleAISummaryPanel() {
|
||||
// 在多面板模式下
|
||||
if (visiblePanels.contains(aiSummaryPanel)) {
|
||||
// 如果已经可见,则移除
|
||||
visiblePanels.remove(aiSummaryPanel);
|
||||
isAISummaryPanelVisible = false;
|
||||
} else {
|
||||
// 如果不可见,则添加
|
||||
visiblePanels.add(aiSummaryPanel);
|
||||
isAISummaryPanelVisible = true;
|
||||
}
|
||||
saveVisiblePanels();
|
||||
_notifyLayoutChange(); // 使用布局专用的通知方法
|
||||
}
|
||||
|
||||
// 新增:切换AI自动续写面板可见性
|
||||
void toggleAIContinueWritingPanel() {
|
||||
if (visiblePanels.contains(aiContinueWritingPanel)) {
|
||||
visiblePanels.remove(aiContinueWritingPanel);
|
||||
isAIContinueWritingPanelVisible = false;
|
||||
} else {
|
||||
visiblePanels.add(aiContinueWritingPanel);
|
||||
isAIContinueWritingPanelVisible = true;
|
||||
}
|
||||
saveVisiblePanels();
|
||||
_notifyLayoutChange(); // 使用布局专用的通知方法
|
||||
}
|
||||
|
||||
// 切换设置面板可见性
|
||||
void toggleSettingsPanel() {
|
||||
isSettingsPanelVisible = !isSettingsPanelVisible;
|
||||
if (isSettingsPanelVisible) {
|
||||
// 设置面板是全屏遮罩,不影响其他面板的显示
|
||||
}
|
||||
_notifyLayoutChange(); // 使用布局专用的通知方法
|
||||
}
|
||||
|
||||
// 切换小说设置视图可见性
|
||||
void toggleNovelSettings() {
|
||||
isNovelSettingsVisible = !isNovelSettingsVisible;
|
||||
if (isNovelSettingsVisible) {
|
||||
// 小说设置视图会替换主编辑区域,不影响侧边面板
|
||||
}
|
||||
_notifyLayoutChange(); // 使用布局专用的通知方法
|
||||
}
|
||||
|
||||
// 获取面板是否为最后一个
|
||||
bool isLastPanel(String panelId) {
|
||||
return visiblePanels.length == 1 && visiblePanels.contains(panelId);
|
||||
}
|
||||
|
||||
// 重新排序面板
|
||||
void reorderPanels(int oldIndex, int newIndex) {
|
||||
if (oldIndex < newIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
final item = visiblePanels.removeAt(oldIndex);
|
||||
visiblePanels.insert(newIndex, item);
|
||||
saveVisiblePanels();
|
||||
_notifyLayoutChange(); // 使用布局专用的通知方法
|
||||
}
|
||||
|
||||
void toggleAISettingGenerationPanel() {
|
||||
if (visiblePanels.contains(aiSettingGenerationPanel)) {
|
||||
visiblePanels.remove(aiSettingGenerationPanel);
|
||||
isAISettingGenerationPanelVisible = false;
|
||||
} else {
|
||||
visiblePanels.add(aiSettingGenerationPanel);
|
||||
isAISettingGenerationPanelVisible = true;
|
||||
}
|
||||
saveVisiblePanels();
|
||||
_notifyLayoutChange(); // 使用布局专用的通知方法
|
||||
}
|
||||
|
||||
// 切换提示词视图可见性
|
||||
void togglePromptView() {
|
||||
isPromptViewVisible = !isPromptViewVisible;
|
||||
if (isPromptViewVisible) {
|
||||
// 提示词视图是全屏替换,不影响其他面板的显示
|
||||
}
|
||||
_notifyLayoutChange(); // 使用布局专用的通知方法
|
||||
}
|
||||
|
||||
// 🚀 新增:沉浸模式状态管理
|
||||
bool isImmersiveModeEnabled = false;
|
||||
|
||||
// 🚀 新增:切换沉浸模式
|
||||
void toggleImmersiveMode() {
|
||||
isImmersiveModeEnabled = !isImmersiveModeEnabled;
|
||||
AppLogger.i('EditorLayoutManager', '切换沉浸模式: $isImmersiveModeEnabled');
|
||||
_notifyLayoutChange();
|
||||
}
|
||||
|
||||
// 🚀 新增:启用沉浸模式
|
||||
void enableImmersiveMode() {
|
||||
if (!isImmersiveModeEnabled) {
|
||||
isImmersiveModeEnabled = true;
|
||||
AppLogger.i('EditorLayoutManager', '启用沉浸模式');
|
||||
_notifyLayoutChange();
|
||||
}
|
||||
}
|
||||
|
||||
// 🚀 新增:禁用沉浸模式
|
||||
void disableImmersiveMode() {
|
||||
if (isImmersiveModeEnabled) {
|
||||
isImmersiveModeEnabled = false;
|
||||
AppLogger.i('EditorLayoutManager', '禁用沉浸模式');
|
||||
_notifyLayoutChange();
|
||||
}
|
||||
}
|
||||
|
||||
/// 隐藏所有AI面板
|
||||
void hideAllAIPanels() {
|
||||
if (visiblePanels.isNotEmpty) {
|
||||
// 保存当前配置
|
||||
_lastHiddenPanelsConfig = List<String>.from(visiblePanels);
|
||||
_saveLastHiddenPanelsConfig();
|
||||
|
||||
// 隐藏所有面板
|
||||
visiblePanels.clear();
|
||||
isAIChatSidebarVisible = false;
|
||||
isAISummaryPanelVisible = false;
|
||||
isAISceneGenerationPanelVisible = false;
|
||||
isAIContinueWritingPanelVisible = false;
|
||||
isAISettingGenerationPanelVisible = false;
|
||||
|
||||
saveVisiblePanels();
|
||||
_notifyLayoutChange();
|
||||
}
|
||||
}
|
||||
|
||||
/// 恢复隐藏前的AI面板配置
|
||||
void restoreHiddenAIPanels() {
|
||||
if (_lastHiddenPanelsConfig.isNotEmpty) {
|
||||
// 恢复面板配置
|
||||
visiblePanels.clear();
|
||||
visiblePanels.addAll(_lastHiddenPanelsConfig);
|
||||
|
||||
// 更新各面板的可见性状态
|
||||
isAIChatSidebarVisible = visiblePanels.contains(aiChatPanel);
|
||||
isAISummaryPanelVisible = visiblePanels.contains(aiSummaryPanel);
|
||||
isAISceneGenerationPanelVisible = visiblePanels.contains(aiScenePanel);
|
||||
isAIContinueWritingPanelVisible = visiblePanels.contains(aiContinueWritingPanel);
|
||||
isAISettingGenerationPanelVisible = visiblePanels.contains(aiSettingGenerationPanel);
|
||||
|
||||
saveVisiblePanels();
|
||||
_notifyLayoutChange();
|
||||
} else {
|
||||
// 如果没有保存的配置,显示默认的AI聊天面板
|
||||
toggleAIChatSidebar();
|
||||
}
|
||||
}
|
||||
|
||||
// 显示AI摘要面板
|
||||
void showAISummaryPanel() {
|
||||
if (!visiblePanels.contains(aiSummaryPanel)) {
|
||||
visiblePanels.add(aiSummaryPanel);
|
||||
isAISummaryPanelVisible = true;
|
||||
saveVisiblePanels();
|
||||
_notifyLayoutChange();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_isDisposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
319
AINoval/lib/screens/editor/managers/editor_state_manager.dart
Normal file
319
AINoval/lib/screens/editor/managers/editor_state_manager.dart
Normal file
@@ -0,0 +1,319 @@
|
||||
import 'package:ainoval/blocs/editor/editor_bloc.dart' as editor_bloc;
|
||||
import 'package:ainoval/models/novel_structure.dart' as novel_models;
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 编辑器状态管理器
|
||||
/// 负责管理编辑器的状态,如字数统计、控制器检查等
|
||||
class EditorStateManager {
|
||||
EditorStateManager();
|
||||
|
||||
// 控制器检查节流相关变量
|
||||
DateTime? _lastControllerCheckTime;
|
||||
static const Duration _controllerCheckInterval = Duration(milliseconds: 500);
|
||||
static const Duration _controllerLongCheckInterval = Duration(seconds: 5);
|
||||
editor_bloc.EditorLoaded? _lastEditorState;
|
||||
|
||||
// 字数统计缓存
|
||||
int _cachedWordCount = 0;
|
||||
String? _wordCountCacheKey;
|
||||
final Map<String, int> _memoryWordCountCache = {};
|
||||
|
||||
// 🔧 新增:模型验证状态跟踪,防止模型操作影响编辑器状态
|
||||
bool _isModelOperationInProgress = false;
|
||||
DateTime? _lastModelOperationTime;
|
||||
static const Duration _modelOperationCooldown = Duration(seconds: 5);
|
||||
|
||||
// 🔧 新增:设置模型操作状态
|
||||
void setModelOperationInProgress(bool inProgress) {
|
||||
_isModelOperationInProgress = inProgress;
|
||||
if (inProgress) {
|
||||
_lastModelOperationTime = DateTime.now();
|
||||
AppLogger.i('EditorStateManager', '模型操作开始,暂停控制器检查');
|
||||
} else {
|
||||
AppLogger.i('EditorStateManager', '模型操作结束');
|
||||
}
|
||||
}
|
||||
|
||||
// 🔧 新增:检查是否在模型操作冷却期
|
||||
bool get _isInModelOperationCooldown {
|
||||
if (_lastModelOperationTime == null) return false;
|
||||
final now = DateTime.now();
|
||||
final inCooldown = now.difference(_lastModelOperationTime!) < _modelOperationCooldown;
|
||||
if (inCooldown) {
|
||||
AppLogger.d('EditorStateManager', '模型操作冷却期中,跳过控制器检查');
|
||||
}
|
||||
return inCooldown;
|
||||
}
|
||||
|
||||
// 清除内存缓存
|
||||
void clearMemoryCache() {
|
||||
_memoryWordCountCache.clear();
|
||||
}
|
||||
|
||||
// 计算总字数
|
||||
int calculateTotalWordCount(novel_models.Novel novel) {
|
||||
// 生成缓存键:使用更新时间和场景总数作为缓存键
|
||||
final totalSceneCount = novel.acts.fold(0, (sum, act) =>
|
||||
sum + act.chapters.fold(0, (sum, chapter) =>
|
||||
sum + chapter.scenes.length));
|
||||
|
||||
final updatedAtMs = novel.updatedAt.millisecondsSinceEpoch ?? 0;
|
||||
final cacheKey = '${novel.id}_${updatedAtMs}_$totalSceneCount';
|
||||
|
||||
// 首先检查内存缓存,这是最快的检查方式
|
||||
if (_memoryWordCountCache.containsKey(cacheKey)) {
|
||||
// 完全跳过日志记录以提高性能
|
||||
return _memoryWordCountCache[cacheKey]!;
|
||||
}
|
||||
|
||||
// 如果持久化缓存有效,直接返回缓存的字数
|
||||
if (cacheKey == _wordCountCacheKey && _cachedWordCount > 0) {
|
||||
// 同时更新内存缓存
|
||||
_memoryWordCountCache[cacheKey] = _cachedWordCount;
|
||||
return _cachedWordCount;
|
||||
}
|
||||
|
||||
// 检查是否在滚动过程中 - 如果在滚动,使用旧缓存或返回0而不是计算
|
||||
final now = DateTime.now();
|
||||
if (_lastScrollHandleTime != null &&
|
||||
now.difference(_lastScrollHandleTime!) < const Duration(seconds: 2)) {
|
||||
// 在滚动过程中,如果有缓存直接用,没有就返回0避免计算
|
||||
if (_cachedWordCount > 0) {
|
||||
AppLogger.d('EditorStateManager', '滚动中使用缓存字数: $_cachedWordCount');
|
||||
// 同时更新内存缓存
|
||||
_memoryWordCountCache[cacheKey] = _cachedWordCount;
|
||||
return _cachedWordCount;
|
||||
} else {
|
||||
AppLogger.d('EditorStateManager', '滚动中跳过字数计算');
|
||||
return 0; // 返回0避免计算
|
||||
}
|
||||
}
|
||||
|
||||
// 正常情况下,记录字数计算原因
|
||||
AppLogger.i('EditorStateManager', '字数统计缓存无效,重新计算。新缓存键: $cacheKey,旧缓存键: ${_wordCountCacheKey ?? "无"}');
|
||||
|
||||
// 计算总字数(不再重复计算每个场景的字数)
|
||||
int totalWordCount = 0;
|
||||
for (final act in novel.acts) {
|
||||
for (final chapter in act.chapters) {
|
||||
for (final scene in chapter.scenes) {
|
||||
// 直接使用存储的字数,不重新计算
|
||||
totalWordCount += scene.wordCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新缓存,并减少日志输出
|
||||
_wordCountCacheKey = cacheKey;
|
||||
_cachedWordCount = totalWordCount;
|
||||
|
||||
// 同时更新内存缓存
|
||||
_memoryWordCountCache[cacheKey] = totalWordCount;
|
||||
|
||||
AppLogger.i('EditorStateManager', '小说总字数计算结果: $totalWordCount (Acts: ${novel.acts.length}, 更新缓存键: $cacheKey)');
|
||||
return totalWordCount;
|
||||
}
|
||||
|
||||
// 滚动处理节流
|
||||
DateTime? _lastScrollHandleTime;
|
||||
|
||||
// 检查是否应该重建Quill控制器
|
||||
bool shouldCheckControllers(editor_bloc.EditorLoaded state, {bool isLayoutOnlyChange = false}) {
|
||||
if (_isModelOperationInProgress || _isInModelOperationCooldown) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果是纯布局变化,跳过控制器检查
|
||||
if (isLayoutOnlyChange) {
|
||||
if (kDebugMode) {
|
||||
AppLogger.d('EditorStateManager', '跳过控制器检查 - 原因: 纯布局变化');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (state.lastUpdateSilent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果状态对象引用变化,表示小说数据结构可能发生变化,需要检查
|
||||
final bool stateChanged = _lastEditorState != state;
|
||||
final now = DateTime.now();
|
||||
|
||||
// 检查是否刚完成加载且内容有变化 (最重要的条件)
|
||||
bool justFinishedLoadingWithChanges = false;
|
||||
bool contentChanged = false; // Calculate contentChanged regardless of other checks
|
||||
|
||||
if (stateChanged && _lastEditorState != null) {
|
||||
// 检查小说结构是否有实质变化,主要比较acts和scenes的数量
|
||||
final oldNovel = _lastEditorState!.novel;
|
||||
final newNovel = state.novel;
|
||||
|
||||
// 🔧 修复:更严格的内容变化检查,避免将非内容变化误认为内容变化
|
||||
// 只有在小说结构本身发生变化时才认为是内容变化
|
||||
|
||||
// 首先检查小说基本信息是否变化(排除时间戳)
|
||||
if (oldNovel.id != newNovel.id ||
|
||||
oldNovel.title != newNovel.title) {
|
||||
contentChanged = true;
|
||||
AppLogger.i('EditorStateManager', '检测到小说基本信息变化');
|
||||
}
|
||||
|
||||
// 检查act数量是否变化
|
||||
else if (oldNovel.acts.length != newNovel.acts.length) {
|
||||
contentChanged = true;
|
||||
AppLogger.i('EditorStateManager', '检测到Act数量变化: ${oldNovel.acts.length} -> ${newNovel.acts.length}');
|
||||
}
|
||||
else {
|
||||
// 检查章节和场景数量是否变化
|
||||
bool structureChanged = false;
|
||||
|
||||
for (int i = 0; i < oldNovel.acts.length && i < newNovel.acts.length; i++) {
|
||||
final oldAct = oldNovel.acts[i];
|
||||
final newAct = newNovel.acts[i];
|
||||
|
||||
// 检查Act基本信息
|
||||
if (oldAct.id != newAct.id || oldAct.title != newAct.title) {
|
||||
structureChanged = true;
|
||||
AppLogger.i('EditorStateManager', '检测到Act[$i]基本信息变化');
|
||||
break;
|
||||
}
|
||||
|
||||
// 检查章节数量
|
||||
if (oldAct.chapters.length != newAct.chapters.length) {
|
||||
structureChanged = true;
|
||||
AppLogger.i('EditorStateManager', '检测到Act[$i]章节数量变化: ${oldAct.chapters.length} -> ${newAct.chapters.length}');
|
||||
break;
|
||||
}
|
||||
|
||||
// 检查每个章节的场景数量
|
||||
for (int j = 0; j < oldAct.chapters.length && j < newAct.chapters.length; j++) {
|
||||
final oldChapter = oldAct.chapters[j];
|
||||
final newChapter = newAct.chapters[j];
|
||||
|
||||
// 检查Chapter基本信息
|
||||
if (oldChapter.id != newChapter.id || oldChapter.title != newChapter.title) {
|
||||
structureChanged = true;
|
||||
AppLogger.i('EditorStateManager', '检测到Chapter[$i][$j]基本信息变化');
|
||||
break;
|
||||
}
|
||||
|
||||
// 检查场景数量
|
||||
if (oldChapter.scenes.length != newChapter.scenes.length) {
|
||||
structureChanged = true;
|
||||
AppLogger.i('EditorStateManager', '检测到Chapter[$i][$j]场景数量变化: ${oldChapter.scenes.length} -> ${newChapter.scenes.length}');
|
||||
break;
|
||||
}
|
||||
|
||||
// 检查场景ID是否变化(新增/删除场景)
|
||||
final oldSceneIds = oldChapter.scenes.map((s) => s.id).toSet();
|
||||
final newSceneIds = newChapter.scenes.map((s) => s.id).toSet();
|
||||
if (oldSceneIds.length != newSceneIds.length ||
|
||||
!oldSceneIds.containsAll(newSceneIds) ||
|
||||
!newSceneIds.containsAll(oldSceneIds)) {
|
||||
structureChanged = true;
|
||||
AppLogger.i('EditorStateManager', '检测到Chapter[$i][$j]场景ID变化');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (structureChanged) break;
|
||||
}
|
||||
|
||||
contentChanged = structureChanged;
|
||||
}
|
||||
|
||||
// *** Check if loading just finished and content actually changed ***
|
||||
if (_lastEditorState!.isLoading && !state.isLoading && contentChanged) {
|
||||
justFinishedLoadingWithChanges = true;
|
||||
// 仅在调试模式下记录日志
|
||||
if (kDebugMode) {
|
||||
AppLogger.i('EditorStateManager', '检测到加载完成且内容有变化,强制检查控制器。');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// *** Bypass throttle if loading just finished with changes ***
|
||||
if (justFinishedLoadingWithChanges) {
|
||||
_lastControllerCheckTime = now;
|
||||
_lastEditorState = state; // Update state reference
|
||||
// 仅在调试模式下记录日志
|
||||
if (kDebugMode) {
|
||||
AppLogger.i('EditorStateManager', '触发控制器检查 - 原因: 加载完成');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 🔧 修复:增加节流时间到15秒,减少不必要的控制器检查
|
||||
// 极端节流:如果距离上次检查时间不足15秒,且不是刚加载完成,绝对不检查
|
||||
if (_lastControllerCheckTime != null &&
|
||||
now.difference(_lastControllerCheckTime!) < const Duration(seconds: 15)) {
|
||||
// 记录日志:禁止频繁检查 (仅在状态变化且调试模式下记录,避免日志刷屏)
|
||||
if (stateChanged && kDebugMode) {
|
||||
AppLogger.d('EditorStateManager', '节流: 禁止15秒内重复检查控制器');
|
||||
}
|
||||
// 更新状态引用,即使被节流也要更新,以便下次比较
|
||||
_lastEditorState = state;
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查活动元素是否变化
|
||||
bool activeElementsChanged = false;
|
||||
if (stateChanged && _lastEditorState != null) {
|
||||
activeElementsChanged =
|
||||
_lastEditorState!.activeActId != state.activeActId ||
|
||||
_lastEditorState!.activeChapterId != state.activeChapterId ||
|
||||
_lastEditorState!.activeSceneId != state.activeSceneId;
|
||||
}
|
||||
|
||||
// 🔧 修复:只有在以下严格条件下才重建控制器
|
||||
// 1. 首次加载(_lastControllerCheckTime为null)
|
||||
// 2. 确实的内容结构变化(添加/删除场景或章节)
|
||||
// 3. 活动元素变化
|
||||
// 4. 长时间间隔超时 (15秒)
|
||||
final bool timeIntervalExceeded = _lastControllerCheckTime == null ||
|
||||
now.difference(_lastControllerCheckTime!) > const Duration(seconds: 15);
|
||||
|
||||
final bool needsCheck = _lastControllerCheckTime == null ||
|
||||
contentChanged ||
|
||||
activeElementsChanged ||
|
||||
timeIntervalExceeded;
|
||||
|
||||
// 更新状态引用,用于下次比较
|
||||
_lastEditorState = state;
|
||||
|
||||
// 如果需要检查,更新最后检查时间
|
||||
if (needsCheck) {
|
||||
_lastControllerCheckTime = now;
|
||||
|
||||
// 仅在调试模式下记录日志
|
||||
if (kDebugMode) {
|
||||
String reason;
|
||||
if (contentChanged) {
|
||||
reason = '内容结构变化';
|
||||
} else if (activeElementsChanged) {
|
||||
reason = '活动元素变化';
|
||||
} else if (timeIntervalExceeded) {
|
||||
reason = '时间间隔超过(15秒)';
|
||||
} else {
|
||||
reason = '首次加载';
|
||||
}
|
||||
|
||||
AppLogger.i('EditorStateManager', '触发控制器检查 - 原因: $reason');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 内容更新通知器
|
||||
final ValueNotifier<String> contentUpdateNotifier = ValueNotifier<String>('');
|
||||
|
||||
// 通知内容更新
|
||||
void notifyContentUpdate(String reason) {
|
||||
AppLogger.i('EditorStateManager', '通知内容更新: $reason');
|
||||
contentUpdateNotifier.value = '${DateTime.now().millisecondsSinceEpoch}_$reason';
|
||||
}
|
||||
}
|
||||
784
AINoval/lib/screens/editor/utils/document_parser.dart
Normal file
784
AINoval/lib/screens/editor/utils/document_parser.dart
Normal file
@@ -0,0 +1,784 @@
|
||||
/**
|
||||
* 文档解析工具类
|
||||
*
|
||||
* 用于解析和处理文本内容,将其转换为可编辑的Quill文档格式。
|
||||
* 提供两种解析方法:安全解析(在UI线程使用)和隔离解析(在计算隔离中使用)。
|
||||
*/
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:isolate';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_quill/flutter_quill.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:ainoval/utils/quill_helper.dart';
|
||||
|
||||
/// 优化的文档解析器
|
||||
///
|
||||
/// 包含以下优化特性:
|
||||
/// 1. LRU缓存机制 - 避免重复解析
|
||||
/// 2. 解析队列和优先级控制 - 减少并发竞争
|
||||
/// 3. 批量解析 - 提高吞吐量
|
||||
/// 4. 智能预解析 - 提前准备常用内容
|
||||
/// 5. 解析结果压缩 - 减少内存占用
|
||||
class DocumentParser {
|
||||
static final DocumentParser _instance = DocumentParser._internal();
|
||||
factory DocumentParser() => _instance;
|
||||
DocumentParser._internal();
|
||||
|
||||
// LRU缓存配置
|
||||
static const int _maxCacheSize = 50; // 从50
|
||||
static const int _maxCacheMemoryMB = 200; // 从100MB增加到200MB
|
||||
|
||||
// 解析队列配置
|
||||
static const int _maxConcurrentParsing = 5; // 从3增加到5个并发解析
|
||||
static const Duration _parseTimeout = Duration(seconds: 8); // 从5秒增加到8秒
|
||||
|
||||
// 缓存存储
|
||||
final Map<String, _CachedDocument> _documentCache = {};
|
||||
final List<String> _cacheAccessOrder = []; // LRU访问顺序
|
||||
|
||||
// 解析队列
|
||||
final List<_ParseRequest> _parseQueue = [];
|
||||
int _currentParsingCount = 0;
|
||||
|
||||
// 统计信息
|
||||
int _cacheHits = 0;
|
||||
int _cacheMisses = 0;
|
||||
int _totalParseTime = 0;
|
||||
int _totalParseCount = 0;
|
||||
|
||||
/// 解析文档(带缓存和优先级)
|
||||
static Future<Document> parseDocumentOptimized(
|
||||
String content, {
|
||||
int priority = 5, // 优先级 1-10,10最高
|
||||
String? cacheKey,
|
||||
bool useCache = true,
|
||||
}) async {
|
||||
return DocumentParser()._parseWithCache(
|
||||
content,
|
||||
priority: priority,
|
||||
cacheKey: cacheKey,
|
||||
useCache: useCache,
|
||||
);
|
||||
}
|
||||
|
||||
/// 原始解析方法(保持兼容性)
|
||||
static Future<Document> parseDocumentInIsolate(String content) async {
|
||||
return DocumentParser()._parseWithCache(content, priority: 5);
|
||||
}
|
||||
|
||||
/// 安全解析文档(用于UI线程,兼容性方法)
|
||||
static Future<Document> parseDocumentSafely(String content) async {
|
||||
return DocumentParser()._parseWithCache(content, priority: 5, useCache: true);
|
||||
}
|
||||
|
||||
/// 同步解析文档(用于控制器初始化)
|
||||
///
|
||||
/// 这个方法用于需要立即返回Document的场景,如QuillController初始化
|
||||
/// 使用简化解析逻辑,避免异步操作
|
||||
static Document parseDocumentSync(String content) {
|
||||
return DocumentParser()._parseDocumentSimple(content);
|
||||
}
|
||||
|
||||
/// 批量解析文档
|
||||
static Future<List<Document>> parseBatchDocuments(
|
||||
List<String> contents, {
|
||||
int priority = 5,
|
||||
List<String>? cacheKeys,
|
||||
}) async {
|
||||
return DocumentParser()._parseBatch(contents, priority: priority, cacheKeys: cacheKeys);
|
||||
}
|
||||
|
||||
/// 预加载文档到缓存(增强版)
|
||||
static Future<void> preloadDocuments(
|
||||
List<String> contents, {
|
||||
List<String>? cacheKeys,
|
||||
int maxPreloadConcurrency = 2, // 限制预加载并发数,避免影响正常解析
|
||||
}) async {
|
||||
final parser = DocumentParser();
|
||||
final futures = <Future<void>>[];
|
||||
|
||||
for (int i = 0; i < contents.length; i++) {
|
||||
final content = contents[i];
|
||||
final cacheKey = cacheKeys != null && i < cacheKeys.length
|
||||
? cacheKeys[i]
|
||||
: parser._generateCacheKey(content);
|
||||
|
||||
// 检查是否已缓存
|
||||
if (!parser._documentCache.containsKey(cacheKey)) {
|
||||
// 创建预加载Future
|
||||
final preloadFuture = parser._parseWithCache(
|
||||
content,
|
||||
priority: 1, // 最低优先级后台解析
|
||||
cacheKey: cacheKey,
|
||||
useCache: true
|
||||
).then((_) {
|
||||
AppLogger.d('DocumentParser', '预加载完成: $cacheKey');
|
||||
}).catchError((e) {
|
||||
AppLogger.w('DocumentParser', '预加载失败: $cacheKey, $e');
|
||||
});
|
||||
|
||||
futures.add(preloadFuture);
|
||||
|
||||
// 控制并发数量,每批处理maxPreloadConcurrency个
|
||||
if (futures.length >= maxPreloadConcurrency) {
|
||||
await Future.wait(futures);
|
||||
futures.clear();
|
||||
// 短暂延迟,避免阻塞主线程
|
||||
await Future.delayed(const Duration(milliseconds: 10));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理剩余的预加载任务
|
||||
if (futures.isNotEmpty) {
|
||||
await Future.wait(futures);
|
||||
}
|
||||
|
||||
AppLogger.i('DocumentParser', '批量预加载完成,处理了${contents.length}个文档');
|
||||
}
|
||||
|
||||
/// 清理缓存
|
||||
static void clearCache() {
|
||||
final parser = DocumentParser();
|
||||
parser._documentCache.clear();
|
||||
parser._cacheAccessOrder.clear();
|
||||
parser._cacheHits = 0;
|
||||
parser._cacheMisses = 0;
|
||||
parser._totalParseTime = 0;
|
||||
parser._totalParseCount = 0;
|
||||
AppLogger.i('DocumentParser', '缓存已清理');
|
||||
}
|
||||
|
||||
/// 获取缓存统计信息
|
||||
static Map<String, dynamic> getCacheStats() {
|
||||
final parser = DocumentParser();
|
||||
final cacheSize = parser._documentCache.length;
|
||||
final memoryUsageMB = parser._calculateCacheMemoryUsage() / 1024 / 1024;
|
||||
final hitRate = parser._cacheHits + parser._cacheMisses > 0
|
||||
? (parser._cacheHits / (parser._cacheHits + parser._cacheMisses) * 100).toStringAsFixed(1) + '%'
|
||||
: '0.0%';
|
||||
final avgParseTimeMs = parser._totalParseCount > 0
|
||||
? (parser._totalParseTime / parser._totalParseCount).toStringAsFixed(1)
|
||||
: '0.0';
|
||||
|
||||
return {
|
||||
'cacheSize': cacheSize,
|
||||
'memoryUsageMB': memoryUsageMB.toStringAsFixed(2),
|
||||
'hitRate': hitRate,
|
||||
'avgParseTimeMs': avgParseTimeMs,
|
||||
'queueLength': parser._parseQueue.length,
|
||||
'currentParsing': parser._currentParsingCount,
|
||||
'totalHits': parser._cacheHits,
|
||||
'totalMisses': parser._cacheMisses,
|
||||
'totalParseCount': parser._totalParseCount,
|
||||
'maxCacheSize': _maxCacheSize,
|
||||
'maxMemoryMB': _maxCacheMemoryMB,
|
||||
};
|
||||
}
|
||||
|
||||
/// 核心解析方法(带缓存)
|
||||
Future<Document> _parseWithCache(
|
||||
String content, {
|
||||
int priority = 5,
|
||||
String? cacheKey,
|
||||
bool useCache = true,
|
||||
}) async {
|
||||
final key = cacheKey ?? _generateCacheKey(content);
|
||||
|
||||
// 🚀 快速路径:空内容直接返回
|
||||
if (content.isEmpty) {
|
||||
AppLogger.d('DocumentParser', '快速路径:空内容 $key');
|
||||
return Document.fromJson([{'insert': '\n'}]);
|
||||
}
|
||||
|
||||
// 尝试从缓存获取
|
||||
if (useCache && _documentCache.containsKey(key)) {
|
||||
_updateCacheAccess(key);
|
||||
_cacheHits++;
|
||||
AppLogger.d('DocumentParser', '缓存命中: $key');
|
||||
return _documentCache[key]!.document;
|
||||
}
|
||||
|
||||
_cacheMisses++;
|
||||
|
||||
// 🚀 快速路径:内容过大时使用简化解析
|
||||
if (content.length > 100000) { // 大于100KB使用简化解析
|
||||
AppLogger.w('DocumentParser', '内容过大($content.length字符),使用简化解析: $key');
|
||||
try {
|
||||
final simpleDocument = _parseDocumentSimple(content);
|
||||
if (useCache) {
|
||||
_storeInCache(key, simpleDocument, content.length);
|
||||
}
|
||||
return simpleDocument;
|
||||
} catch (e) {
|
||||
AppLogger.e('DocumentParser', '简化解析失败: $key', e);
|
||||
return Document.fromJson([{'insert': '内容过大,解析失败\n'}]);
|
||||
}
|
||||
}
|
||||
|
||||
// 🚀 快速路径:如果是纯文本且不太长,直接解析
|
||||
if (content.length < 1000 && !content.trim().startsWith('[') && !content.trim().startsWith('{')) {
|
||||
AppLogger.d('DocumentParser', '快速路径:纯文本解析 $key');
|
||||
final quickDocument = Document.fromJson([{'insert': '$content\n'}]);
|
||||
if (useCache) {
|
||||
_storeInCache(key, quickDocument, content.length);
|
||||
}
|
||||
return quickDocument;
|
||||
}
|
||||
|
||||
// 创建解析请求
|
||||
final completer = Completer<Document>();
|
||||
final request = _ParseRequest(
|
||||
content: content,
|
||||
cacheKey: key,
|
||||
priority: priority,
|
||||
completer: completer,
|
||||
useCache: useCache,
|
||||
);
|
||||
|
||||
_parseQueue.add(request);
|
||||
_parseQueue.sort((a, b) => b.priority.compareTo(a.priority)); // 按优先级排序
|
||||
|
||||
_processParseQueue();
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
/// 批量解析
|
||||
Future<List<Document>> _parseBatch(
|
||||
List<String> contents, {
|
||||
int priority = 5,
|
||||
List<String>? cacheKeys,
|
||||
}) async {
|
||||
final futures = <Future<Document>>[];
|
||||
|
||||
for (int i = 0; i < contents.length; i++) {
|
||||
final cacheKey = cacheKeys != null && i < cacheKeys.length ? cacheKeys[i] : null;
|
||||
futures.add(_parseWithCache(contents[i], priority: priority, cacheKey: cacheKey));
|
||||
}
|
||||
|
||||
return Future.wait(futures);
|
||||
}
|
||||
|
||||
/// 处理解析队列
|
||||
void _processParseQueue() {
|
||||
while (_parseQueue.isNotEmpty && _currentParsingCount < _maxConcurrentParsing) {
|
||||
final request = _parseQueue.removeAt(0);
|
||||
_currentParsingCount++;
|
||||
|
||||
_executeParseRequest(request);
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行解析请求
|
||||
void _executeParseRequest(_ParseRequest request) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
|
||||
try {
|
||||
// 🚀 预估解析时间,如果内容过大直接使用简化解析
|
||||
if (request.content.length > 50000) {
|
||||
AppLogger.w('DocumentParser', '内容较大(${request.content.length}字符),使用简化解析: ${request.cacheKey}');
|
||||
final document = _parseDocumentSimple(request.content);
|
||||
|
||||
stopwatch.stop();
|
||||
final parseTime = stopwatch.elapsedMilliseconds;
|
||||
_totalParseTime += parseTime;
|
||||
_totalParseCount++;
|
||||
|
||||
if (request.useCache) {
|
||||
_storeInCache(request.cacheKey, document, request.content.length);
|
||||
}
|
||||
|
||||
AppLogger.d('DocumentParser', '简化解析完成: ${request.cacheKey}, 耗时: ${parseTime}ms');
|
||||
request.completer.complete(document);
|
||||
return;
|
||||
}
|
||||
|
||||
// 正常解析流程
|
||||
final document = await _parseInIsolateWithTimeout(request.content);
|
||||
|
||||
stopwatch.stop();
|
||||
final parseTime = stopwatch.elapsedMilliseconds;
|
||||
_totalParseTime += parseTime;
|
||||
_totalParseCount++;
|
||||
|
||||
// 🚨 性能监控:如果解析时间过长,记录警告
|
||||
if (parseTime > 1000) {
|
||||
AppLogger.w('DocumentParser', '⚠️ 解析时间过长: ${request.cacheKey}, 耗时: ${parseTime}ms, 内容长度: ${request.content.length}');
|
||||
}
|
||||
|
||||
// 存储到缓存
|
||||
if (request.useCache) {
|
||||
_storeInCache(request.cacheKey, document, request.content.length);
|
||||
}
|
||||
|
||||
AppLogger.d('DocumentParser', '解析完成: ${request.cacheKey}, 耗时: ${parseTime}ms');
|
||||
request.completer.complete(document);
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
stopwatch.stop();
|
||||
AppLogger.e('DocumentParser', '解析失败: ${request.cacheKey}', e, stackTrace);
|
||||
|
||||
// 🚀 解析失败时使用简化解析作为备用方案
|
||||
try {
|
||||
AppLogger.i('DocumentParser', '尝试简化解析备用方案: ${request.cacheKey}');
|
||||
final fallbackDocument = _parseDocumentSimple(request.content);
|
||||
|
||||
if (request.useCache) {
|
||||
_storeInCache(request.cacheKey, fallbackDocument, request.content.length);
|
||||
}
|
||||
|
||||
request.completer.complete(fallbackDocument);
|
||||
AppLogger.i('DocumentParser', '简化解析备用方案成功: ${request.cacheKey}');
|
||||
} catch (fallbackError) {
|
||||
// 最后的备用方案:创建错误文档
|
||||
final errorDocument = Document.fromJson([
|
||||
{'insert': '⚠️ 文档解析失败\n内容加载出现问题,请刷新重试。\n\n原始内容预览:\n'},
|
||||
{'insert': request.content.length > 200 ? '${request.content.substring(0, 200)}...\n' : '${request.content}\n'},
|
||||
]);
|
||||
|
||||
request.completer.complete(errorDocument);
|
||||
AppLogger.e('DocumentParser', '所有解析方案都失败: ${request.cacheKey}', fallbackError);
|
||||
}
|
||||
} finally {
|
||||
_currentParsingCount--;
|
||||
_processParseQueue(); // 处理队列中的下一个请求
|
||||
}
|
||||
}
|
||||
|
||||
/// 在隔离中解析(带超时)
|
||||
Future<Document> _parseInIsolateWithTimeout(String content) async {
|
||||
// 🚀 根据内容大小动态调整超时时间
|
||||
Duration timeout;
|
||||
if (content.length < 1000) {
|
||||
timeout = const Duration(seconds: 2); // 小内容2秒超时
|
||||
} else if (content.length < 10000) {
|
||||
timeout = const Duration(seconds: 4); // 中等内容4秒超时
|
||||
} else {
|
||||
timeout = const Duration(seconds: 6); // 大内容6秒超时,不再使用8秒
|
||||
}
|
||||
|
||||
return compute(_isolateParseFunction, content).timeout(
|
||||
timeout,
|
||||
onTimeout: () {
|
||||
AppLogger.w('DocumentParser', '解析超时(${timeout.inSeconds}秒),使用简化解析,内容长度: ${content.length}');
|
||||
return _parseDocumentSimple(content);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 生成缓存键
|
||||
String _generateCacheKey(String content) {
|
||||
// 使用内容长度和特征字符生成更稳定的缓存键
|
||||
final length = content.length;
|
||||
if (length == 0) return 'doc_empty_0';
|
||||
|
||||
// 采样关键字符位置,避免完整内容哈希
|
||||
final sample1 = content.codeUnitAt(0);
|
||||
final sample2 = length > 10 ? content.codeUnitAt(length ~/ 4) : 0;
|
||||
final sample3 = length > 20 ? content.codeUnitAt(length ~/ 2) : 0;
|
||||
final sample4 = length > 30 ? content.codeUnitAt(length * 3 ~/ 4) : 0;
|
||||
final sample5 = content.codeUnitAt(length - 1);
|
||||
|
||||
// 使用字符码点和生成稳定哈希
|
||||
int stableHash = length;
|
||||
stableHash = (stableHash * 31 + sample1) & 0x7FFFFFFF;
|
||||
stableHash = (stableHash * 31 + sample2) & 0x7FFFFFFF;
|
||||
stableHash = (stableHash * 31 + sample3) & 0x7FFFFFFF;
|
||||
stableHash = (stableHash * 31 + sample4) & 0x7FFFFFFF;
|
||||
stableHash = (stableHash * 31 + sample5) & 0x7FFFFFFF;
|
||||
|
||||
return 'doc_${length}_${stableHash}';
|
||||
}
|
||||
|
||||
/// 存储到缓存
|
||||
void _storeInCache(String key, Document document, int contentSize) {
|
||||
// 检查缓存大小限制
|
||||
_enforceCacheLimits();
|
||||
|
||||
final cachedDoc = _CachedDocument(
|
||||
document: document,
|
||||
contentSize: contentSize,
|
||||
accessTime: DateTime.now(),
|
||||
);
|
||||
|
||||
_documentCache[key] = cachedDoc;
|
||||
_updateCacheAccess(key);
|
||||
}
|
||||
|
||||
/// 更新缓存访问顺序
|
||||
void _updateCacheAccess(String key) {
|
||||
_cacheAccessOrder.remove(key);
|
||||
_cacheAccessOrder.add(key); // 移到最后(最近访问)
|
||||
|
||||
if (_documentCache.containsKey(key)) {
|
||||
_documentCache[key]!.accessTime = DateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
/// 强制执行缓存限制
|
||||
void _enforceCacheLimits() {
|
||||
// 检查数量限制
|
||||
while (_documentCache.length >= _maxCacheSize && _cacheAccessOrder.isNotEmpty) {
|
||||
final oldestKey = _cacheAccessOrder.removeAt(0);
|
||||
_documentCache.remove(oldestKey);
|
||||
}
|
||||
|
||||
// 检查内存限制
|
||||
while (_calculateCacheMemoryUsage() > _maxCacheMemoryMB * 1024 * 1024 && _cacheAccessOrder.isNotEmpty) {
|
||||
final oldestKey = _cacheAccessOrder.removeAt(0);
|
||||
_documentCache.remove(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// 计算缓存内存使用量
|
||||
int _calculateCacheMemoryUsage() {
|
||||
return _documentCache.values.fold(0, (sum, doc) => sum + doc.contentSize);
|
||||
}
|
||||
|
||||
/// 简化解析方法 - 用于大内容或解析失败的备用方案
|
||||
Document _parseDocumentSimple(String content) {
|
||||
try {
|
||||
// 🚀 快速检查:如果是空内容
|
||||
if (content.trim().isEmpty) {
|
||||
return Document.fromJson([{'insert': '\n'}]);
|
||||
}
|
||||
|
||||
// 🚀 快速检查:如果明显是纯文本
|
||||
final trimmedContent = content.trim();
|
||||
if (!trimmedContent.startsWith('[') && !trimmedContent.startsWith('{')) {
|
||||
// 处理纯文本,保留换行
|
||||
final lines = content.split('\n');
|
||||
final ops = <Map<String, dynamic>>[];
|
||||
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
if (lines[i].isNotEmpty) {
|
||||
ops.add({'insert': lines[i]});
|
||||
}
|
||||
if (i < lines.length - 1 || content.endsWith('\n')) {
|
||||
ops.add({'insert': '\n'});
|
||||
}
|
||||
}
|
||||
|
||||
if (ops.isEmpty) {
|
||||
ops.add({'insert': '\n'});
|
||||
}
|
||||
|
||||
return Document.fromJson(ops);
|
||||
}
|
||||
|
||||
// 🚀 尝试快速JSON解析
|
||||
try {
|
||||
final jsonData = jsonDecode(content);
|
||||
|
||||
if (jsonData is List) {
|
||||
// 验证是否是有效的Quill操作数组
|
||||
bool isValidOps = true;
|
||||
bool hasStyleAttributes = false;
|
||||
|
||||
for (final op in jsonData) {
|
||||
if (op is! Map || !op.containsKey('insert')) {
|
||||
isValidOps = false;
|
||||
break;
|
||||
}
|
||||
// 检查是否有样式属性
|
||||
if (op is Map && op.containsKey('attributes')) {
|
||||
hasStyleAttributes = true;
|
||||
final attributes = op['attributes'] as Map<String, dynamic>?;
|
||||
if (attributes != null) {
|
||||
AppLogger.d('DocumentParser/_parseDocumentSimple',
|
||||
'🎨 发现样式属性: ${attributes.keys.join(', ')}');
|
||||
|
||||
if (attributes.containsKey('color')) {
|
||||
AppLogger.d('DocumentParser/_parseDocumentSimple',
|
||||
'🎨 文字颜色: ${attributes['color']}');
|
||||
}
|
||||
if (attributes.containsKey('background')) {
|
||||
AppLogger.d('DocumentParser/_parseDocumentSimple',
|
||||
'🎨 背景颜色: ${attributes['background']}');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasStyleAttributes) {
|
||||
AppLogger.i('DocumentParser/_parseDocumentSimple',
|
||||
'🎨 简化解析包含样式属性的内容,操作数量: ${jsonData.length}');
|
||||
}
|
||||
|
||||
if (isValidOps) {
|
||||
return Document.fromJson(jsonData);
|
||||
}
|
||||
} else if (jsonData is Map && jsonData.containsKey('ops')) {
|
||||
final ops = jsonData['ops'];
|
||||
if (ops is List) {
|
||||
// 检查ops中的样式属性
|
||||
bool hasStyleAttributes = false;
|
||||
for (final op in ops) {
|
||||
if (op is Map && op.containsKey('attributes')) {
|
||||
hasStyleAttributes = true;
|
||||
final attributes = op['attributes'] as Map<String, dynamic>?;
|
||||
if (attributes != null) {
|
||||
AppLogger.d('DocumentParser/_parseDocumentSimple',
|
||||
'🎨 ops中发现样式属性: ${attributes.keys.join(', ')}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasStyleAttributes) {
|
||||
AppLogger.i('DocumentParser/_parseDocumentSimple',
|
||||
'🎨 简化解析ops格式包含样式属性的内容,操作数量: ${ops.length}');
|
||||
}
|
||||
|
||||
return Document.fromJson(ops);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果JSON格式不正确,当作文本处理
|
||||
return Document.fromJson([
|
||||
{'insert': '⚠️ 内容格式异常,显示原始内容:\n'},
|
||||
{'insert': content.length > 1000 ? '${content.substring(0, 1000)}...\n' : '$content\n'}
|
||||
]);
|
||||
|
||||
} catch (jsonError) {
|
||||
// JSON解析失败,当作纯文本处理
|
||||
AppLogger.d('DocumentParser', '简化解析:JSON解析失败,当作纯文本处理');
|
||||
return Document.fromJson([
|
||||
{'insert': content.length > 10000 ? '${content.substring(0, 10000)}...\n' : '$content\n'}
|
||||
]);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
AppLogger.w('DocumentParser', '简化解析也失败,使用最基础的文档', e);
|
||||
return Document.fromJson([
|
||||
{'insert': '⚠️ 内容解析失败\n'},
|
||||
{'insert': '内容长度: ${content.length} 字符\n'},
|
||||
{'insert': '请联系技术支持\n'}
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// 优化缓存键生成 - 使用更稳定的hash算法
|
||||
String _generateCacheKeyOptimized(String content) {
|
||||
// 统一使用新的稳定缓存键生成方法
|
||||
return _generateCacheKey(content);
|
||||
}
|
||||
|
||||
/// 检查缓存健康状况
|
||||
static Map<String, dynamic> checkCacheHealth() {
|
||||
final parser = DocumentParser();
|
||||
final stats = getCacheStats();
|
||||
final issues = <String>[];
|
||||
|
||||
// 检查缓存命中率
|
||||
final hitRateNum = parser._cacheHits + parser._cacheMisses > 0
|
||||
? (parser._cacheHits / (parser._cacheHits + parser._cacheMisses) * 100)
|
||||
: 0.0;
|
||||
|
||||
if (hitRateNum < 30) {
|
||||
issues.add('缓存命中率过低 (${hitRateNum.toStringAsFixed(1)}%)');
|
||||
}
|
||||
|
||||
// 检查平均解析时间
|
||||
final avgParseTime = parser._totalParseCount > 0
|
||||
? (parser._totalParseTime / parser._totalParseCount)
|
||||
: 0.0;
|
||||
|
||||
if (avgParseTime > 500) {
|
||||
issues.add('平均解析时间过长 (${avgParseTime.toStringAsFixed(1)}ms)');
|
||||
}
|
||||
|
||||
// 检查队列长度
|
||||
if (parser._parseQueue.length > 10) {
|
||||
issues.add('解析队列过长 (${parser._parseQueue.length})');
|
||||
}
|
||||
|
||||
return {
|
||||
'isHealthy': issues.isEmpty,
|
||||
'issues': issues,
|
||||
'stats': stats,
|
||||
'recommendations': _generateRecommendations(issues),
|
||||
};
|
||||
}
|
||||
|
||||
/// 生成优化建议
|
||||
static List<String> _generateRecommendations(List<String> issues) {
|
||||
final recommendations = <String>[];
|
||||
|
||||
if (issues.any((issue) => issue.contains('缓存命中率'))) {
|
||||
recommendations.add('增加预加载范围');
|
||||
recommendations.add('检查缓存键生成逻辑');
|
||||
recommendations.add('考虑增加缓存大小');
|
||||
}
|
||||
|
||||
if (issues.any((issue) => issue.contains('解析时间'))) {
|
||||
recommendations.add('检查内容复杂度');
|
||||
recommendations.add('考虑内容预处理');
|
||||
recommendations.add('增加并发解析数量');
|
||||
}
|
||||
|
||||
if (issues.any((issue) => issue.contains('队列'))) {
|
||||
recommendations.add('减少同时触发的解析请求');
|
||||
recommendations.add('提高高优先级任务处理速度');
|
||||
recommendations.add('检查是否有解析死锁');
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
}
|
||||
|
||||
/// 智能缓存预热 - 新增功能
|
||||
static Future<void> warmupCache({
|
||||
List<String>? priorityContents,
|
||||
int warmupSize = 10,
|
||||
}) async {
|
||||
final parser = DocumentParser();
|
||||
|
||||
AppLogger.i('DocumentParser', '开始缓存预热...');
|
||||
|
||||
// 预热常见的文档格式
|
||||
final commonFormats = [
|
||||
'[{"insert":"\\n"}]', // 空文档
|
||||
'[{"insert":"测试文本\\n"}]', // 简单文本
|
||||
'[{"insert":"测试文本\\n","attributes":{"bold":true}}]', // 带格式文本
|
||||
'简单纯文本内容', // 纯文本
|
||||
'{"insert":"旧格式文档\\n"}', // 旧格式
|
||||
];
|
||||
|
||||
// 预热优先内容
|
||||
if (priorityContents != null) {
|
||||
await preloadDocuments(
|
||||
priorityContents.take(warmupSize).toList(),
|
||||
maxPreloadConcurrency: 3,
|
||||
);
|
||||
}
|
||||
|
||||
// 预热常见格式
|
||||
await preloadDocuments(
|
||||
commonFormats,
|
||||
cacheKeys: List.generate(commonFormats.length, (i) => 'warmup_format_$i'),
|
||||
maxPreloadConcurrency: 2,
|
||||
);
|
||||
|
||||
AppLogger.i('DocumentParser', '缓存预热完成');
|
||||
}
|
||||
}
|
||||
|
||||
/// 隔离中的解析函数
|
||||
Document _isolateParseFunction(String content) {
|
||||
try {
|
||||
if (content.isEmpty) {
|
||||
return Document.fromJson([{'insert': '\n'}]);
|
||||
}
|
||||
|
||||
// 优化的JSON解析
|
||||
if (content.trim().startsWith('[') || content.trim().startsWith('{')) {
|
||||
final jsonData = jsonDecode(content);
|
||||
List<Map<String, dynamic>> ops;
|
||||
|
||||
if (jsonData is List) {
|
||||
ops = jsonData.cast<Map<String, dynamic>>();
|
||||
} else if (jsonData is Map && jsonData.containsKey('ops')) {
|
||||
// 处理 {"ops": [...]} 格式
|
||||
ops = (jsonData['ops'] as List).cast<Map<String, dynamic>>();
|
||||
} else if (jsonData is Map) {
|
||||
ops = [jsonData.cast<String, dynamic>()];
|
||||
} else {
|
||||
// 转换为纯文本处理
|
||||
return Document.fromJson([{'insert': '$content\n'}]);
|
||||
}
|
||||
|
||||
// 🚀 新增:检查和记录样式属性
|
||||
bool hasStyleAttributes = false;
|
||||
for (final op in ops) {
|
||||
if (op.containsKey('attributes')) {
|
||||
hasStyleAttributes = true;
|
||||
final attributes = op['attributes'] as Map<String, dynamic>?;
|
||||
if (attributes != null) {
|
||||
// 记录发现的样式属性
|
||||
AppLogger.d('DocumentParser/_isolateParseFunction',
|
||||
'🎨 发现样式属性: ${attributes.keys.join(', ')}');
|
||||
|
||||
// 特别记录颜色属性
|
||||
if (attributes.containsKey('color')) {
|
||||
AppLogger.d('DocumentParser/_isolateParseFunction',
|
||||
'🎨 文字颜色: ${attributes['color']}');
|
||||
}
|
||||
if (attributes.containsKey('background')) {
|
||||
AppLogger.d('DocumentParser/_isolateParseFunction',
|
||||
'🎨 背景颜色: ${attributes['background']}');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasStyleAttributes) {
|
||||
AppLogger.i('DocumentParser/_isolateParseFunction',
|
||||
'🎨 解析包含样式属性的内容,操作数量: ${ops.length}');
|
||||
}
|
||||
|
||||
// 确保最后一个操作以换行符结尾
|
||||
if (ops.isNotEmpty) {
|
||||
final lastOp = ops.last;
|
||||
if (lastOp.containsKey('insert')) {
|
||||
final insertText = lastOp['insert'].toString();
|
||||
if (!insertText.endsWith('\n')) {
|
||||
// 如果最后一个insert不以换行符结尾,添加一个新的换行符操作
|
||||
ops.add({'insert': '\n'});
|
||||
}
|
||||
} else {
|
||||
// 如果最后一个操作不包含insert,添加换行符
|
||||
ops.add({'insert': '\n'});
|
||||
}
|
||||
} else {
|
||||
// 如果ops为空,添加一个换行符
|
||||
ops = [{'insert': '\n'}];
|
||||
}
|
||||
|
||||
return Document.fromJson(ops);
|
||||
}
|
||||
|
||||
// 处理普通文本
|
||||
return Document.fromJson([{'insert': '$content\n'}]);
|
||||
|
||||
} catch (e) {
|
||||
// 解析失败时的备用方案 - 增强错误信息
|
||||
AppLogger.e('DocumentParser/_isolateParseFunction',
|
||||
'解析失败,内容长度: ${content.length}, 错误: $e');
|
||||
|
||||
return Document.fromJson([
|
||||
{'insert': '解析错误: ${e.toString()}\n'},
|
||||
{'insert': content.length > 200 ? '${content.substring(0, 200)}...\n' : '$content\n'},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// 缓存的文档数据
|
||||
class _CachedDocument {
|
||||
final Document document;
|
||||
final int contentSize;
|
||||
DateTime accessTime;
|
||||
|
||||
_CachedDocument({
|
||||
required this.document,
|
||||
required this.contentSize,
|
||||
required this.accessTime,
|
||||
});
|
||||
}
|
||||
|
||||
/// 解析请求
|
||||
class _ParseRequest {
|
||||
final String content;
|
||||
final String cacheKey;
|
||||
final int priority;
|
||||
final Completer<Document> completer;
|
||||
final bool useCache;
|
||||
|
||||
_ParseRequest({
|
||||
required this.content,
|
||||
required this.cacheKey,
|
||||
required this.priority,
|
||||
required this.completer,
|
||||
required this.useCache,
|
||||
});
|
||||
}
|
||||
65
AINoval/lib/screens/editor/widgets/ai_chat_button.dart
Normal file
65
AINoval/lib/screens/editor/widgets/ai_chat_button.dart
Normal file
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../blocs/chat/chat_bloc.dart';
|
||||
import '../../../blocs/chat/chat_event.dart';
|
||||
import '../../../blocs/chat/chat_state.dart';
|
||||
|
||||
/// AI聊天按钮,用于在编辑器中打开AI聊天侧边栏
|
||||
class AIChatButton extends StatelessWidget {
|
||||
const AIChatButton({
|
||||
Key? key,
|
||||
required this.novelId,
|
||||
this.chapterId,
|
||||
required this.onPressed,
|
||||
this.isActive = false,
|
||||
}) : super(key: key);
|
||||
|
||||
final String novelId;
|
||||
final String? chapterId;
|
||||
final VoidCallback onPressed;
|
||||
final bool isActive;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<ChatBloc, ChatState>(
|
||||
builder: (context, state) {
|
||||
return IconButton(
|
||||
icon: Stack(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_outlined,
|
||||
color: isActive ? Colors.blue : Colors.black54,
|
||||
),
|
||||
if (state is ChatSessionActive && state.isGenerating)
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.green,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
tooltip: '打开AI聊天',
|
||||
onPressed: () {
|
||||
// 如果没有活动会话,创建一个新会话
|
||||
if (state is! ChatSessionActive) {
|
||||
context.read<ChatBloc>().add(CreateChatSession(
|
||||
title: 'New Chat',
|
||||
novelId: novelId,
|
||||
chapterId: chapterId,
|
||||
));
|
||||
}
|
||||
onPressed();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
1346
AINoval/lib/screens/editor/widgets/ai_generation_panel.dart
Normal file
1346
AINoval/lib/screens/editor/widgets/ai_generation_panel.dart
Normal file
File diff suppressed because it is too large
Load Diff
382
AINoval/lib/screens/editor/widgets/ai_generation_toolbar.dart
Normal file
382
AINoval/lib/screens/editor/widgets/ai_generation_toolbar.dart
Normal file
@@ -0,0 +1,382 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// AI生成工具栏
|
||||
/// 在流式输出文本时显示,提供Apply、Retry、Discard、Section等操作
|
||||
class AIGenerationToolbar extends StatefulWidget {
|
||||
const AIGenerationToolbar({
|
||||
super.key,
|
||||
required this.layerLink,
|
||||
required this.onApply,
|
||||
required this.onRetry,
|
||||
required this.onDiscard,
|
||||
required this.onSection,
|
||||
required this.wordCount,
|
||||
required this.modelName,
|
||||
this.isGenerating = false,
|
||||
this.onClosed,
|
||||
this.showAbove = false,
|
||||
this.onStop,
|
||||
this.offsetAbove = -60.0,
|
||||
this.offsetBelow = 30.0,
|
||||
});
|
||||
|
||||
/// 用于定位工具栏的层链接
|
||||
final LayerLink layerLink;
|
||||
|
||||
/// 应用生成的文本
|
||||
final VoidCallback onApply;
|
||||
|
||||
/// 重新生成
|
||||
final VoidCallback onRetry;
|
||||
|
||||
/// 丢弃生成的文本
|
||||
final VoidCallback onDiscard;
|
||||
|
||||
/// 分段功能
|
||||
final VoidCallback onSection;
|
||||
|
||||
/// 停止生成
|
||||
final VoidCallback? onStop;
|
||||
|
||||
/// 生成文本的字数
|
||||
final int wordCount;
|
||||
|
||||
/// 使用的模型名称
|
||||
final String modelName;
|
||||
|
||||
/// 是否正在生成中
|
||||
final bool isGenerating;
|
||||
|
||||
/// 工具栏关闭回调
|
||||
final VoidCallback? onClosed;
|
||||
|
||||
/// 是否显示在上方
|
||||
final bool showAbove;
|
||||
|
||||
/// 上方显示时的Y偏移量
|
||||
final double offsetAbove;
|
||||
|
||||
/// 下方显示时的Y偏移量
|
||||
final double offsetBelow;
|
||||
|
||||
@override
|
||||
State<AIGenerationToolbar> createState() => _AIGenerationToolbarState();
|
||||
}
|
||||
|
||||
class _AIGenerationToolbarState extends State<AIGenerationToolbar> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final isLight = !isDark;
|
||||
|
||||
return CompositedTransformFollower(
|
||||
link: widget.layerLink,
|
||||
offset: widget.showAbove ? Offset(0, widget.offsetAbove) : Offset(0, widget.offsetBelow),
|
||||
followerAnchor: Alignment.topCenter,
|
||||
targetAnchor: Alignment.topCenter,
|
||||
showWhenUnlinked: false,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
opaque: true,
|
||||
hitTestBehavior: HitTestBehavior.opaque,
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 600),
|
||||
child: _buildToolbarContainer(isLightTheme: isLight),
|
||||
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建工具栏容器
|
||||
Widget _buildToolbarContainer({required bool isLightTheme}) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
// 统一使用 WebTheme 色系
|
||||
color: isLightTheme ? WebTheme.black : WebTheme.getSurfaceColor(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: WebTheme.getShadowColor(context, opacity: isLightTheme ? 0.3 : 0.1),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
border: Border.all(
|
||||
color: WebTheme.getSecondaryBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// 估算内容总宽度
|
||||
final contentWidth = _estimateContentWidth();
|
||||
|
||||
// 如果空间不足,使用垂直布局
|
||||
if (contentWidth > constraints.maxWidth && constraints.maxWidth > 0) {
|
||||
return _buildVerticalLayout(isLightTheme);
|
||||
} else {
|
||||
return _buildHorizontalLayout(isLightTheme);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建水平布局
|
||||
Widget _buildHorizontalLayout(bool isLightTheme) {
|
||||
return IntrinsicWidth(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 操作按钮区域
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildActionButton(
|
||||
icon: Icons.check,
|
||||
label: 'Apply',
|
||||
tooltip: '应用生成的文本',
|
||||
onPressed: widget.isGenerating ? null : widget.onApply,
|
||||
),
|
||||
if (widget.isGenerating && widget.onStop != null)
|
||||
_buildActionButton(
|
||||
icon: Icons.stop,
|
||||
label: 'Stop',
|
||||
tooltip: '停止生成',
|
||||
onPressed: widget.onStop,
|
||||
)
|
||||
else
|
||||
_buildActionButton(
|
||||
icon: Icons.refresh,
|
||||
label: 'Retry',
|
||||
tooltip: '重新生成',
|
||||
onPressed: widget.isGenerating ? null : widget.onRetry,
|
||||
),
|
||||
_buildActionButton(
|
||||
icon: Icons.close,
|
||||
label: 'Discard',
|
||||
tooltip: widget.isGenerating ? '停止并丢弃生成的文本' : '丢弃生成的文本',
|
||||
onPressed: widget.onDiscard,
|
||||
),
|
||||
_buildActionButton(
|
||||
icon: Icons.crop_free,
|
||||
label: 'Section',
|
||||
tooltip: '分段处理',
|
||||
onPressed: widget.isGenerating ? null : widget.onSection,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 分隔线
|
||||
Container(
|
||||
width: 1,
|
||||
height: 32,
|
||||
color: WebTheme.getSecondaryBorderColor(context),
|
||||
),
|
||||
// 信息区域
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: _buildInfoContent(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建垂直布局(当空间不足时)
|
||||
Widget _buildVerticalLayout(bool isLightTheme) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 操作按钮区域
|
||||
Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildActionButton(
|
||||
icon: Icons.check,
|
||||
label: 'Apply',
|
||||
tooltip: '应用生成的文本',
|
||||
onPressed: widget.isGenerating ? null : widget.onApply,
|
||||
),
|
||||
if (widget.isGenerating && widget.onStop != null)
|
||||
_buildActionButton(
|
||||
icon: Icons.stop,
|
||||
label: 'Stop',
|
||||
tooltip: '停止生成',
|
||||
onPressed: widget.onStop,
|
||||
)
|
||||
else
|
||||
_buildActionButton(
|
||||
icon: Icons.refresh,
|
||||
label: 'Retry',
|
||||
tooltip: '重新生成',
|
||||
onPressed: widget.isGenerating ? null : widget.onRetry,
|
||||
),
|
||||
_buildActionButton(
|
||||
icon: Icons.close,
|
||||
label: 'Discard',
|
||||
tooltip: widget.isGenerating ? '停止并丢弃生成的文本' : '丢弃生成的文本',
|
||||
onPressed: widget.onDiscard,
|
||||
),
|
||||
_buildActionButton(
|
||||
icon: Icons.crop_free,
|
||||
label: 'Section',
|
||||
tooltip: '分段处理',
|
||||
onPressed: widget.isGenerating ? null : widget.onSection,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// 分隔线
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 1,
|
||||
color: WebTheme.getSecondaryBorderColor(context),
|
||||
),
|
||||
// 信息区域
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: _buildInfoContent(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建信息内容
|
||||
Widget _buildInfoContent() {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.isGenerating) ...[
|
||||
const SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1.5,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(WebTheme.white),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'生成中...',
|
||||
style: const TextStyle(
|
||||
color: WebTheme.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Flexible(
|
||||
child: Text(
|
||||
'${widget.wordCount} Words',
|
||||
style: const TextStyle(
|
||||
color: WebTheme.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
', ',
|
||||
style: TextStyle(
|
||||
color: WebTheme.white,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.modelName,
|
||||
style: const TextStyle(
|
||||
color: WebTheme.white,
|
||||
fontSize: 12,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 估算内容总宽度
|
||||
double _estimateContentWidth() {
|
||||
// 操作按钮: 4个按钮 * 80px ≈ 320px
|
||||
// 分隔线: 1px
|
||||
// 信息区域: 约150px
|
||||
// 内边距: 约30px
|
||||
return 320 + 1 + 150 + 30; // ≈ 501px
|
||||
}
|
||||
|
||||
/// 构建操作按钮
|
||||
Widget _buildActionButton({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required String tooltip,
|
||||
required VoidCallback? onPressed,
|
||||
}) {
|
||||
final isEnabled = onPressed != null;
|
||||
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: MouseRegion(
|
||||
cursor: isEnabled ? SystemMouseCursors.click : SystemMouseCursors.forbidden,
|
||||
opaque: true,
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 14,
|
||||
color: isEnabled
|
||||
? WebTheme.white
|
||||
: WebTheme.white,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isEnabled
|
||||
? WebTheme.white
|
||||
: WebTheme.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:ainoval/blocs/editor/editor_bloc.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
|
||||
/// AI场景生成侧边栏,用于显示从摘要生成的场景内容
|
||||
class AISceneGenerationSidePanel extends StatefulWidget {
|
||||
const AISceneGenerationSidePanel({
|
||||
Key? key,
|
||||
required this.onClose,
|
||||
required this.onInsert,
|
||||
}) : super(key: key);
|
||||
|
||||
/// 关闭面板时的回调
|
||||
final VoidCallback onClose;
|
||||
|
||||
/// 插入内容到编辑器的回调
|
||||
final Function(String content) onInsert;
|
||||
|
||||
@override
|
||||
State<AISceneGenerationSidePanel> createState() => _AISceneGenerationSidePanelState();
|
||||
}
|
||||
|
||||
class _AISceneGenerationSidePanelState extends State<AISceneGenerationSidePanel> {
|
||||
/// 编辑器控制器
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
|
||||
/// 滚动控制器
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
/// 是否已滚动到底部
|
||||
bool _isScrolledToBottom = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// 监听滚动事件,判断是否在底部
|
||||
_scrollController.addListener(_scrollListener);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_scrollController.removeListener(_scrollListener);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 滚动监听器,判断是否在底部
|
||||
void _scrollListener() {
|
||||
if (_scrollController.hasClients) {
|
||||
final isBottom = _scrollController.position.pixels >=
|
||||
_scrollController.position.maxScrollExtent - 50;
|
||||
if (isBottom != _isScrolledToBottom) {
|
||||
setState(() {
|
||||
_isScrolledToBottom = isBottom;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 复制内容到剪贴板
|
||||
void _copyToClipboard() {
|
||||
Clipboard.setData(ClipboardData(text: _controller.text)).then((_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('内容已复制到剪贴板')),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocConsumer<EditorBloc, EditorState>(
|
||||
listener: (context, state) {
|
||||
if (state is EditorLoaded && state.generatedSceneContent != null) {
|
||||
// 更新编辑器内容
|
||||
_controller.text = state.generatedSceneContent!;
|
||||
|
||||
// 如果用户滚动在底部,自动滚动到最新内容
|
||||
if (_isScrolledToBottom && _scrollController.hasClients) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state is! EditorLoaded) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final editorState = state as EditorLoaded;
|
||||
final isGenerating = editorState.aiSceneGenerationStatus == AIGenerationStatus.generating;
|
||||
final isCompleted = editorState.aiSceneGenerationStatus == AIGenerationStatus.completed;
|
||||
final isFailed = editorState.aiSceneGenerationStatus == AIGenerationStatus.failed;
|
||||
|
||||
return Container(
|
||||
width: 350, // 固定宽度
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(-2, 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题栏
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getPrimaryColor(context).withOpacity(0.1),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'AI 生成的场景',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const Spacer(),
|
||||
// 状态显示
|
||||
if (isGenerating)
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
WebTheme.getPrimaryColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'正在生成...',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
],
|
||||
)
|
||||
else if (isCompleted)
|
||||
const Text(
|
||||
'已完成',
|
||||
style: TextStyle(fontSize: 12, color: Colors.green),
|
||||
)
|
||||
else if (isFailed)
|
||||
const Text(
|
||||
'生成失败',
|
||||
style: TextStyle(fontSize: 12, color: Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 内容区域
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
// 文本编辑器
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
scrollController: _scrollController,
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
decoration: const InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText: '生成的内容将显示在这里...',
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 错误信息
|
||||
if (isFailed && editorState.aiGenerationError != null)
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.red.shade200),
|
||||
),
|
||||
child: Text(
|
||||
'错误: ${editorState.aiGenerationError}',
|
||||
style: TextStyle(
|
||||
color: Colors.red.shade800,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 操作栏
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
// 复制按钮
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy),
|
||||
tooltip: '复制内容',
|
||||
onPressed: _controller.text.isNotEmpty
|
||||
? _copyToClipboard
|
||||
: null,
|
||||
),
|
||||
// 插入原文按钮
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
tooltip: '插入到编辑器',
|
||||
onPressed: (isCompleted || !isGenerating) && _controller.text.isNotEmpty
|
||||
? () => widget.onInsert(_controller.text)
|
||||
: null,
|
||||
),
|
||||
// 停止生成按钮
|
||||
if (isGenerating)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.stop_circle_outlined),
|
||||
tooltip: '停止生成',
|
||||
onPressed: () {
|
||||
context.read<EditorBloc>().add(const StopSceneGeneration());
|
||||
},
|
||||
),
|
||||
// 关闭按钮
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: '关闭',
|
||||
onPressed: widget.onClose,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,787 @@
|
||||
// import 'dart:math'; // Added for min function
|
||||
import 'package:ainoval/screens/editor/widgets/floating_setting_dialogs.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:ainoval/models/novel_setting_item.dart';
|
||||
import 'package:ainoval/models/setting_type.dart'; // Your SettingType enum
|
||||
import 'package:ainoval/blocs/ai_setting_generation/ai_setting_generation_bloc.dart'; // Correct BLoC import
|
||||
import 'package:ainoval/models/novel_structure.dart'; // Import for Chapter model
|
||||
import 'package:ainoval/services/api_service/repositories/editor_repository.dart'; // Import EditorRepository
|
||||
import 'package:ainoval/services/api_service/repositories/novel_ai_repository.dart'; // Needed for BLoC creation
|
||||
import 'package:ainoval/blocs/setting/setting_bloc.dart';
|
||||
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
|
||||
// Removed placeholder BLoC, State, and Event definitions
|
||||
|
||||
class AISettingGenerationPanel extends StatelessWidget {
|
||||
final String novelId;
|
||||
final VoidCallback onClose;
|
||||
final bool isCardMode;
|
||||
final EditorRepository editorRepository; // Added
|
||||
final NovelAIRepository novelAIRepository; // Added
|
||||
|
||||
const AISettingGenerationPanel({
|
||||
Key? key,
|
||||
required this.novelId,
|
||||
required this.onClose,
|
||||
required this.editorRepository, // Added
|
||||
required this.novelAIRepository, // Added
|
||||
this.isCardMode = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<AISettingGenerationBloc>(
|
||||
create: (context) => AISettingGenerationBloc(
|
||||
editorRepository: editorRepository, // Changed from context.read
|
||||
novelAIRepository: novelAIRepository, // Changed from context.read
|
||||
)..add(LoadInitialDataForAISettingPanel(novelId)),
|
||||
child: AISettingGenerationView(novelId: novelId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AISettingGenerationView extends StatefulWidget {
|
||||
final String novelId;
|
||||
const AISettingGenerationView({Key? key, required this.novelId}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<AISettingGenerationView> createState() => _AISettingGenerationViewState();
|
||||
}
|
||||
|
||||
// 章节选择项数据模型
|
||||
class ChapterOption {
|
||||
final String id;
|
||||
final String title;
|
||||
final int order;
|
||||
final int globalOrder; // 全局排序序号
|
||||
final String actTitle;
|
||||
final int actOrder;
|
||||
|
||||
ChapterOption({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.order,
|
||||
required this.globalOrder,
|
||||
required this.actTitle,
|
||||
required this.actOrder,
|
||||
});
|
||||
|
||||
String get displayTitle {
|
||||
final chapterTitle = title.isNotEmpty ? title : '无标题章节';
|
||||
return '第${globalOrder}章 $chapterTitle';
|
||||
}
|
||||
|
||||
String get actDisplayTitle {
|
||||
return actTitle.isNotEmpty ? actTitle : '第${actOrder}卷';
|
||||
}
|
||||
}
|
||||
|
||||
class _AISettingGenerationViewState extends State<AISettingGenerationView> {
|
||||
String? _selectedStartChapterId;
|
||||
String? _selectedEndChapterId;
|
||||
final List<SettingTypeOption> _settingTypeOptions =
|
||||
SettingType.values.map((type) => SettingTypeOption(type)).toList();
|
||||
final _maxSettingsController = TextEditingController(text: '3');
|
||||
final _instructionsController = TextEditingController();
|
||||
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// 生成排序后的章节选项列表
|
||||
List<ChapterOption> _generateChapterOptions(List<Chapter> chapters, Novel? novel) {
|
||||
List<ChapterOption> options = [];
|
||||
int globalOrder = 1;
|
||||
|
||||
if (novel == null) {
|
||||
// 回退方案:没有Novel信息时,简单排序
|
||||
chapters.sort((a, b) => a.order.compareTo(b.order));
|
||||
for (final chapter in chapters) {
|
||||
options.add(ChapterOption(
|
||||
id: chapter.id,
|
||||
title: chapter.title,
|
||||
order: chapter.order,
|
||||
globalOrder: globalOrder++,
|
||||
actTitle: '',
|
||||
actOrder: 1,
|
||||
));
|
||||
}
|
||||
} else {
|
||||
// 有Novel信息时,按Act和章节顺序正确排序
|
||||
final sortedActs = novel.acts..sort((a, b) => a.order.compareTo(b.order));
|
||||
|
||||
for (final act in sortedActs) {
|
||||
final sortedChapters = act.chapters..sort((a, b) => a.order.compareTo(b.order));
|
||||
|
||||
for (final chapter in sortedChapters) {
|
||||
// 只处理在chapters列表中的章节(可能有过滤)
|
||||
if (chapters.any((c) => c.id == chapter.id)) {
|
||||
options.add(ChapterOption(
|
||||
id: chapter.id,
|
||||
title: chapter.title,
|
||||
order: chapter.order,
|
||||
globalOrder: globalOrder++,
|
||||
actTitle: act.title,
|
||||
actOrder: act.order,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_maxSettingsController.dispose();
|
||||
_instructionsController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 0), // Changed from 24, assuming MultiAIPanelView handles top padding for header
|
||||
child: Column(
|
||||
children: [
|
||||
_buildConfigurationArea(context, theme),
|
||||
const Divider(height: 1, thickness: 1),
|
||||
Expanded(child: _buildResultsArea(context, theme)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConfigurationArea(BuildContext context, ThemeData theme) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
BlocBuilder<AISettingGenerationBloc, AISettingGenerationState>(
|
||||
builder: (context, state) {
|
||||
List<Chapter> chapters = [];
|
||||
Novel? novel;
|
||||
bool isLoadingChapters = true;
|
||||
String? chapterLoadingError;
|
||||
|
||||
if (state is AISettingGenerationDataLoaded) {
|
||||
chapters = state.chapters;
|
||||
novel = state.novel;
|
||||
isLoadingChapters = false;
|
||||
} else if (state is AISettingGenerationSuccess) {
|
||||
chapters = state.chapters;
|
||||
novel = state.novel;
|
||||
isLoadingChapters = false;
|
||||
} else if (state is AISettingGenerationFailure) {
|
||||
chapters = state.chapters; // Might still have chapters from a previous successful load
|
||||
novel = state.novel;
|
||||
isLoadingChapters = false;
|
||||
if(chapters.isEmpty) chapterLoadingError = state.error; // Only show error if no chapters displayed
|
||||
} else if (state is AISettingGenerationLoadingChapters || state is AISettingGenerationInitial) {
|
||||
isLoadingChapters = true;
|
||||
} else {
|
||||
isLoadingChapters = false;
|
||||
}
|
||||
|
||||
if (isLoadingChapters) {
|
||||
return const Center(child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24.0),
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
));
|
||||
}
|
||||
if (chapterLoadingError != null) {
|
||||
return Center(child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: Text('加载章节失败: $chapterLoadingError', style: TextStyle(color: theme.colorScheme.error)),
|
||||
));
|
||||
}
|
||||
if (chapters.isEmpty) {
|
||||
return const Center(child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24.0),
|
||||
child: Text('没有可用的章节。'),
|
||||
));
|
||||
}
|
||||
|
||||
final chapterOptions = _generateChapterOptions(chapters, novel);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_buildChapterDropdown(
|
||||
context: context,
|
||||
theme: theme,
|
||||
label: '起始章节',
|
||||
value: _selectedStartChapterId,
|
||||
options: chapterOptions,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedStartChapterId = value;
|
||||
if (_selectedEndChapterId != null && _selectedStartChapterId != null) {
|
||||
final startOption = chapterOptions.firstWhere((opt) => opt.id == _selectedStartChapterId);
|
||||
final endOption = chapterOptions.firstWhere((opt) => opt.id == _selectedEndChapterId);
|
||||
if (endOption.globalOrder < startOption.globalOrder) {
|
||||
_selectedEndChapterId = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
validator: (value) => value == null ? '请选择起始章节' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildChapterDropdown(
|
||||
context: context,
|
||||
theme: theme,
|
||||
label: '结束章节 (可选)',
|
||||
value: _selectedEndChapterId,
|
||||
options: chapterOptions.where((option) {
|
||||
if (_selectedStartChapterId == null) return true;
|
||||
final startOption = chapterOptions.firstWhere((opt) => opt.id == _selectedStartChapterId);
|
||||
return option.globalOrder >= startOption.globalOrder;
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedEndChapterId = value;
|
||||
});
|
||||
},
|
||||
hasDefaultOption: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('希望生成的设定类型:', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8.0,
|
||||
runSpacing: 4.0,
|
||||
children: _settingTypeOptions.map((option) {
|
||||
return FilterChip(
|
||||
label: Text(option.type.displayName, style: const TextStyle(fontSize: 12)),
|
||||
selected: option.isSelected,
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
option.isSelected = selected;
|
||||
});
|
||||
},
|
||||
checkmarkColor: option.isSelected ? theme.colorScheme.onPrimary : null,
|
||||
selectedColor: WebTheme.getPrimaryColor(context),
|
||||
labelStyle: TextStyle(
|
||||
color: option.isSelected ? theme.colorScheme.onPrimary : theme.textTheme.bodySmall?.color,
|
||||
fontWeight: option.isSelected ? FontWeight.bold : FontWeight.normal),
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: option.isSelected ? WebTheme.getPrimaryColor(context) : theme.colorScheme.outline,
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _maxSettingsController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '每类生成数量 (1-5)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) return '请输入数量';
|
||||
final num = int.tryParse(value);
|
||||
if (num == null || num < 1 || num > 5) return '请输入1到5之间的数字';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _instructionsController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '其他说明或风格引导 (可选)',
|
||||
hintText: '例如:希望角色更神秘,或侧重描写地点的历史感',
|
||||
border: OutlineInputBorder(),
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
maxLines: 2,
|
||||
maxLength: 200,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Center(
|
||||
child: BlocBuilder<AISettingGenerationBloc, AISettingGenerationState>(
|
||||
builder: (context, state) {
|
||||
bool isLoading = state is AISettingGenerationInProgress;
|
||||
return ElevatedButton.icon(
|
||||
icon: isLoading
|
||||
? const SizedBox(width:16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||
: const Icon(Icons.auto_awesome_outlined, size: 18),
|
||||
label: Text(isLoading ? '生成中...' : '开始生成设定'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
textStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)
|
||||
),
|
||||
onPressed: isLoading ? null : () {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
final selectedTypes = _settingTypeOptions
|
||||
.where((opt) => opt.isSelected)
|
||||
.map((opt) => opt.type.value)
|
||||
.toList();
|
||||
if (selectedTypes.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('请至少选择一个设定类型'), backgroundColor: Colors.orange)
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (_selectedStartChapterId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('请选择起始章节'), backgroundColor: Colors.orange)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
context.read<AISettingGenerationBloc>().add(GenerateSettingsRequested(
|
||||
novelId: widget.novelId,
|
||||
startChapterId: _selectedStartChapterId!,
|
||||
endChapterId: _selectedEndChapterId,
|
||||
settingTypes: selectedTypes,
|
||||
maxSettingsPerType: int.parse(_maxSettingsController.text),
|
||||
additionalInstructions: _instructionsController.text,
|
||||
));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12), // Add some bottom padding
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChapterDropdown({
|
||||
required BuildContext context,
|
||||
required ThemeData theme,
|
||||
required String label,
|
||||
required String? value,
|
||||
required List<ChapterOption> options,
|
||||
required ValueChanged<String?> onChanged,
|
||||
String? Function(String?)? validator,
|
||||
bool hasDefaultOption = false,
|
||||
}) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: theme.dividerColor),
|
||||
),
|
||||
child: DropdownButtonFormField<String>(
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
labelStyle: TextStyle(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
value: value,
|
||||
isExpanded: true, // 确保下拉框内容完全显示
|
||||
icon: Icon(Icons.keyboard_arrow_down, color: theme.colorScheme.onSurfaceVariant),
|
||||
items: [
|
||||
if (hasDefaultOption)
|
||||
DropdownMenuItem<String>(
|
||||
value: null,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.auto_awesome, size: 18, color: WebTheme.getPrimaryColor(context)),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'到最新章节 (默认)',
|
||||
style: TextStyle(
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
...options.map((option) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: option.id,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
option.displayTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
if (option.actTitle.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
option.actDisplayTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
onChanged: onChanged,
|
||||
validator: validator,
|
||||
selectedItemBuilder: (BuildContext context) {
|
||||
return [
|
||||
if (hasDefaultOption)
|
||||
Text(
|
||||
'到最新章节 (默认)',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurface,
|
||||
fontSize: 14,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
...options.map((option) {
|
||||
return Text(
|
||||
option.displayTitle,
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurface,
|
||||
fontSize: 14,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
}).toList(),
|
||||
];
|
||||
},
|
||||
dropdownColor: theme.cardColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
elevation: 8,
|
||||
menuMaxHeight: 300, // 限制下拉菜单最大高度
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResultsArea(BuildContext context, ThemeData theme) {
|
||||
return BlocBuilder<AISettingGenerationBloc, AISettingGenerationState>(
|
||||
builder: (context, state) {
|
||||
if (state is AISettingGenerationInProgress) {
|
||||
return const Center(child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('正在分析章节并生成设定,请稍候...')
|
||||
],
|
||||
));
|
||||
}
|
||||
if (state is AISettingGenerationSuccess) {
|
||||
if (state.generatedSettings.isEmpty) {
|
||||
return const Center(child: Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Text('AI未能根据您的选择生成任何设定,请尝试调整选项或章节内容后再试。', textAlign: TextAlign.center,)
|
||||
));
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
itemCount: state.generatedSettings.length,
|
||||
itemBuilder: (context, index) {
|
||||
return NovelSettingItemCard(
|
||||
settingItem: state.generatedSettings[index],
|
||||
novelId: widget.novelId,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
if (state is AISettingGenerationFailure) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: theme.colorScheme.error, size: 48),
|
||||
const SizedBox(height:16),
|
||||
Text('生成设定时出错:', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height:8),
|
||||
Text(state.error, style: TextStyle(color: theme.colorScheme.error), textAlign: TextAlign.center,),
|
||||
const SizedBox(height:16),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
label: const Text('重试'),
|
||||
onPressed: (){
|
||||
if (_formKey.currentState!.validate()) {
|
||||
final selectedTypes = _settingTypeOptions
|
||||
.where((opt) => opt.isSelected)
|
||||
.map((opt) => opt.type.value)
|
||||
.toList();
|
||||
if (selectedTypes.isEmpty || _selectedStartChapterId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('请确保已选择起始章节和至少一个设定类型再重试。'), backgroundColor: Colors.orange)
|
||||
);
|
||||
return;
|
||||
}
|
||||
context.read<AISettingGenerationBloc>().add(GenerateSettingsRequested(
|
||||
novelId: widget.novelId,
|
||||
startChapterId: _selectedStartChapterId!,
|
||||
endChapterId: _selectedEndChapterId,
|
||||
settingTypes: selectedTypes,
|
||||
maxSettingsPerType: int.parse(_maxSettingsController.text),
|
||||
additionalInstructions: _instructionsController.text,
|
||||
));
|
||||
}
|
||||
}
|
||||
)
|
||||
],
|
||||
)
|
||||
),
|
||||
);
|
||||
}
|
||||
// Initial or other states
|
||||
return const Center(child: Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Text('请选择起始章节和希望生成的设定类型,然后点击"开始生成设定"按钮。', textAlign: TextAlign.center,)
|
||||
));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NovelSettingItemCard extends StatefulWidget {
|
||||
final NovelSettingItem settingItem;
|
||||
final String novelId;
|
||||
|
||||
const NovelSettingItemCard({
|
||||
Key? key,
|
||||
required this.settingItem,
|
||||
required this.novelId,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<NovelSettingItemCard> createState() => _NovelSettingItemCardState();
|
||||
}
|
||||
|
||||
class _NovelSettingItemCardState extends State<NovelSettingItemCard> {
|
||||
bool _isExpanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final typeEnum = SettingType.fromValue(widget.settingItem.type ?? 'OTHER');
|
||||
final itemAttributes = widget.settingItem.attributes; // Store in a local variable
|
||||
final itemTags = widget.settingItem.tags; // Store in a local variable
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 6.0),
|
||||
elevation: 1.5,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)), // Softer corners
|
||||
clipBehavior: Clip.antiAlias, // Ensures content respects border radius
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start, // Align items to the top
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.settingItem.name,
|
||||
style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold, fontSize: 15),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Chip(
|
||||
label: Text(typeEnum.displayName, style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w500)),
|
||||
backgroundColor: _getTypeColor(typeEnum).withOpacity(0.15),
|
||||
labelStyle: TextStyle(color: _getTypeColor(typeEnum)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: const VisualDensity(horizontal: 0.0, vertical: -2), // Compact chip
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
widget.settingItem.description ?? '无描述',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant, fontSize: 13, height: 1.4),
|
||||
maxLines: _isExpanded ? null : 3, // Show a bit more before expanding
|
||||
overflow: _isExpanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
),
|
||||
if ((widget.settingItem.description?.length ?? 0) > 120) // Show expand if description is somewhat long
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
style: TextButton.styleFrom(padding: EdgeInsets.zero, minimumSize: const Size(50,30), visualDensity: VisualDensity.compact),
|
||||
child: Text(_isExpanded ? '收起' : '展开', style: TextStyle(fontSize: 12, color: WebTheme.getPrimaryColor(context))),
|
||||
onPressed: () => setState(() => _isExpanded = !_isExpanded)),
|
||||
),
|
||||
|
||||
if ((itemAttributes?.isNotEmpty ?? false) || (itemTags?.isNotEmpty ?? false)) ...[
|
||||
const SizedBox(height: 6),
|
||||
Divider(thickness: 0.5, color: theme.dividerColor.withOpacity(0.5)),
|
||||
const SizedBox(height: 6),
|
||||
if (itemAttributes?.isNotEmpty ?? false)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: itemAttributes!.entries.map((e) => Chip(
|
||||
label: Text('${e.key}: ${e.value}', style: const TextStyle(fontSize: 10)),
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
backgroundColor: theme.colorScheme.surfaceVariant.withOpacity(0.7),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
||||
)).toList(),
|
||||
),
|
||||
),
|
||||
if (itemTags?.isNotEmpty ?? false)
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: itemTags!.map((tag) => Chip(
|
||||
label: Text(tag, style: const TextStyle(fontSize: 10)),
|
||||
backgroundColor: theme.colorScheme.secondaryContainer.withOpacity(0.6),
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
||||
)).toList(),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.add_circle_outline, size: 16),
|
||||
label: const Text('采纳到设定组', style: TextStyle(fontSize: 12)),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: WebTheme.getPrimaryColor(context),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
onPressed: () {
|
||||
_showAdoptDialog(context, widget.settingItem, widget.novelId);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getTypeColor(SettingType type) {
|
||||
switch (type) {
|
||||
case SettingType.character: return Colors.blue.shade600;
|
||||
case SettingType.location: return Colors.green.shade600;
|
||||
case SettingType.item: return Colors.orange.shade700;
|
||||
case SettingType.lore: return Colors.purple.shade600;
|
||||
case SettingType.event: return Colors.red.shade600;
|
||||
case SettingType.concept: return Colors.teal.shade600;
|
||||
case SettingType.faction: return Colors.indigo.shade600;
|
||||
case SettingType.creature: return Colors.brown.shade600;
|
||||
case SettingType.magicSystem: return Colors.cyan.shade600;
|
||||
case SettingType.technology: return Colors.blueGrey.shade600;
|
||||
case SettingType.culture: return Colors.deepOrange.shade600;
|
||||
case SettingType.history: return Colors.brown.shade600;
|
||||
case SettingType.organization: return Colors.indigo.shade600;
|
||||
case SettingType.worldview: return Colors.purple.shade600;
|
||||
case SettingType.pleasurePoint: return Colors.redAccent.shade200;
|
||||
case SettingType.anticipationHook: return Colors.teal.shade400;
|
||||
case SettingType.theme: return Colors.blueGrey.shade500;
|
||||
case SettingType.tone: return Colors.amber.shade700;
|
||||
case SettingType.style: return Colors.cyan.shade700;
|
||||
case SettingType.trope: return Colors.pink.shade400;
|
||||
case SettingType.plotDevice: return Colors.green.shade600;
|
||||
case SettingType.powerSystem: return Colors.orange.shade700;
|
||||
case SettingType.timeline: return Colors.blue.shade600;
|
||||
case SettingType.religion: return Colors.deepPurple.shade600;
|
||||
case SettingType.politics: return Colors.red.shade700;
|
||||
case SettingType.economy: return Colors.lightGreen.shade700;
|
||||
case SettingType.geography: return Colors.lightBlue.shade700;
|
||||
default: return Colors.grey.shade600;
|
||||
}
|
||||
}
|
||||
|
||||
void _showAdoptDialog(BuildContext context, NovelSettingItem itemToAdopt, String novelId) {
|
||||
final settingBloc = context.read<SettingBloc>();
|
||||
|
||||
AppLogger.i("AISettingGenerationPanel", "准备采纳设定: ${itemToAdopt.name}, 描述长度: ${itemToAdopt.description?.length ?? 0}, 标签数量: ${itemToAdopt.tags?.length ?? 0}, 属性数量: ${itemToAdopt.attributes?.length ?? 0}");
|
||||
|
||||
FloatingSettingDialogs.showSettingGroupSelection(
|
||||
context: context,
|
||||
novelId: novelId,
|
||||
onGroupSelected: (groupId, groupName) {
|
||||
// 显示操作提示
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('正在将 "${itemToAdopt.name}" 添加到 "$groupName"...'))
|
||||
);
|
||||
|
||||
// 确保类型值使用正确的枚举value值
|
||||
final typeValue = itemToAdopt.type;
|
||||
|
||||
// 准备创建的设定条目
|
||||
NovelSettingItem itemForCreation = itemToAdopt.copyWith(
|
||||
id: null,
|
||||
isAiSuggestion: false,
|
||||
status: 'ACTIVE',
|
||||
type: typeValue, // 确保使用原始的value值
|
||||
// 明确设置content和description,确保不会丢失
|
||||
content: "", // 不再使用content字段
|
||||
description: itemToAdopt.description, // 保留description作为主要描述字段
|
||||
attributes: itemToAdopt.attributes, // 确保属性被保留
|
||||
tags: itemToAdopt.tags, // 确保标签被保留
|
||||
generatedBy: "AI设定生成器" // 明确标记生成来源
|
||||
);
|
||||
|
||||
// 在安全的上下文环境中创建并添加到组
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
settingBloc.add(CreateSettingItemAndAddToGroup(
|
||||
novelId: novelId,
|
||||
item: itemForCreation,
|
||||
groupId: groupId,
|
||||
));
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,653 @@
|
||||
import 'package:ainoval/blocs/editor/editor_bloc.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'dart:async';
|
||||
|
||||
/// AI流式生成内容显示组件
|
||||
/// 在编辑器右侧面板中展示流式生成的内容,使用打字机效果
|
||||
class AIStreamGenerationDisplay extends StatefulWidget {
|
||||
const AIStreamGenerationDisplay({
|
||||
Key? key,
|
||||
required this.onClose,
|
||||
this.onOpenInEditor,
|
||||
}) : super(key: key);
|
||||
|
||||
/// 关闭面板的回调
|
||||
final VoidCallback onClose;
|
||||
|
||||
/// 在编辑器中打开内容的回调
|
||||
final Function(String content)? onOpenInEditor;
|
||||
|
||||
@override
|
||||
State<AIStreamGenerationDisplay> createState() => _AIStreamGenerationDisplayState();
|
||||
}
|
||||
|
||||
class _AIStreamGenerationDisplayState extends State<AIStreamGenerationDisplay> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
Timer? _autoScrollTimer;
|
||||
final TextEditingController _summaryController = TextEditingController();
|
||||
final TextEditingController _styleController = TextEditingController();
|
||||
bool _userScrolled = false;
|
||||
bool _showGeneratePanel = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// 初始化时检查是否有正在进行的生成,如有则自动滚动
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final state = context.read<EditorBloc>().state;
|
||||
if (state is EditorLoaded &&
|
||||
state.aiSceneGenerationStatus == AIGenerationStatus.generating &&
|
||||
state.generatedSceneContent != null &&
|
||||
state.generatedSceneContent!.isNotEmpty) {
|
||||
_scrollToBottom();
|
||||
AppLogger.i('AIStreamGenerationDisplay', '初始化时检测到生成内容,自动滚动到底部');
|
||||
}
|
||||
});
|
||||
|
||||
// 启动定期滚动更新
|
||||
_startAutoScrollTimer();
|
||||
|
||||
// 监听滚动事件,检测用户是否主动滚动
|
||||
_scrollController.addListener(_handleUserScroll);
|
||||
}
|
||||
|
||||
void _handleUserScroll() {
|
||||
if (_scrollController.hasClients) {
|
||||
// 如果用户向上滚动(滚动位置不在底部),标记为用户滚动
|
||||
if (_scrollController.position.pixels <
|
||||
_scrollController.position.maxScrollExtent - 50) {
|
||||
_userScrolled = true;
|
||||
}
|
||||
|
||||
// 如果用户滚动到底部,重置标记
|
||||
if (_scrollController.position.pixels >=
|
||||
_scrollController.position.maxScrollExtent - 10) {
|
||||
_userScrolled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _startAutoScrollTimer() {
|
||||
// 每500毫秒检查一次是否需要滚动
|
||||
_autoScrollTimer = Timer.periodic(const Duration(milliseconds: 500), (_) {
|
||||
final state = context.read<EditorBloc>().state;
|
||||
if (state is EditorLoaded &&
|
||||
state.isStreamingGeneration &&
|
||||
state.aiSceneGenerationStatus == AIGenerationStatus.generating &&
|
||||
!_userScrolled) { // 只有在用户没有主动滚动时自动滚动
|
||||
_scrollToBottom();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_autoScrollTimer?.cancel();
|
||||
_scrollController.removeListener(_handleUserScroll);
|
||||
_scrollController.dispose();
|
||||
_summaryController.dispose();
|
||||
_styleController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 自动滚动到底部
|
||||
void _scrollToBottom() {
|
||||
if (!_scrollController.hasClients) {
|
||||
AppLogger.d('AIStreamGenerationDisplay', '滚动控制器还没有客户端,延迟滚动');
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollToBottom();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
AppLogger.d('AIStreamGenerationDisplay', '执行滚动到底部');
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
} catch (e) {
|
||||
AppLogger.e('AIStreamGenerationDisplay', '滚动到底部失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 复制内容到剪贴板
|
||||
void _copyToClipboard(String content) {
|
||||
Clipboard.setData(ClipboardData(text: content)).then((_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('内容已复制到剪贴板')),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// 生成场景
|
||||
void _generateScene(BuildContext context) {
|
||||
if (_summaryController.text.isEmpty) return;
|
||||
|
||||
try {
|
||||
final state = context.read<EditorBloc>().state;
|
||||
if (state is! EditorLoaded) return;
|
||||
|
||||
// 触发场景生成请求
|
||||
context.read<EditorBloc>().add(
|
||||
GenerateSceneFromSummaryRequested(
|
||||
novelId: state.novel.id,
|
||||
summary: _summaryController.text,
|
||||
chapterId: state.activeChapterId,
|
||||
styleInstructions: _styleController.text.isNotEmpty
|
||||
? _styleController.text
|
||||
: null,
|
||||
useStreamingMode: true,
|
||||
),
|
||||
);
|
||||
|
||||
// 隐藏生成面板
|
||||
setState(() {
|
||||
_showGeneratePanel = false;
|
||||
});
|
||||
|
||||
// 重置用户滚动标记
|
||||
_userScrolled = false;
|
||||
|
||||
} catch (e) {
|
||||
AppLogger.e('AIStreamGenerationDisplay', '生成场景错误', e);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('启动AI生成时出错: ${e.toString()}')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocConsumer<EditorBloc, EditorState>(
|
||||
listener: (context, state) {
|
||||
if (state is EditorLoaded &&
|
||||
state.isStreamingGeneration &&
|
||||
state.generatedSceneContent != null &&
|
||||
state.generatedSceneContent!.isNotEmpty &&
|
||||
!_userScrolled) {
|
||||
_scrollToBottom();
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state is! EditorLoaded) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final isGenerating = state.aiSceneGenerationStatus == AIGenerationStatus.generating;
|
||||
final hasGenerated = state.aiSceneGenerationStatus == AIGenerationStatus.completed;
|
||||
final hasFailed = state.aiSceneGenerationStatus == AIGenerationStatus.failed;
|
||||
final content = state.generatedSceneContent ?? '';
|
||||
|
||||
return Container(
|
||||
width: 350, // 固定宽度
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(-2, 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 标题栏
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.7),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'AI 生成助手',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// 状态指示器
|
||||
if (isGenerating)
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
WebTheme.getPrimaryColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'正在流式生成...',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else if (hasGenerated)
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
size: 14,
|
||||
color: Colors.green.shade600,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'生成完成',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.green.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else if (hasFailed)
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error,
|
||||
size: 14,
|
||||
color: Colors.red.shade600,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'生成失败',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.red.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
padding: const EdgeInsets.all(4),
|
||||
onPressed: widget.onClose,
|
||||
tooltip: '关闭',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 内容标签
|
||||
if (!_showGeneratePanel)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
TabPageSelector(
|
||||
selectedColor: WebTheme.getPrimaryColor(context),
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
controller: TabController(
|
||||
initialIndex: 0,
|
||||
length: 2,
|
||||
vsync: const _TickerProviderImpl(),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// 添加生成场景按钮
|
||||
if (!isGenerating) // 只在不生成时显示
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showGeneratePanel = true;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('生成新场景'),
|
||||
style: TextButton.styleFrom(
|
||||
textStyle: const TextStyle(fontSize: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 生成面板 (新增)
|
||||
if (_showGeneratePanel)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'创建新场景',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _summaryController,
|
||||
maxLines: 4,
|
||||
decoration: InputDecoration(
|
||||
labelText: '场景摘要/大纲',
|
||||
hintText: '请输入场景大纲或摘要...',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _styleController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '风格指令(可选)',
|
||||
hintText: '多对话,少描写,悬疑风格...',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: (_summaryController.text.isNotEmpty || content.isNotEmpty)
|
||||
? () => _generateScene(context)
|
||||
: null,
|
||||
icon: const Icon(Icons.auto_awesome, size: 16),
|
||||
label: const Text('开始生成'),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showGeneratePanel = false;
|
||||
});
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 内容区域
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
if (content.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(), // 允许滚动
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
content,
|
||||
style: TextStyle(
|
||||
height: 1.8,
|
||||
fontSize: 15,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
// 底部空间
|
||||
if (isGenerating)
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (!isGenerating && !hasFailed)
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.description_outlined,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'生成的内容将显示在这里',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (isGenerating && content.isEmpty)
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'正在准备内容...',
|
||||
style: TextStyle(
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 生成指示器 (流式生成时在底部显示小提示)
|
||||
if (isGenerating && content.isNotEmpty)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
left: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Theme.of(context).colorScheme.surface.withOpacity(0),
|
||||
Theme.of(context).colorScheme.surface,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'正在生成中...',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 错误信息
|
||||
if (hasFailed && state.aiGenerationError != null)
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.red.shade200),
|
||||
),
|
||||
child: Text(
|
||||
'错误: ${state.aiGenerationError}',
|
||||
style: TextStyle(
|
||||
color: Colors.red.shade800,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 底部操作栏
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5),
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// 左侧按钮
|
||||
if (isGenerating)
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
context.read<EditorBloc>().add(StopSceneGeneration());
|
||||
},
|
||||
icon: const Icon(Icons.stop, size: 16),
|
||||
label: const Text('停止生成'),
|
||||
style: TextButton.styleFrom(
|
||||
textStyle: const TextStyle(fontSize: 13),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
)
|
||||
else
|
||||
FilledButton.icon(
|
||||
onPressed: hasGenerated && content.isNotEmpty
|
||||
? () {
|
||||
// 创建新场景并使用生成的内容
|
||||
if (widget.onOpenInEditor != null) {
|
||||
widget.onOpenInEditor!(content);
|
||||
AppLogger.i('AIStreamGenerationDisplay', '在编辑器中打开生成内容');
|
||||
widget.onClose();
|
||||
}
|
||||
}
|
||||
: null,
|
||||
icon: const Icon(Icons.save, size: 16),
|
||||
label: const Text('保存为场景'),
|
||||
style: FilledButton.styleFrom(
|
||||
textStyle: const TextStyle(fontSize: 13),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
),
|
||||
|
||||
// 右侧按钮
|
||||
Row(
|
||||
children: [
|
||||
if (!isGenerating && hasGenerated)
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showGeneratePanel = true;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
tooltip: '重新生成',
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
padding: const EdgeInsets.all(8),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: hasGenerated && content.isNotEmpty
|
||||
? () => _copyToClipboard(content)
|
||||
: null,
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
tooltip: '复制全部内容',
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
padding: const EdgeInsets.all(8),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 简单的TickerProvider实现,用于TabController
|
||||
class _TickerProviderImpl extends TickerProvider {
|
||||
const _TickerProviderImpl();
|
||||
|
||||
@override
|
||||
Ticker createTicker(TickerCallback onTick) => Ticker(onTick);
|
||||
}
|
||||
973
AINoval/lib/screens/editor/widgets/ai_summary_panel.dart
Normal file
973
AINoval/lib/screens/editor/widgets/ai_summary_panel.dart
Normal file
@@ -0,0 +1,973 @@
|
||||
import 'package:ainoval/blocs/editor/editor_bloc.dart';
|
||||
import 'package:ainoval/blocs/universal_ai/universal_ai_bloc.dart';
|
||||
import 'package:ainoval/blocs/universal_ai/universal_ai_event.dart';
|
||||
import 'package:ainoval/blocs/universal_ai/universal_ai_state.dart';
|
||||
import 'package:ainoval/blocs/ai_config/ai_config_bloc.dart';
|
||||
import 'package:ainoval/blocs/public_models/public_models_bloc.dart';
|
||||
import 'package:ainoval/models/novel_structure.dart';
|
||||
import 'package:ainoval/models/unified_ai_model.dart';
|
||||
import 'package:ainoval/models/ai_request_models.dart';
|
||||
// import 'package:ainoval/screens/editor/managers/editor_layout_manager.dart';
|
||||
import 'package:ainoval/widgets/common/unified_ai_model_dropdown.dart';
|
||||
import 'package:ainoval/models/context_selection_models.dart';
|
||||
import 'package:ainoval/widgets/common/form_dialog_template.dart';
|
||||
import 'package:ainoval/screens/editor/components/ai_dialog_common_logic.dart';
|
||||
import 'package:ainoval/widgets/common/scene_selector.dart';
|
||||
import 'package:ainoval/config/app_config.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:ainoval/utils/quill_helper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:ainoval/widgets/common/top_toast.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// AI摘要生成面板,提供根据场景内容生成摘要的功能
|
||||
class AISummaryPanel extends StatefulWidget {
|
||||
const AISummaryPanel({
|
||||
Key? key,
|
||||
required this.novelId,
|
||||
required this.onClose,
|
||||
this.isCardMode = false,
|
||||
}) : super(key: key);
|
||||
|
||||
final String novelId;
|
||||
final VoidCallback onClose;
|
||||
final bool isCardMode; // 是否以卡片模式显示
|
||||
|
||||
@override
|
||||
State<AISummaryPanel> createState() => _AISummaryPanelState();
|
||||
}
|
||||
|
||||
class _AISummaryPanelState extends State<AISummaryPanel> with AIDialogCommonLogic {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final TextEditingController _summaryController = TextEditingController();
|
||||
final LayerLink _layerLink = LayerLink();
|
||||
|
||||
UnifiedAIModel? _selectedModel;
|
||||
bool _enableSmartContext = true;
|
||||
// bool _userScrolled = false; // 未使用,先注释避免警告
|
||||
// bool _contentEdited = false; // 未使用,先注释避免警告
|
||||
bool _isGenerating = false;
|
||||
bool _thisInstanceIsGenerating = false; // 标记是否是当前实例发起的生成请求
|
||||
late ContextSelectionData _contextSelectionData;
|
||||
String? _selectedPromptTemplateId;
|
||||
// 临时自定义提示词
|
||||
String? _customSystemPrompt;
|
||||
String? _customUserPrompt;
|
||||
bool _contextInitialized = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// _contentEdited = false;
|
||||
|
||||
// 监听滚动事件,检测用户是否主动滚动
|
||||
_scrollController.addListener(_handleUserScroll);
|
||||
|
||||
// 初始化默认模型配置
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_initializeDefaultModel();
|
||||
_initializeContextData();
|
||||
});
|
||||
}
|
||||
|
||||
void _initializeDefaultModel() {
|
||||
final aiConfigState = context.read<AiConfigBloc>().state;
|
||||
final publicModelsState = context.read<PublicModelsBloc>().state;
|
||||
|
||||
// 合并私有模型和公共模型
|
||||
final allModels = _combineModels(aiConfigState, publicModelsState);
|
||||
|
||||
if (allModels.isNotEmpty && _selectedModel == null) {
|
||||
// 优先选择默认配置
|
||||
UnifiedAIModel? defaultModel;
|
||||
|
||||
// 首先查找私有模型中的默认配置
|
||||
for (final model in allModels) {
|
||||
if (!model.isPublic && (model as PrivateAIModel).userConfig.isDefault) {
|
||||
defaultModel = model;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有默认私有模型,选择第一个公共模型
|
||||
defaultModel ??= allModels.firstWhere(
|
||||
(model) => model.isPublic,
|
||||
orElse: () => allModels.first,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_selectedModel = defaultModel;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 合并私有模型和公共模型
|
||||
List<UnifiedAIModel> _combineModels(AiConfigState aiState, PublicModelsState publicState) {
|
||||
final List<UnifiedAIModel> allModels = [];
|
||||
|
||||
// 添加已验证的私有模型
|
||||
final validatedConfigs = aiState.validatedConfigs;
|
||||
for (final config in validatedConfigs) {
|
||||
allModels.add(PrivateAIModel(config));
|
||||
}
|
||||
|
||||
// 添加公共模型
|
||||
if (publicState is PublicModelsLoaded) {
|
||||
for (final publicModel in publicState.models) {
|
||||
allModels.add(PublicAIModel(publicModel));
|
||||
}
|
||||
}
|
||||
|
||||
return allModels;
|
||||
}
|
||||
|
||||
void _initializeContextData() {
|
||||
if (_contextInitialized) return;
|
||||
final editorState = context.read<EditorBloc>().state;
|
||||
if (editorState is EditorLoaded) {
|
||||
_contextSelectionData = ContextSelectionDataBuilder.fromNovel(editorState.novel);
|
||||
_contextInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_handleUserScroll);
|
||||
_scrollController.dispose();
|
||||
_summaryController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleUserScroll() {}
|
||||
|
||||
/// 复制内容到剪贴板
|
||||
void _copyToClipboard(String content) {
|
||||
Clipboard.setData(ClipboardData(text: content)).then((_) {
|
||||
TopToast.success(context, '摘要已复制到剪贴板');
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<EditorBloc, EditorState>(
|
||||
builder: (context, editorState) {
|
||||
if (editorState is! EditorLoaded) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return BlocConsumer<UniversalAIBloc, UniversalAIState>(
|
||||
listener: (context, state) {
|
||||
// 只处理摘要生成相关的状态变化
|
||||
if (state is UniversalAIStreaming) {
|
||||
// 检查是否是摘要生成请求
|
||||
if (_isSummaryRequest(state)) {
|
||||
setState(() {
|
||||
_isGenerating = true;
|
||||
_summaryController.text = state.partialResponse;
|
||||
// _contentEdited = false;
|
||||
});
|
||||
}
|
||||
} else if (state is UniversalAISuccess) {
|
||||
// 检查是否是摘要生成请求
|
||||
if (_isSummaryRequest(state)) {
|
||||
setState(() {
|
||||
_isGenerating = false;
|
||||
_thisInstanceIsGenerating = false; // 重置实例生成标记
|
||||
_summaryController.text = state.response.content;
|
||||
// _contentEdited = false;
|
||||
});
|
||||
}
|
||||
} else if (state is UniversalAIError) {
|
||||
// 检查是否是摘要生成请求
|
||||
if (_isSummaryRequest(state)) {
|
||||
setState(() {
|
||||
_isGenerating = false;
|
||||
_thisInstanceIsGenerating = false; // 重置实例生成标记
|
||||
});
|
||||
TopToast.error(context, '生成摘要失败: ${state.message}');
|
||||
}
|
||||
} else if (state is UniversalAILoading) {
|
||||
// 检查是否是摘要生成请求
|
||||
if (_isSummaryRequest(state)) {
|
||||
setState(() {
|
||||
_isGenerating = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
builder: (context, universalAIState) {
|
||||
return Column(
|
||||
children: [
|
||||
// 面板标题栏
|
||||
_buildHeader(context, editorState),
|
||||
|
||||
// 面板内容
|
||||
Expanded(
|
||||
child: _buildSummaryContentPanel(context, editorState),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context, EditorLoaded state) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 10.0),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getSurfaceColor(context),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: WebTheme.getSecondaryBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 标题行
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.summarize,
|
||||
size: 14,
|
||||
color: WebTheme.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'AI摘要助手',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
// 状态指示器
|
||||
if (_isGenerating) ...[
|
||||
const SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.black),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'正在生成...',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
|
||||
// 帮助按钮
|
||||
Tooltip(
|
||||
message: '使用说明',
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
Icons.help_outline,
|
||||
size: 16,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
padding: const EdgeInsets.all(4),
|
||||
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: WebTheme.getCardColor(context),
|
||||
surfaceTintColor: Colors.transparent,
|
||||
title: Text(
|
||||
'AI摘要生成说明',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
content: const SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'1. 选择要生成摘要的场景',
|
||||
style: TextStyle(fontSize: 12, color: Colors.black87),
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
Text(
|
||||
'2. 选择AI模型和配置',
|
||||
style: TextStyle(fontSize: 12, color: Colors.black87),
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
Text(
|
||||
'3. 点击"生成摘要"按钮',
|
||||
style: TextStyle(fontSize: 12, color: Colors.black87),
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
Text(
|
||||
'4. 生成完成后,可以直接编辑摘要内容',
|
||||
style: TextStyle(fontSize: 12, color: Colors.black87),
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
Text(
|
||||
'5. 点击"保存摘要"按钮将摘要保存到场景',
|
||||
style: TextStyle(fontSize: 12, color: Colors.black87),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: WebTheme.getPrimaryColor(context),
|
||||
foregroundColor: WebTheme.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
),
|
||||
child: const Text('了解了', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
IconButton(
|
||||
icon: Icon(Icons.close, size: 16, color: WebTheme.getSecondaryTextColor(context)),
|
||||
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
|
||||
padding: const EdgeInsets.all(4),
|
||||
onPressed: widget.onClose,
|
||||
tooltip: '关闭',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 当前场景信息行
|
||||
const SizedBox(height: 8),
|
||||
_buildCurrentSceneSelector(context, state),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCurrentSceneSelector(BuildContext context, EditorLoaded state) {
|
||||
return SceneSelector(
|
||||
novel: state.novel,
|
||||
activeSceneId: state.activeSceneId,
|
||||
onSceneSelected: (sceneId, actId, chapterId) {
|
||||
// 更新活跃场景
|
||||
context.read<EditorBloc>().add(SetActiveScene(
|
||||
actId: actId,
|
||||
chapterId: chapterId,
|
||||
sceneId: sceneId,
|
||||
));
|
||||
},
|
||||
onSummaryLoaded: (summary) {
|
||||
// 加载场景摘要到输入框
|
||||
setState(() {
|
||||
_summaryController.text = summary;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 构建摘要内容面板
|
||||
Widget _buildSummaryContentPanel(BuildContext context, EditorLoaded state) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(10.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 模型配置区域
|
||||
_buildModelConfigSection(context, state),
|
||||
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// 分割线
|
||||
Container(
|
||||
height: 1,
|
||||
color: WebTheme.getSecondaryBorderColor(context),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// 生成的摘要区域
|
||||
Expanded(
|
||||
child: _buildSummarySection(context, state),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildModelConfigSection(BuildContext context, EditorLoaded state) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getSurfaceColor(context),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: WebTheme.getSecondaryBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'模型设置',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 统一模型选择器
|
||||
_buildUnifiedModelSelector(context, state),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 智能上下文开关
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'智能上下文',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'启用后将自动检索相关的小说设定和背景信息',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Transform.scale(
|
||||
scale: 0.8,
|
||||
child: Switch(
|
||||
value: _enableSmartContext,
|
||||
activeColor: WebTheme.getPrimaryColor(context),
|
||||
activeTrackColor: WebTheme.getSecondaryBorderColor(context),
|
||||
inactiveThumbColor: WebTheme.getCardColor(context),
|
||||
inactiveTrackColor: WebTheme.getSecondaryBorderColor(context),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_enableSmartContext = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 上下文选择
|
||||
if (_contextInitialized)
|
||||
FormFieldFactory.createContextSelectionField(
|
||||
contextData: _contextSelectionData,
|
||||
onSelectionChanged: (newData) {
|
||||
setState(() {
|
||||
_contextSelectionData = newData;
|
||||
});
|
||||
},
|
||||
title: '附加上下文',
|
||||
description: '选择要包含在生成中的上下文信息',
|
||||
onReset: () {
|
||||
setState(() {
|
||||
_contextSelectionData = ContextSelectionDataBuilder.fromNovel(state.novel);
|
||||
});
|
||||
},
|
||||
dropdownWidth: 400,
|
||||
initialChapterId: state.activeChapterId,
|
||||
initialSceneId: state.activeSceneId,
|
||||
),
|
||||
|
||||
if (_contextInitialized) const SizedBox(height: 12),
|
||||
|
||||
// 关联提示词模板
|
||||
FormFieldFactory.createPromptTemplateSelectionField(
|
||||
selectedTemplateId: _selectedPromptTemplateId,
|
||||
onTemplateSelected: (templateId) {
|
||||
setState(() {
|
||||
_selectedPromptTemplateId = templateId;
|
||||
});
|
||||
},
|
||||
aiFeatureType: 'SCENE_TO_SUMMARY',
|
||||
title: '关联提示词模板',
|
||||
description: '可选,选择一个提示词模板优化摘要生成',
|
||||
onReset: () {
|
||||
setState(() {
|
||||
_selectedPromptTemplateId = null;
|
||||
});
|
||||
},
|
||||
onTemporaryPromptsSaved: (sys, user) {
|
||||
setState(() {
|
||||
_customSystemPrompt = sys.trim().isEmpty ? null : sys.trim();
|
||||
_customUserPrompt = user.trim().isEmpty ? null : user.trim();
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 生成按钮
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 36,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: (_getActiveScene(state) == null ||
|
||||
_getActiveScene(state)!.content.isEmpty ||
|
||||
_selectedModel == null ||
|
||||
_isGenerating)
|
||||
? null
|
||||
: () => _generateSummary(context, state),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.black,
|
||||
foregroundColor: Colors.white,
|
||||
disabledBackgroundColor: Colors.grey[300],
|
||||
disabledForegroundColor: Colors.grey[600],
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
icon: _isGenerating
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.auto_awesome, size: 14),
|
||||
label: Text(
|
||||
_isGenerating ? '生成中...' : '生成摘要',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建统一模型选择器
|
||||
Widget _buildUnifiedModelSelector(BuildContext context, EditorLoaded state) {
|
||||
return BlocBuilder<AiConfigBloc, AiConfigState>(
|
||||
builder: (context, aiState) {
|
||||
return BlocBuilder<PublicModelsBloc, PublicModelsState>(
|
||||
builder: (context, publicState) {
|
||||
final allModels = _combineModels(aiState, publicState);
|
||||
|
||||
return CompositedTransformTarget(
|
||||
link: _layerLink,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
_showModelDropdown(context, state, allModels);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: Colors.grey[300]!, width: 1),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _selectedModel != null
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_selectedModel!.displayName,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: _selectedModel!.isPublic ? Colors.green[50] : Colors.blue[50],
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
border: Border.all(
|
||||
color: _selectedModel!.isPublic ? Colors.green[200]! : Colors.blue[200]!,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_selectedModel!.isPublic ? '系统' : '私有',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: _selectedModel!.isPublic ? Colors.green[700] : Colors.blue[700],
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
_selectedModel!.provider,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
: const Text(
|
||||
'选择AI模型',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.black54,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.arrow_drop_down,
|
||||
color: Colors.black54,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示模型选择下拉菜单
|
||||
void _showModelDropdown(BuildContext context, EditorLoaded state, List<UnifiedAIModel> allModels) {
|
||||
UnifiedAIModelDropdown.show(
|
||||
context: context,
|
||||
layerLink: _layerLink,
|
||||
selectedModel: _selectedModel,
|
||||
onModelSelected: (model) {
|
||||
setState(() {
|
||||
_selectedModel = model;
|
||||
});
|
||||
},
|
||||
showSettingsButton: false,
|
||||
maxHeight: 300,
|
||||
novel: state.novel,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummarySection(BuildContext context, EditorLoaded state) {
|
||||
final hasContent = _summaryController.text.isNotEmpty;
|
||||
final activeScene = _getActiveScene(state);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'生成的摘要',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
if (hasContent && !_isGenerating) ...[
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(color: Colors.grey[300]!),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.copy, size: 14, color: Colors.black),
|
||||
tooltip: '复制到剪贴板',
|
||||
constraints: const BoxConstraints(),
|
||||
padding: EdgeInsets.zero,
|
||||
onPressed: () {
|
||||
_copyToClipboard(_summaryController.text);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
if (activeScene != null) ...[
|
||||
SizedBox(
|
||||
height: 28,
|
||||
child: ElevatedButton(
|
||||
onPressed: _summaryController.text.trim().isEmpty
|
||||
? null
|
||||
: () => _saveSummary(context, state, activeScene),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
disabledBackgroundColor: Colors.grey[200],
|
||||
disabledForegroundColor: Colors.grey,
|
||||
side: BorderSide(color: Colors.grey[300]!),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'保存摘要',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: Colors.grey[300]!,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: _isGenerating && _summaryController.text.isEmpty
|
||||
? const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.black),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'正在生成摘要...',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: !hasContent && !_isGenerating
|
||||
? const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.summarize,
|
||||
color: Colors.grey,
|
||||
size: 32,
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'点击"生成摘要"按钮开始生成',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: TextField(
|
||||
controller: _summaryController,
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
decoration: const InputDecoration(
|
||||
contentPadding: EdgeInsets.all(12),
|
||||
border: InputBorder.none,
|
||||
hintText: '生成的摘要将显示在这里',
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.4,
|
||||
color: Colors.black,
|
||||
),
|
||||
onChanged: (_) {
|
||||
setState(() {
|
||||
// _contentEdited = true;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 检查是否是摘要生成请求
|
||||
bool _isSummaryRequest(UniversalAIState state) {
|
||||
// 对于流式响应状态,只有当前实例发起的请求才处理
|
||||
if (state is UniversalAIStreaming) {
|
||||
return _thisInstanceIsGenerating;
|
||||
}
|
||||
// 对于成功状态,检查请求类型
|
||||
else if (state is UniversalAISuccess) {
|
||||
return state.response.requestType == AIRequestType.sceneSummary;
|
||||
}
|
||||
// 对于错误和加载状态,检查当前实例是否有生成任务
|
||||
else if (state is UniversalAIError || state is UniversalAILoading) {
|
||||
return _thisInstanceIsGenerating;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 生成摘要
|
||||
void _generateSummary(BuildContext context, EditorLoaded state) {
|
||||
final activeScene = _getActiveScene(state);
|
||||
if (activeScene == null || _selectedModel == null) return;
|
||||
|
||||
// 清空现有内容
|
||||
_summaryController.clear();
|
||||
|
||||
AppLogger.i('AISummaryPanel', '开始生成摘要,场景ID: ${activeScene.id}');
|
||||
|
||||
// 使用公共逻辑创建模型配置(公共模型会被包装为临时配置)
|
||||
final modelConfig = createModelConfig(_selectedModel!);
|
||||
|
||||
// 构建AI请求(先将Quill内容转换为纯文本)
|
||||
final String plainSceneText = QuillHelper.deltaToText(activeScene.content);
|
||||
// 构建元数据(包含公共模型标识)
|
||||
final metadata = createModelMetadata(_selectedModel!, {
|
||||
'actId': state.activeActId,
|
||||
'chapterId': state.activeChapterId,
|
||||
'sceneId': state.activeSceneId,
|
||||
'sceneTitle': activeScene.title,
|
||||
'wordCount': activeScene.wordCount,
|
||||
'action': 'scene_summary',
|
||||
'source': 'ai_summary_panel',
|
||||
});
|
||||
final request = UniversalAIRequest(
|
||||
requestType: AIRequestType.sceneSummary,
|
||||
userId: AppConfig.userId ?? 'unknown',
|
||||
novelId: widget.novelId,
|
||||
modelConfig: modelConfig,
|
||||
selectedText: plainSceneText, // 使用纯文本作为输入
|
||||
instructions: '请为这个小说场景生成一个准确、简洁的摘要,突出关键情节和重要细节。',
|
||||
contextSelections: _contextSelectionData,
|
||||
enableSmartContext: _enableSmartContext,
|
||||
parameters: {
|
||||
'temperature': 0.7,
|
||||
'maxTokens': 500,
|
||||
'promptTemplateId': _selectedPromptTemplateId,
|
||||
if (_customSystemPrompt != null) 'customSystemPrompt': _customSystemPrompt,
|
||||
if (_customUserPrompt != null) 'customUserPrompt': _customUserPrompt,
|
||||
},
|
||||
metadata: metadata,
|
||||
);
|
||||
|
||||
// 公共模型预估积分并确认
|
||||
if (_selectedModel!.isPublic) {
|
||||
handlePublicModelCreditConfirmation(_selectedModel!, request).then((ok) {
|
||||
if (!ok) return;
|
||||
setState(() { _thisInstanceIsGenerating = true; });
|
||||
context.read<UniversalAIBloc>().add(SendAIStreamRequestEvent(request));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 发送流式请求(私有模型直接发送)
|
||||
setState(() { _thisInstanceIsGenerating = true; });
|
||||
context.read<UniversalAIBloc>().add(SendAIStreamRequestEvent(request));
|
||||
}
|
||||
|
||||
void _saveSummary(BuildContext context, EditorLoaded state, Scene activeScene) {
|
||||
final summary = _summaryController.text.trim();
|
||||
if (summary.isEmpty) return;
|
||||
|
||||
// 保存摘要到场景
|
||||
context.read<EditorBloc>().add(
|
||||
UpdateSummary(
|
||||
novelId: widget.novelId,
|
||||
actId: state.activeActId!,
|
||||
chapterId: state.activeChapterId!,
|
||||
sceneId: activeScene.id,
|
||||
summary: summary,
|
||||
),
|
||||
);
|
||||
|
||||
// 显示保存成功提示
|
||||
TopToast.success(context, '摘要已保存');
|
||||
|
||||
// 已移除未使用的编辑状态标记
|
||||
|
||||
AppLogger.i('AISummaryPanel', '摘要已保存: ${activeScene.id}');
|
||||
}
|
||||
|
||||
// 获取当前活动场景
|
||||
Scene? _getActiveScene(EditorLoaded state) {
|
||||
if (state.activeSceneId != null && state.activeActId != null && state.activeChapterId != null) {
|
||||
// 获取完整的场景对象而不仅仅是ID
|
||||
final scene = state.novel.getScene(state.activeActId!, state.activeChapterId!, sceneId: state.activeSceneId);
|
||||
return scene;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
408
AINoval/lib/screens/editor/widgets/continue_writing_form.dart
Normal file
408
AINoval/lib/screens/editor/widgets/continue_writing_form.dart
Normal file
@@ -0,0 +1,408 @@
|
||||
import 'package:ainoval/models/user_ai_model_config_model.dart';
|
||||
import 'package:ainoval/services/api_service/repositories/user_ai_model_config_repository.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:ainoval/widgets/common/top_toast.dart';
|
||||
|
||||
/// 自动续写表单组件
|
||||
class ContinueWritingForm extends StatefulWidget {
|
||||
const ContinueWritingForm({
|
||||
super.key,
|
||||
required this.novelId,
|
||||
required this.userId,
|
||||
required this.onCancel,
|
||||
required this.onSubmit,
|
||||
required this.userAiModelConfigRepository,
|
||||
});
|
||||
|
||||
final String novelId;
|
||||
final String userId;
|
||||
final VoidCallback onCancel;
|
||||
final Function(Map<String, dynamic> parameters) onSubmit;
|
||||
final UserAIModelConfigRepository userAiModelConfigRepository;
|
||||
|
||||
@override
|
||||
State<ContinueWritingForm> createState() => _ContinueWritingFormState();
|
||||
}
|
||||
|
||||
class _ContinueWritingFormState extends State<ContinueWritingForm> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _numberOfChaptersController = TextEditingController(text: '1');
|
||||
final _contextChapterCountController = TextEditingController(text: '3');
|
||||
final _customContextController = TextEditingController();
|
||||
final _writingStyleController = TextEditingController();
|
||||
|
||||
List<UserAIModelConfigModel> _aiConfigs = [];
|
||||
bool _isLoadingConfigs = true;
|
||||
bool _isSubmitting = false;
|
||||
|
||||
String? _selectedSummaryConfigId;
|
||||
String? _selectedContentConfigId;
|
||||
String _startContextMode = 'AUTO'; // 默认为自动模式
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadAiConfigs();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_numberOfChaptersController.dispose();
|
||||
_contextChapterCountController.dispose();
|
||||
_customContextController.dispose();
|
||||
_writingStyleController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadAiConfigs() async {
|
||||
setState(() {
|
||||
_isLoadingConfigs = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final configs = await widget.userAiModelConfigRepository.listConfigurations(
|
||||
userId: widget.userId,
|
||||
validatedOnly: true,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_aiConfigs = configs;
|
||||
_isLoadingConfigs = false;
|
||||
|
||||
// 如果有配置,预选第一个
|
||||
if (configs.isNotEmpty) {
|
||||
_selectedSummaryConfigId = configs.first.id;
|
||||
_selectedContentConfigId = configs.first.id;
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
AppLogger.e('ContinueWritingForm', '加载AI配置失败', e);
|
||||
setState(() {
|
||||
_isLoadingConfigs = false;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
TopToast.error(context, '加载AI配置失败: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _submitForm() async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isSubmitting = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final parameters = <String, dynamic>{
|
||||
'novelId': widget.novelId,
|
||||
'numberOfChapters': int.parse(_numberOfChaptersController.text),
|
||||
'aiConfigIdSummary': _selectedSummaryConfigId,
|
||||
'aiConfigIdContent': _selectedContentConfigId,
|
||||
'startContextMode': _startContextMode,
|
||||
};
|
||||
|
||||
// 根据上下文模式添加对应参数
|
||||
if (_startContextMode == 'LAST_N_CHAPTERS') {
|
||||
parameters['contextChapterCount'] = int.parse(_contextChapterCountController.text);
|
||||
} else if (_startContextMode == 'CUSTOM') {
|
||||
parameters['customContext'] = _customContextController.text;
|
||||
}
|
||||
|
||||
// 添加写作风格参数(如果有)
|
||||
if (_writingStyleController.text.isNotEmpty) {
|
||||
parameters['writingStyle'] = _writingStyleController.text;
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
widget.onSubmit(parameters);
|
||||
} catch (e) {
|
||||
AppLogger.e('ContinueWritingForm', '提交表单失败', e);
|
||||
if (mounted) {
|
||||
TopToast.error(context, '提交失败: ${e.toString()}');
|
||||
}
|
||||
} finally {
|
||||
setState(() {
|
||||
_isSubmitting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
elevation: 3,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'自动续写设置',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: widget.onCancel,
|
||||
splashRadius: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 表单
|
||||
Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 续写章节数
|
||||
TextFormField(
|
||||
controller: _numberOfChaptersController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '续写章节数',
|
||||
helperText: '设置要自动续写的章节数量',
|
||||
prefixIcon: Icon(Icons.book_outlined),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入续写章节数';
|
||||
}
|
||||
final number = int.tryParse(value);
|
||||
if (number == null || number <= 0) {
|
||||
return '请输入有效的章节数';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 摘要模型选择
|
||||
_isLoadingConfigs
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: DropdownButtonFormField<String>(
|
||||
decoration: const InputDecoration(
|
||||
labelText: '摘要生成模型',
|
||||
helperText: '选择用于生成章节摘要的AI模型',
|
||||
prefixIcon: Icon(Icons.summarize_outlined),
|
||||
),
|
||||
value: _selectedSummaryConfigId,
|
||||
items: _aiConfigs
|
||||
.map((config) => DropdownMenuItem<String>(
|
||||
value: config.id,
|
||||
child: Text(config.alias ?? config.modelName),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedSummaryConfigId = value;
|
||||
});
|
||||
},
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请选择摘要生成模型';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 内容模型选择
|
||||
_isLoadingConfigs
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: DropdownButtonFormField<String>(
|
||||
decoration: const InputDecoration(
|
||||
labelText: '内容生成模型',
|
||||
helperText: '选择用于生成章节内容的AI模型',
|
||||
prefixIcon: Icon(Icons.text_fields),
|
||||
),
|
||||
value: _selectedContentConfigId,
|
||||
items: _aiConfigs
|
||||
.map((config) => DropdownMenuItem<String>(
|
||||
value: config.id,
|
||||
child: Text(config.alias ?? config.modelName),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedContentConfigId = value;
|
||||
});
|
||||
},
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请选择内容生成模型';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 上下文模式选择
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'上下文模式',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 上下文模式单选组
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
_buildContextModeRadio('自动', 'AUTO'),
|
||||
_buildContextModeRadio('最近N章', 'LAST_N_CHAPTERS'),
|
||||
_buildContextModeRadio('自定义', 'CUSTOM'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'选择AI续写时使用的上下文模式',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 上下文章节数(仅当模式为LAST_N_CHAPTERS时显示)
|
||||
if (_startContextMode == 'LAST_N_CHAPTERS')
|
||||
TextFormField(
|
||||
controller: _contextChapterCountController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '上下文章节数',
|
||||
helperText: '设置AI生成时参考的最近章节数量',
|
||||
prefixIcon: Icon(Icons.format_list_numbered),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入上下文章节数';
|
||||
}
|
||||
final number = int.tryParse(value);
|
||||
if (number == null || number <= 0) {
|
||||
return '请输入有效的章节数';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
|
||||
// 自定义上下文(仅当模式为CUSTOM时显示)
|
||||
if (_startContextMode == 'CUSTOM')
|
||||
TextFormField(
|
||||
controller: _customContextController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '自定义上下文',
|
||||
helperText: '输入AI生成时参考的自定义上下文内容',
|
||||
prefixIcon: Icon(Icons.description_outlined),
|
||||
),
|
||||
maxLines: 3,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入自定义上下文';
|
||||
}
|
||||
if (value.length < 10) {
|
||||
return '上下文内容过短,请提供更详细的信息';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 写作风格(可选)
|
||||
TextFormField(
|
||||
controller: _writingStyleController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '写作风格提示 (可选)',
|
||||
helperText: '描述期望的写作风格,例如:悬疑、浪漫、幽默等',
|
||||
prefixIcon: Icon(Icons.style),
|
||||
),
|
||||
maxLines: 1,
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 提交按钮
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: _isSubmitting ? null : widget.onCancel,
|
||||
child: const Text('取消'),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _isSubmitting ? null : _submitForm,
|
||||
child: _isSubmitting
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: theme.colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text('提交中...'),
|
||||
],
|
||||
)
|
||||
: const Text('开始任务'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 构建上下文模式单选按钮
|
||||
Widget _buildContextModeRadio(String label, String value) {
|
||||
return FilterChip(
|
||||
label: Text(label),
|
||||
selected: _startContextMode == value,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setState(() {
|
||||
_startContextMode = value;
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
354
AINoval/lib/screens/editor/widgets/custom_dropdown.dart
Normal file
354
AINoval/lib/screens/editor/widgets/custom_dropdown.dart
Normal file
@@ -0,0 +1,354 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// 通用下拉菜单组件,用于替换项目中的三点水下拉菜单
|
||||
class CustomDropdown extends StatefulWidget {
|
||||
/// 触发下拉菜单的小部件
|
||||
final Widget trigger;
|
||||
|
||||
/// 下拉菜单内容
|
||||
final Widget child;
|
||||
|
||||
/// 下拉菜单宽度
|
||||
final double width;
|
||||
|
||||
/// 下拉菜单对齐方式 ('left' 或 'right')
|
||||
final String align;
|
||||
|
||||
/// 是否为暗色主题
|
||||
final bool isDarkTheme;
|
||||
|
||||
/// 菜单出现/消失的动画时长
|
||||
final Duration animationDuration;
|
||||
|
||||
const CustomDropdown({
|
||||
Key? key,
|
||||
required this.trigger,
|
||||
required this.child,
|
||||
this.width = 240,
|
||||
this.align = 'left',
|
||||
this.isDarkTheme = false,
|
||||
this.animationDuration = const Duration(milliseconds: 150),
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<CustomDropdown> createState() => _CustomDropdownState();
|
||||
}
|
||||
|
||||
class _CustomDropdownState extends State<CustomDropdown> {
|
||||
bool isOpen = false;
|
||||
final LayerLink _layerLink = LayerLink();
|
||||
OverlayEntry? _overlayEntry;
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode.addListener(_onFocusChange);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_removeOverlay();
|
||||
_focusNode.removeListener(_onFocusChange);
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onFocusChange() {
|
||||
if (!_focusNode.hasFocus && isOpen) {
|
||||
_closeDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleDropdown() {
|
||||
if (isOpen) {
|
||||
_closeDropdown();
|
||||
} else {
|
||||
_openDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
void _closeDropdown() {
|
||||
_removeOverlay();
|
||||
setState(() {
|
||||
isOpen = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _openDropdown() {
|
||||
_showOverlay();
|
||||
setState(() {
|
||||
isOpen = true;
|
||||
});
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
|
||||
void _removeOverlay() {
|
||||
_overlayEntry?.remove();
|
||||
_overlayEntry = null;
|
||||
}
|
||||
|
||||
void _showOverlay() {
|
||||
_overlayEntry = _createOverlayEntry();
|
||||
Overlay.of(context).insert(_overlayEntry!);
|
||||
}
|
||||
|
||||
OverlayEntry _createOverlayEntry() {
|
||||
RenderBox renderBox = context.findRenderObject() as RenderBox;
|
||||
var size = renderBox.size;
|
||||
var offset = renderBox.localToGlobal(Offset.zero);
|
||||
|
||||
return OverlayEntry(
|
||||
builder: (context) => GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: _closeDropdown,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: widget.align == 'left' ? offset.dx : null,
|
||||
right: widget.align == 'right' ? (MediaQuery.of(context).size.width - offset.dx - size.width) : null,
|
||||
top: offset.dy + size.height + 4,
|
||||
width: widget.width,
|
||||
child: CompositedTransformFollower(
|
||||
link: _layerLink,
|
||||
followerAnchor: widget.align == 'left' ? Alignment.topLeft : Alignment.topRight,
|
||||
targetAnchor: widget.align == 'left' ? Alignment.bottomLeft : Alignment.bottomRight,
|
||||
offset: const Offset(0, 4),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
duration: widget.animationDuration,
|
||||
curve: Curves.easeOutCubic,
|
||||
tween: Tween<double>(begin: 0.0, end: 1.0),
|
||||
builder: (context, value, child) => Transform.scale(
|
||||
scale: 0.95 + (0.05 * value),
|
||||
alignment: widget.align == 'left'
|
||||
? Alignment.topLeft
|
||||
: Alignment.topRight,
|
||||
child: Opacity(
|
||||
opacity: value,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
elevation: 8,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: widget.isDarkTheme ? Colors.grey[850] : Colors.white,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: _wrapChildWithCloseCallback(widget.child),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _wrapChildWithCloseCallback(Widget child) {
|
||||
if (child is Column) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: child.children.map((item) {
|
||||
if (item is DropdownItem) {
|
||||
return DropdownItem(
|
||||
icon: item.icon,
|
||||
label: item.label,
|
||||
onTap: item.onTap,
|
||||
hasSubmenu: item.hasSubmenu,
|
||||
disabled: item.disabled,
|
||||
isDarkTheme: item.isDarkTheme,
|
||||
isDangerous: item.isDangerous,
|
||||
onClose: _closeDropdown,
|
||||
);
|
||||
}
|
||||
if (item is DropdownSection) {
|
||||
return DropdownSection(
|
||||
title: item.title,
|
||||
children: item.children.map((sectionItem) {
|
||||
if (sectionItem is DropdownItem) {
|
||||
return DropdownItem(
|
||||
icon: sectionItem.icon,
|
||||
label: sectionItem.label,
|
||||
onTap: sectionItem.onTap,
|
||||
hasSubmenu: sectionItem.hasSubmenu,
|
||||
disabled: sectionItem.disabled,
|
||||
isDarkTheme: sectionItem.isDarkTheme,
|
||||
isDangerous: sectionItem.isDangerous,
|
||||
onClose: _closeDropdown,
|
||||
);
|
||||
}
|
||||
return sectionItem;
|
||||
}).toList(),
|
||||
isDarkTheme: item.isDarkTheme,
|
||||
dividerAtBottom: item.dividerAtBottom,
|
||||
);
|
||||
}
|
||||
return item;
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
return child;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return KeyboardListener(
|
||||
focusNode: _focusNode,
|
||||
onKeyEvent: (keyEvent) {
|
||||
if (keyEvent is KeyDownEvent && keyEvent.logicalKey == LogicalKeyboardKey.escape) {
|
||||
_closeDropdown();
|
||||
}
|
||||
},
|
||||
child: CompositedTransformTarget(
|
||||
link: _layerLink,
|
||||
child: GestureDetector(
|
||||
onTap: _toggleDropdown,
|
||||
child: widget.trigger,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 下拉菜单项
|
||||
class DropdownItem extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Future<void> Function()? onTap;
|
||||
final bool hasSubmenu;
|
||||
final bool disabled;
|
||||
final bool isDarkTheme;
|
||||
final bool isDangerous;
|
||||
final VoidCallback? onClose;
|
||||
|
||||
const DropdownItem({
|
||||
Key? key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
this.onTap,
|
||||
this.hasSubmenu = false,
|
||||
this.disabled = false,
|
||||
this.isDarkTheme = false,
|
||||
this.isDangerous = false,
|
||||
this.onClose,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: disabled
|
||||
? null
|
||||
: () async {
|
||||
if (onTap != null) {
|
||||
await onTap!();
|
||||
}
|
||||
onClose?.call();
|
||||
},
|
||||
child: Opacity(
|
||||
opacity: disabled ? 0.5 : 1.0,
|
||||
child: Container(
|
||||
height: 40,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: isDangerous
|
||||
? Colors.red.shade700
|
||||
: (isDarkTheme ? Colors.white70 : Colors.black87)
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: isDangerous
|
||||
? Colors.red.shade700
|
||||
: (isDarkTheme ? Colors.white : Colors.black87),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hasSubmenu)
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
size: 16,
|
||||
color: isDarkTheme ? Colors.white38 : Colors.black45,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 下拉菜单分区
|
||||
class DropdownSection extends StatelessWidget {
|
||||
final String? title;
|
||||
final List<Widget> children;
|
||||
final bool isDarkTheme;
|
||||
final bool dividerAtBottom;
|
||||
|
||||
const DropdownSection({
|
||||
Key? key,
|
||||
this.title,
|
||||
required this.children,
|
||||
this.isDarkTheme = false,
|
||||
this.dividerAtBottom = true,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (title != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: Text(
|
||||
title!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isDarkTheme ? Colors.white54 : Colors.black54,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
...children,
|
||||
if (dividerAtBottom)
|
||||
Divider(
|
||||
height: 8,
|
||||
thickness: 1,
|
||||
color: isDarkTheme ? Colors.white12 : Colors.black12,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 下拉菜单分隔线
|
||||
class DropdownDivider extends StatelessWidget {
|
||||
final bool isDarkTheme;
|
||||
|
||||
const DropdownDivider({
|
||||
Key? key,
|
||||
this.isDarkTheme = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Divider(
|
||||
height: 8,
|
||||
thickness: 1,
|
||||
color: isDarkTheme ? Colors.white12 : Colors.black12,
|
||||
);
|
||||
}
|
||||
}
|
||||
126
AINoval/lib/screens/editor/widgets/dialogs.dart
Normal file
126
AINoval/lib/screens/editor/widgets/dialogs.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 对话框工具类
|
||||
///
|
||||
/// 用于创建和显示各种常用对话框
|
||||
class DialogUtils {
|
||||
/// 显示确认对话框
|
||||
static Future<bool> showConfirmDialog({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required String message,
|
||||
String confirmText = '确认',
|
||||
String cancelText = '取消',
|
||||
bool isDangerous = false,
|
||||
}) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(cancelText),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(confirmText),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: isDangerous ? Colors.red : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
/// 显示危险操作确认对话框
|
||||
static Future<bool> showDangerousConfirmDialog({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required String message,
|
||||
String confirmText = '删除',
|
||||
String cancelText = '取消',
|
||||
}) async {
|
||||
return showConfirmDialog(
|
||||
context: context,
|
||||
title: title,
|
||||
message: message,
|
||||
confirmText: confirmText,
|
||||
cancelText: cancelText,
|
||||
isDangerous: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示删除确认对话框
|
||||
static Future<bool> showDeleteConfirmDialog({
|
||||
required BuildContext context,
|
||||
required String itemType,
|
||||
String? itemName,
|
||||
}) async {
|
||||
final title = '删除$itemType';
|
||||
final message = itemName != null
|
||||
? '确定要删除"$itemName"吗?此操作不可撤销。'
|
||||
: '确定要删除这个$itemType吗?此操作不可撤销。';
|
||||
|
||||
return showDangerousConfirmDialog(
|
||||
context: context,
|
||||
title: title,
|
||||
message: message,
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示输入对话框
|
||||
static Future<String?> showInputDialog({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
String? initialValue,
|
||||
String hintText = '',
|
||||
String confirmText = '确认',
|
||||
String cancelText = '取消',
|
||||
}) async {
|
||||
final controller = TextEditingController(text: initialValue);
|
||||
|
||||
final result = await showDialog<String?>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
),
|
||||
autofocus: true,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, null),
|
||||
child: Text(cancelText),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, controller.text),
|
||||
child: Text(confirmText),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 显示重命名对话框
|
||||
static Future<String?> showRenameDialog({
|
||||
required BuildContext context,
|
||||
required String itemType,
|
||||
required String currentName,
|
||||
}) async {
|
||||
return showInputDialog(
|
||||
context: context,
|
||||
title: '重命名$itemType',
|
||||
initialValue: currentName,
|
||||
hintText: '输入新的名称',
|
||||
);
|
||||
}
|
||||
}
|
||||
469
AINoval/lib/screens/editor/widgets/dropdown_manager.dart
Normal file
469
AINoval/lib/screens/editor/widgets/dropdown_manager.dart
Normal file
@@ -0,0 +1,469 @@
|
||||
import 'package:ainoval/blocs/editor/editor_bloc.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/custom_dropdown.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/menu_definitions.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/preset_menu_definitions.dart';
|
||||
import 'package:ainoval/services/ai_preset_service.dart';
|
||||
import 'package:ainoval/models/preset_models.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 下拉菜单管理器
|
||||
///
|
||||
/// 用于统一构建和管理所有下拉菜单,包括Act、Chapter、Scene和Model的菜单
|
||||
class DropdownManager {
|
||||
/// 菜单构建上下文
|
||||
final BuildContext context;
|
||||
|
||||
/// 编辑器状态管理(模型菜单时可为null)
|
||||
final EditorBloc? editorBloc;
|
||||
|
||||
/// 菜单显示设置
|
||||
final DropdownDisplaySettings displaySettings;
|
||||
|
||||
DropdownManager({
|
||||
required this.context,
|
||||
required this.editorBloc,
|
||||
this.displaySettings = const DropdownDisplaySettings(),
|
||||
});
|
||||
|
||||
/// 构建Act菜单
|
||||
Widget buildActMenu({
|
||||
required String actId,
|
||||
Function()? onRenamePressed,
|
||||
IconData? icon,
|
||||
String? tooltip,
|
||||
}) {
|
||||
return _buildMenu(
|
||||
menuItems: ActMenuDefinitions.getMenuItems(),
|
||||
id: actId,
|
||||
secondaryId: null,
|
||||
tertiaryId: null,
|
||||
onRenamePressed: onRenamePressed,
|
||||
icon: icon ?? Icons.more_vert,
|
||||
tooltip: tooltip ?? 'Act操作',
|
||||
width: displaySettings.actMenuWidth,
|
||||
align: displaySettings.actMenuAlign,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建Chapter菜单
|
||||
Widget buildChapterMenu({
|
||||
required String actId,
|
||||
required String chapterId,
|
||||
Function()? onRenamePressed,
|
||||
IconData? icon,
|
||||
String? tooltip,
|
||||
}) {
|
||||
// 动态统计该章节下的场景数量,用作菜单顶部信息
|
||||
int? sceneCount;
|
||||
try {
|
||||
final state = editorBloc?.state;
|
||||
if (state is EditorLoaded) {
|
||||
final novel = state.novel;
|
||||
for (final act in novel.acts) {
|
||||
if (act.id == actId) {
|
||||
for (final chapter in act.chapters) {
|
||||
if (chapter.id == chapterId) {
|
||||
sceneCount = chapter.scenes.length;
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// 构建带有“章节信息:共N个场景”的菜单项,放在最前面
|
||||
final List<dynamic> items = [];
|
||||
if (sceneCount != null) {
|
||||
items.add(MenuItemData(
|
||||
icon: Icons.info_outline,
|
||||
label: '共${sceneCount}个场景',
|
||||
onTap: null,
|
||||
disabled: true,
|
||||
));
|
||||
items.add("divider");
|
||||
}
|
||||
items.addAll(ChapterMenuDefinitions.getMenuItems());
|
||||
|
||||
return _buildMenu(
|
||||
menuItems: items,
|
||||
id: actId,
|
||||
secondaryId: chapterId,
|
||||
tertiaryId: null,
|
||||
onRenamePressed: onRenamePressed,
|
||||
icon: icon ?? Icons.more_vert,
|
||||
tooltip: tooltip ?? '章节操作',
|
||||
width: displaySettings.chapterMenuWidth,
|
||||
align: displaySettings.chapterMenuAlign,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建Scene菜单
|
||||
Widget buildSceneMenu({
|
||||
required String actId,
|
||||
required String chapterId,
|
||||
required String sceneId,
|
||||
IconData? icon,
|
||||
String? tooltip,
|
||||
}) {
|
||||
return _buildMenu(
|
||||
menuItems: SceneMenuDefinitions.getMenuItems(),
|
||||
id: actId,
|
||||
secondaryId: chapterId,
|
||||
tertiaryId: sceneId,
|
||||
icon: icon ?? Icons.more_horiz,
|
||||
tooltip: tooltip ?? '场景操作',
|
||||
width: displaySettings.sceneMenuWidth,
|
||||
align: displaySettings.sceneMenuAlign,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建Model菜单
|
||||
Widget buildModelMenu({
|
||||
required String configId,
|
||||
required bool isValidated,
|
||||
required bool isDefault,
|
||||
required Future<void> Function(String) onValidate,
|
||||
required Future<void> Function(String) onSetDefault,
|
||||
required Future<void> Function(String) onEdit,
|
||||
required Future<void> Function(String) onDelete,
|
||||
IconData? icon,
|
||||
String? tooltip,
|
||||
}) {
|
||||
final menuItems = ModelMenuDefinitions.getMenuItems(
|
||||
isValidated: isValidated,
|
||||
isDefault: isDefault,
|
||||
onValidate: onValidate,
|
||||
onSetDefault: onSetDefault,
|
||||
onEdit: onEdit,
|
||||
onDelete: onDelete,
|
||||
);
|
||||
|
||||
return _buildModelMenu(
|
||||
menuItems: menuItems,
|
||||
configId: configId,
|
||||
icon: icon ?? Icons.more_vert,
|
||||
tooltip: tooltip ?? '模型操作',
|
||||
width: displaySettings.modelMenuWidth,
|
||||
align: displaySettings.modelMenuAlign,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建预设菜单
|
||||
Widget buildPresetMenu({
|
||||
required String featureType,
|
||||
required Function() onCreatePreset,
|
||||
required Function() onManagePresets,
|
||||
required Function(AIPromptPreset preset) onPresetSelected,
|
||||
IconData? icon,
|
||||
String? tooltip,
|
||||
}) {
|
||||
return CustomDropdown(
|
||||
width: displaySettings.presetMenuWidth,
|
||||
align: displaySettings.presetMenuAlign,
|
||||
trigger: IconButton(
|
||||
icon: Icon(icon ?? Icons.bookmark_border, size: 18),
|
||||
onPressed: null, // 由CustomDropdown处理点击
|
||||
tooltip: tooltip ?? '预设管理',
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
splashRadius: 20,
|
||||
),
|
||||
child: FutureBuilder<List<dynamic>>(
|
||||
future: PresetMenuDefinitions.getDynamicMenuItems(
|
||||
featureType: featureType,
|
||||
onCreatePreset: onCreatePreset,
|
||||
onManagePresets: onManagePresets,
|
||||
onPresetSelected: onPresetSelected,
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final menuItems = snapshot.data ?? [];
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: _buildPresetMenuItemWidgets(
|
||||
menuItems,
|
||||
featureType,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 内部方法:构建通用菜单
|
||||
Widget _buildMenu({
|
||||
required List<dynamic> menuItems,
|
||||
required String id,
|
||||
String? secondaryId,
|
||||
String? tertiaryId,
|
||||
Function()? onRenamePressed,
|
||||
required IconData icon,
|
||||
required String tooltip,
|
||||
double width = 240,
|
||||
String align = 'left',
|
||||
}) {
|
||||
return CustomDropdown(
|
||||
width: width,
|
||||
align: align,
|
||||
trigger: IconButton(
|
||||
icon: Icon(icon, size: 20),
|
||||
onPressed: null, // 由CustomDropdown处理点击
|
||||
tooltip: tooltip,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
splashRadius: 20,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: _buildMenuItemWidgets(
|
||||
menuItems,
|
||||
id,
|
||||
secondaryId,
|
||||
tertiaryId,
|
||||
onRenamePressed,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 内部方法:构建模型菜单
|
||||
Widget _buildModelMenu({
|
||||
required List<dynamic> menuItems,
|
||||
required String configId,
|
||||
required IconData icon,
|
||||
required String tooltip,
|
||||
double width = 180,
|
||||
String align = 'right',
|
||||
}) {
|
||||
return CustomDropdown(
|
||||
width: width,
|
||||
align: align,
|
||||
trigger: IconButton(
|
||||
icon: Icon(icon, size: 16),
|
||||
onPressed: null, // 由CustomDropdown处理点击
|
||||
tooltip: tooltip,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
splashRadius: 20,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: _buildModelMenuItemWidgets(
|
||||
menuItems,
|
||||
configId,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建菜单项列表
|
||||
List<Widget> _buildMenuItemWidgets(
|
||||
List<dynamic> menuItems,
|
||||
String id,
|
||||
String? secondaryId,
|
||||
String? tertiaryId,
|
||||
Function()? onRenamePressed,
|
||||
) {
|
||||
final List<Widget> widgets = [];
|
||||
|
||||
for (final item in menuItems) {
|
||||
if (item is String && item == "divider") {
|
||||
widgets.add(const DropdownDivider());
|
||||
} else if (item is MenuSectionData) {
|
||||
widgets.add(
|
||||
DropdownSection(
|
||||
title: item.title,
|
||||
children: item.items.map((menuItem) {
|
||||
return _buildSingleMenuItem(
|
||||
menuItem,
|
||||
id,
|
||||
secondaryId,
|
||||
tertiaryId,
|
||||
onRenamePressed,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
} else if (item is MenuItemData) {
|
||||
widgets.add(
|
||||
_buildSingleMenuItem(
|
||||
item,
|
||||
id,
|
||||
secondaryId,
|
||||
tertiaryId,
|
||||
onRenamePressed,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return widgets;
|
||||
}
|
||||
|
||||
/// 构建模型菜单项列表
|
||||
List<Widget> _buildModelMenuItemWidgets(
|
||||
List<dynamic> menuItems,
|
||||
String configId,
|
||||
) {
|
||||
final List<Widget> widgets = [];
|
||||
|
||||
for (final item in menuItems) {
|
||||
if (item is String && item == "divider") {
|
||||
widgets.add(const DropdownDivider());
|
||||
} else if (item is ModelMenuSectionData) {
|
||||
widgets.add(
|
||||
DropdownSection(
|
||||
title: item.title,
|
||||
children: item.items.map((menuItem) {
|
||||
return _buildSingleModelMenuItem(menuItem, configId);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
} else if (item is ModelMenuItemData) {
|
||||
widgets.add(_buildSingleModelMenuItem(item, configId));
|
||||
}
|
||||
}
|
||||
|
||||
return widgets;
|
||||
}
|
||||
|
||||
/// 构建单个菜单项
|
||||
Widget _buildSingleMenuItem(
|
||||
MenuItemData item,
|
||||
String id,
|
||||
String? secondaryId,
|
||||
String? tertiaryId,
|
||||
Function()? onRenamePressed,
|
||||
) {
|
||||
// 特殊处理重命名操作,因为需要直接访问State
|
||||
Future<void> Function()? onTapHandler;
|
||||
if (item.label == '重命名Act' || item.label == '重命名章节') {
|
||||
onTapHandler = null;
|
||||
} else if (item.onTap != null) {
|
||||
onTapHandler = () async {
|
||||
await item.onTap!(context, editorBloc!, id, secondaryId, tertiaryId);
|
||||
};
|
||||
}
|
||||
|
||||
return DropdownItem(
|
||||
icon: item.icon,
|
||||
label: item.label,
|
||||
hasSubmenu: item.hasSubmenu,
|
||||
disabled: item.disabled,
|
||||
isDangerous: item.isDangerous,
|
||||
onTap: onTapHandler,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建单个模型菜单项
|
||||
Widget _buildSingleModelMenuItem(
|
||||
ModelMenuItemData item,
|
||||
String configId,
|
||||
) {
|
||||
Future<void> Function()? onTapHandler;
|
||||
if (item.onTap != null) {
|
||||
onTapHandler = () async {
|
||||
await item.onTap!(configId);
|
||||
};
|
||||
}
|
||||
|
||||
return DropdownItem(
|
||||
icon: item.icon,
|
||||
label: item.label,
|
||||
hasSubmenu: item.hasSubmenu,
|
||||
disabled: item.disabled,
|
||||
isDangerous: item.isDangerous,
|
||||
onTap: onTapHandler,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建预设菜单项列表
|
||||
List<Widget> _buildPresetMenuItemWidgets(
|
||||
List<dynamic> menuItems,
|
||||
String featureType,
|
||||
) {
|
||||
final List<Widget> widgets = [];
|
||||
final presetService = AIPresetService();
|
||||
|
||||
for (final item in menuItems) {
|
||||
if (item is String && item == "divider") {
|
||||
widgets.add(const DropdownDivider());
|
||||
} else if (item is PresetMenuSectionData) {
|
||||
widgets.add(
|
||||
DropdownSection(
|
||||
title: item.title,
|
||||
children: item.items.map((menuItem) {
|
||||
return _buildSinglePresetMenuItem(menuItem, presetService, featureType);
|
||||
}).toList(),
|
||||
dividerAtBottom: item.dividerAtBottom,
|
||||
),
|
||||
);
|
||||
} else if (item is PresetMenuItemData) {
|
||||
widgets.add(_buildSinglePresetMenuItem(item, presetService, featureType));
|
||||
}
|
||||
}
|
||||
|
||||
return widgets;
|
||||
}
|
||||
|
||||
/// 构建单个预设菜单项
|
||||
Widget _buildSinglePresetMenuItem(
|
||||
PresetMenuItemData item,
|
||||
AIPresetService presetService,
|
||||
String featureType,
|
||||
) {
|
||||
Future<void> Function()? onTapHandler;
|
||||
if (item.onTap != null) {
|
||||
onTapHandler = () async {
|
||||
await item.onTap!(context, presetService, featureType);
|
||||
};
|
||||
}
|
||||
|
||||
return DropdownItem(
|
||||
icon: item.icon,
|
||||
label: item.label,
|
||||
hasSubmenu: item.hasSubmenu,
|
||||
disabled: item.disabled,
|
||||
isDangerous: item.isDangerous,
|
||||
onTap: onTapHandler,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 下拉菜单显示设置
|
||||
class DropdownDisplaySettings {
|
||||
final double actMenuWidth;
|
||||
final double chapterMenuWidth;
|
||||
final double sceneMenuWidth;
|
||||
final double modelMenuWidth;
|
||||
final double presetMenuWidth;
|
||||
final String actMenuAlign;
|
||||
final String chapterMenuAlign;
|
||||
final String sceneMenuAlign;
|
||||
final String modelMenuAlign;
|
||||
final String presetMenuAlign;
|
||||
|
||||
const DropdownDisplaySettings({
|
||||
this.actMenuWidth = 240,
|
||||
this.chapterMenuWidth = 240,
|
||||
this.sceneMenuWidth = 240,
|
||||
this.modelMenuWidth = 180,
|
||||
this.presetMenuWidth = 280,
|
||||
this.actMenuAlign = 'left',
|
||||
this.chapterMenuAlign = 'right',
|
||||
this.sceneMenuAlign = 'right',
|
||||
this.modelMenuAlign = 'right',
|
||||
this.presetMenuAlign = 'right',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/models/novel_setting_item.dart';
|
||||
import 'package:ainoval/models/setting_group.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/novel_setting_detail.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/novel_setting_group_dialog.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/novel_setting_group_selection_dialog.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/novel_setting_relationship_dialog.dart';
|
||||
|
||||
/// 统一的浮动设定对话框管理器
|
||||
class FloatingSettingDialogs {
|
||||
|
||||
/// 显示设定详情编辑卡片
|
||||
static void showSettingDetail({
|
||||
required BuildContext context,
|
||||
String? itemId,
|
||||
required String novelId,
|
||||
String? groupId,
|
||||
bool isEditing = false,
|
||||
required Function(NovelSettingItem, String?) onSave,
|
||||
required VoidCallback onCancel,
|
||||
}) {
|
||||
// 使用浮动设定详情管理器
|
||||
FloatingNovelSettingDetail.show(
|
||||
context: context,
|
||||
itemId: itemId,
|
||||
novelId: novelId,
|
||||
groupId: groupId,
|
||||
isEditing: isEditing,
|
||||
onSave: onSave,
|
||||
onCancel: onCancel,
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示设定组管理卡片
|
||||
static void showSettingGroup({
|
||||
required BuildContext context,
|
||||
required String novelId,
|
||||
SettingGroup? group,
|
||||
required Function(SettingGroup) onSave,
|
||||
}) {
|
||||
// 使用浮动设定组管理器
|
||||
FloatingNovelSettingGroupDialog.show(
|
||||
context: context,
|
||||
novelId: novelId,
|
||||
group: group,
|
||||
onSave: onSave,
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示设定组选择卡片
|
||||
static void showSettingGroupSelection({
|
||||
required BuildContext context,
|
||||
required String novelId,
|
||||
required Function(String groupId, String groupName) onGroupSelected,
|
||||
}) {
|
||||
// 使用浮动设定组选择管理器
|
||||
FloatingNovelSettingGroupSelectionDialog.show(
|
||||
context: context,
|
||||
novelId: novelId,
|
||||
onGroupSelected: onGroupSelected,
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示设定关系创建卡片
|
||||
static void showSettingRelationship({
|
||||
required BuildContext context,
|
||||
required String novelId,
|
||||
required String sourceItemId,
|
||||
required String sourceName,
|
||||
required List<NovelSettingItem> availableTargets,
|
||||
required Function(String relationType, String targetItemId, String? description) onSave,
|
||||
}) {
|
||||
// 使用浮动设定关系管理器
|
||||
FloatingNovelSettingRelationshipDialog.show(
|
||||
context: context,
|
||||
novelId: novelId,
|
||||
sourceItemId: sourceItemId,
|
||||
sourceName: sourceName,
|
||||
availableTargets: availableTargets,
|
||||
onSave: onSave,
|
||||
);
|
||||
}
|
||||
}
|
||||
160
AINoval/lib/screens/editor/widgets/generate_scene_dialog.dart
Normal file
160
AINoval/lib/screens/editor/widgets/generate_scene_dialog.dart
Normal file
@@ -0,0 +1,160 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/models/novel_structure.dart';
|
||||
|
||||
/// 生成场景对话框结果
|
||||
class GenerateSceneDialogResult {
|
||||
final String summary;
|
||||
final String? chapterId;
|
||||
final String? styleInstructions;
|
||||
|
||||
GenerateSceneDialogResult({
|
||||
required this.summary,
|
||||
this.chapterId,
|
||||
this.styleInstructions,
|
||||
});
|
||||
}
|
||||
|
||||
/// 生成场景对话框,用于输入摘要/大纲,然后触发AI生成场景内容
|
||||
class GenerateSceneDialog extends StatefulWidget {
|
||||
const GenerateSceneDialog({
|
||||
Key? key,
|
||||
required this.novel,
|
||||
this.initialSummary = '',
|
||||
this.initialChapterId,
|
||||
}) : super(key: key);
|
||||
|
||||
/// 当前小说
|
||||
final Novel novel;
|
||||
|
||||
/// 初始摘要文本
|
||||
final String initialSummary;
|
||||
|
||||
/// 初始章节ID
|
||||
final String? initialChapterId;
|
||||
|
||||
@override
|
||||
State<GenerateSceneDialog> createState() => _GenerateSceneDialogState();
|
||||
}
|
||||
|
||||
class _GenerateSceneDialogState extends State<GenerateSceneDialog> {
|
||||
final TextEditingController _summaryController = TextEditingController();
|
||||
final TextEditingController _styleController = TextEditingController();
|
||||
String? _selectedChapterId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_summaryController.text = widget.initialSummary;
|
||||
_selectedChapterId = widget.initialChapterId;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_summaryController.dispose();
|
||||
_styleController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 准备章节列表,包含篇章>章节层级
|
||||
List<DropdownMenuItem<String>> _buildChapterItems() {
|
||||
final items = <DropdownMenuItem<String>>[];
|
||||
|
||||
// 空选项
|
||||
items.add(const DropdownMenuItem<String>(
|
||||
value: null,
|
||||
child: Text('(无指定章节)'),
|
||||
));
|
||||
|
||||
// 遍历篇章和章节
|
||||
for (final act in widget.novel.acts) {
|
||||
for (final chapter in act.chapters) {
|
||||
items.add(DropdownMenuItem<String>(
|
||||
value: chapter.id,
|
||||
child: Text('${act.title} > ${chapter.title}'),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('AI 生成场景内容'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 摘要/大纲输入
|
||||
TextField(
|
||||
controller: _summaryController,
|
||||
maxLines: 5,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '场景摘要/大纲 *',
|
||||
hintText: '请输入场景的摘要或大纲,AI将根据此内容生成详细场景',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 章节选择
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedChapterId,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '选择章节(可选)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: _buildChapterItems(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedChapterId = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 风格指令
|
||||
TextField(
|
||||
controller: _styleController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '风格指令(可选)',
|
||||
hintText: '例如:多对话,少描写,悬疑风格',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
// 取消按钮
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('取消'),
|
||||
),
|
||||
|
||||
// 生成按钮
|
||||
ElevatedButton(
|
||||
onPressed: _summaryController.text.trim().isEmpty
|
||||
? null
|
||||
: () {
|
||||
// 返回生成结果
|
||||
Navigator.of(context).pop(
|
||||
GenerateSceneDialogResult(
|
||||
summary: _summaryController.text.trim(),
|
||||
chapterId: _selectedChapterId,
|
||||
styleInstructions: _styleController.text.trim().isNotEmpty
|
||||
? _styleController.text.trim()
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('生成'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
116
AINoval/lib/screens/editor/widgets/menu_builder.dart
Normal file
116
AINoval/lib/screens/editor/widgets/menu_builder.dart
Normal file
@@ -0,0 +1,116 @@
|
||||
import 'package:ainoval/blocs/editor/editor_bloc.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/dropdown_manager.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 通用菜单构建器
|
||||
/// 用于构建Act、Chapter、Scene和Model的下拉菜单
|
||||
class MenuBuilder {
|
||||
/// 构建Act菜单
|
||||
static Widget buildActMenu({
|
||||
required BuildContext context,
|
||||
required EditorBloc editorBloc,
|
||||
required String actId,
|
||||
required Function()? onRenamePressed,
|
||||
double width = 240,
|
||||
String align = 'left',
|
||||
}) {
|
||||
final dropdownManager = DropdownManager(
|
||||
context: context,
|
||||
editorBloc: editorBloc,
|
||||
displaySettings: DropdownDisplaySettings(
|
||||
actMenuWidth: width,
|
||||
actMenuAlign: align,
|
||||
),
|
||||
);
|
||||
|
||||
return dropdownManager.buildActMenu(
|
||||
actId: actId,
|
||||
onRenamePressed: onRenamePressed,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建Chapter菜单
|
||||
static Widget buildChapterMenu({
|
||||
required BuildContext context,
|
||||
required EditorBloc editorBloc,
|
||||
required String actId,
|
||||
required String chapterId,
|
||||
required Function()? onRenamePressed,
|
||||
double width = 240,
|
||||
String align = 'right',
|
||||
}) {
|
||||
final dropdownManager = DropdownManager(
|
||||
context: context,
|
||||
editorBloc: editorBloc,
|
||||
displaySettings: DropdownDisplaySettings(
|
||||
chapterMenuWidth: width,
|
||||
chapterMenuAlign: align,
|
||||
),
|
||||
);
|
||||
|
||||
return dropdownManager.buildChapterMenu(
|
||||
actId: actId,
|
||||
chapterId: chapterId,
|
||||
onRenamePressed: onRenamePressed,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建Scene菜单
|
||||
static Widget buildSceneMenu({
|
||||
required BuildContext context,
|
||||
required EditorBloc editorBloc,
|
||||
required String actId,
|
||||
required String chapterId,
|
||||
required String sceneId,
|
||||
double width = 240,
|
||||
String align = 'right',
|
||||
}) {
|
||||
final dropdownManager = DropdownManager(
|
||||
context: context,
|
||||
editorBloc: editorBloc,
|
||||
displaySettings: DropdownDisplaySettings(
|
||||
sceneMenuWidth: width,
|
||||
sceneMenuAlign: align,
|
||||
),
|
||||
);
|
||||
|
||||
return dropdownManager.buildSceneMenu(
|
||||
actId: actId,
|
||||
chapterId: chapterId,
|
||||
sceneId: sceneId,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建Model菜单
|
||||
static Widget buildModelMenu({
|
||||
required BuildContext context,
|
||||
required String configId,
|
||||
required bool isValidated,
|
||||
required bool isDefault,
|
||||
required Future<void> Function(String) onValidate,
|
||||
required Future<void> Function(String) onSetDefault,
|
||||
required Future<void> Function(String) onEdit,
|
||||
required Future<void> Function(String) onDelete,
|
||||
double width = 180,
|
||||
String align = 'right',
|
||||
}) {
|
||||
final dropdownManager = DropdownManager(
|
||||
context: context,
|
||||
editorBloc: null, // 模型菜单不需要EditorBloc
|
||||
displaySettings: DropdownDisplaySettings(
|
||||
modelMenuWidth: width,
|
||||
modelMenuAlign: align,
|
||||
),
|
||||
);
|
||||
|
||||
return dropdownManager.buildModelMenu(
|
||||
configId: configId,
|
||||
isValidated: isValidated,
|
||||
isDefault: isDefault,
|
||||
onValidate: onValidate,
|
||||
onSetDefault: onSetDefault,
|
||||
onEdit: onEdit,
|
||||
onDelete: onDelete,
|
||||
);
|
||||
}
|
||||
}
|
||||
356
AINoval/lib/screens/editor/widgets/menu_definitions.dart
Normal file
356
AINoval/lib/screens/editor/widgets/menu_definitions.dart
Normal file
@@ -0,0 +1,356 @@
|
||||
import 'package:ainoval/blocs/editor/editor_bloc.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/dialogs.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:ainoval/screens/editor/controllers/editor_screen_controller.dart';
|
||||
|
||||
/// 通用菜单项数据模型
|
||||
class MenuItemData {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Future<void> Function(BuildContext, EditorBloc, String, String?, String?)? onTap;
|
||||
final bool hasSubmenu;
|
||||
final bool disabled;
|
||||
final bool isDangerous;
|
||||
|
||||
const MenuItemData({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
this.onTap,
|
||||
this.hasSubmenu = false,
|
||||
this.disabled = false,
|
||||
this.isDangerous = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// 模型菜单项数据模型(扩展用于模型操作)
|
||||
class ModelMenuItemData {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Future<void> Function(String configId)? onTap;
|
||||
final bool hasSubmenu;
|
||||
final bool disabled;
|
||||
final bool isDangerous;
|
||||
|
||||
const ModelMenuItemData({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
this.onTap,
|
||||
this.hasSubmenu = false,
|
||||
this.disabled = false,
|
||||
this.isDangerous = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// 菜单分区数据模型
|
||||
class MenuSectionData {
|
||||
final String title;
|
||||
final List<MenuItemData> items;
|
||||
|
||||
const MenuSectionData({
|
||||
required this.title,
|
||||
required this.items,
|
||||
});
|
||||
}
|
||||
|
||||
/// 模型菜单分区数据模型
|
||||
class ModelMenuSectionData {
|
||||
final String title;
|
||||
final List<ModelMenuItemData> items;
|
||||
|
||||
const ModelMenuSectionData({
|
||||
required this.title,
|
||||
required this.items,
|
||||
});
|
||||
}
|
||||
|
||||
/// Model菜单定义
|
||||
class ModelMenuDefinitions {
|
||||
static List<dynamic> getMenuItems({
|
||||
required bool isValidated,
|
||||
required bool isDefault,
|
||||
required Future<void> Function(String) onValidate,
|
||||
required Future<void> Function(String) onSetDefault,
|
||||
required Future<void> Function(String) onEdit,
|
||||
required Future<void> Function(String) onDelete,
|
||||
}) {
|
||||
return [
|
||||
// 验证操作
|
||||
ModelMenuItemData(
|
||||
icon: isValidated ? Icons.verified : Icons.wifi_protected_setup,
|
||||
label: isValidated ? '重新验证' : '验证连接',
|
||||
onTap: onValidate,
|
||||
),
|
||||
|
||||
// 设为默认(如果不是默认模型)
|
||||
if (!isDefault)
|
||||
ModelMenuItemData(
|
||||
icon: Icons.star,
|
||||
label: '设为默认',
|
||||
onTap: onSetDefault,
|
||||
),
|
||||
|
||||
// 编辑操作
|
||||
ModelMenuItemData(
|
||||
icon: Icons.edit,
|
||||
label: '编辑',
|
||||
onTap: onEdit,
|
||||
),
|
||||
|
||||
// 分隔线
|
||||
"divider",
|
||||
|
||||
// 危险操作
|
||||
ModelMenuItemData(
|
||||
icon: Icons.delete_outline,
|
||||
label: '删除',
|
||||
isDangerous: true,
|
||||
onTap: onDelete,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// Act菜单定义
|
||||
class ActMenuDefinitions {
|
||||
static List<dynamic> getMenuItems() {
|
||||
return [
|
||||
// 基本操作
|
||||
MenuItemData(
|
||||
icon: Icons.add,
|
||||
label: '添加新章节',
|
||||
onTap: (context, editorBloc, actId, _, __) async {
|
||||
editorBloc.add(AddNewChapter(
|
||||
novelId: editorBloc.novelId,
|
||||
actId: actId,
|
||||
title: '新章节 ${DateTime.now().millisecondsSinceEpoch % 100}',
|
||||
));
|
||||
},
|
||||
),
|
||||
MenuItemData(
|
||||
icon: Icons.edit,
|
||||
label: '重命名Act',
|
||||
onTap: null,
|
||||
),
|
||||
|
||||
// 导出选项
|
||||
MenuSectionData(
|
||||
title: '导出选项',
|
||||
items: [
|
||||
MenuItemData(
|
||||
icon: Icons.file_download,
|
||||
label: '导出为PDF',
|
||||
onTap: (context, editorBloc, actId, _, __) async {
|
||||
// 实现导出为PDF功能
|
||||
},
|
||||
),
|
||||
MenuItemData(
|
||||
icon: Icons.file_download,
|
||||
label: '导出为Word',
|
||||
onTap: (context, editorBloc, actId, _, __) async {
|
||||
// 实现导出为Word功能
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 危险操作
|
||||
MenuItemData(
|
||||
icon: Icons.delete_outline,
|
||||
label: '删除Act',
|
||||
isDangerous: true,
|
||||
onTap: (context, editorBloc, actId, _, __) async {
|
||||
final confirmed = await _confirmAndDelete(
|
||||
context,
|
||||
'删除Act',
|
||||
'确定要删除这个Act吗?此操作不可撤销。',
|
||||
);
|
||||
if (confirmed) {
|
||||
editorBloc.add(DeleteAct(
|
||||
novelId: editorBloc.novelId,
|
||||
actId: actId,
|
||||
));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('正在删除卷...'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// Chapter菜单定义
|
||||
class ChapterMenuDefinitions {
|
||||
static List<dynamic> getMenuItems() {
|
||||
return [
|
||||
// 基本操作
|
||||
MenuItemData(
|
||||
icon: Icons.add,
|
||||
label: '添加新场景',
|
||||
onTap: (context, editorBloc, actId, chapterId, _) async {
|
||||
_addNewScene(context, editorBloc, actId, chapterId!);
|
||||
},
|
||||
),
|
||||
MenuItemData(
|
||||
icon: Icons.edit,
|
||||
label: '重命名章节',
|
||||
onTap: null,
|
||||
),
|
||||
|
||||
// 分隔线
|
||||
"divider",
|
||||
|
||||
// 额外功能
|
||||
MenuItemData(
|
||||
icon: Icons.tag,
|
||||
label: '禁用编号',
|
||||
onTap: (context, editorBloc, actId, chapterId, _) async {
|
||||
// 实现禁用编号功能
|
||||
},
|
||||
),
|
||||
MenuItemData(
|
||||
icon: Icons.content_copy,
|
||||
label: '复制所有场景内容',
|
||||
onTap: (context, editorBloc, actId, chapterId, _) async {
|
||||
// 实现复制场景内容功能
|
||||
},
|
||||
),
|
||||
|
||||
// 分隔线
|
||||
"divider",
|
||||
|
||||
// 危险操作
|
||||
MenuItemData(
|
||||
icon: Icons.delete_outline,
|
||||
label: '删除章节',
|
||||
isDangerous: true,
|
||||
onTap: (context, editorBloc, actId, chapterId, _) async {
|
||||
final confirmed = await _confirmAndDelete(
|
||||
context,
|
||||
'删除章节',
|
||||
'确定要删除这个章节吗?此操作不可撤销,章节内的所有场景都将被删除。',
|
||||
);
|
||||
if (confirmed) {
|
||||
// 实现删除章节功能
|
||||
editorBloc.add(DeleteChapter(
|
||||
novelId: editorBloc.novelId,
|
||||
actId: actId,
|
||||
chapterId: chapterId!,
|
||||
));
|
||||
|
||||
// 显示操作反馈
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('正在删除章节...'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// 添加新场景
|
||||
static void _addNewScene(BuildContext context, EditorBloc editorBloc, String actId, String chapterId) {
|
||||
final newSceneId = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
AppLogger.i('Chapter', '添加新场景:actId=$actId, chapterId=$chapterId, sceneId=$newSceneId');
|
||||
|
||||
editorBloc.add(AddNewScene(
|
||||
novelId: editorBloc.novelId,
|
||||
actId: actId,
|
||||
chapterId: chapterId,
|
||||
sceneId: newSceneId,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Scene菜单定义
|
||||
class SceneMenuDefinitions {
|
||||
static List<dynamic> getMenuItems() {
|
||||
return [
|
||||
MenuItemData(
|
||||
icon: Icons.copy_outlined,
|
||||
label: '复制场景',
|
||||
onTap: (context, editorBloc, actId, chapterId, sceneId) async {
|
||||
// 实现复制场景功能
|
||||
// editorBloc.add(DuplicateScene(
|
||||
// novelId: editorBloc.novelId,
|
||||
// actId: actId,
|
||||
// chapterId: chapterId!,
|
||||
// sceneId: sceneId!,
|
||||
// ));
|
||||
},
|
||||
),
|
||||
MenuItemData(
|
||||
icon: Icons.splitscreen_outlined,
|
||||
label: '拆分场景',
|
||||
onTap: (context, editorBloc, actId, chapterId, sceneId) async {
|
||||
// 实现拆分场景功能
|
||||
},
|
||||
),
|
||||
|
||||
MenuSectionData(
|
||||
title: 'AI功能',
|
||||
items: [
|
||||
MenuItemData(
|
||||
icon: Icons.auto_awesome,
|
||||
label: '生成摘要',
|
||||
onTap: (context, editorBloc, actId, chapterId, sceneId) async {
|
||||
editorBloc.add(GenerateSceneSummaryRequested(
|
||||
sceneId: sceneId!,
|
||||
));
|
||||
},
|
||||
),
|
||||
MenuItemData(
|
||||
icon: Icons.psychology,
|
||||
label: '改进内容',
|
||||
onTap: (context, editorBloc, actId, chapterId, sceneId) async {
|
||||
// 实现AI改进内容功能
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 分隔线
|
||||
"divider",
|
||||
|
||||
// 危险操作
|
||||
MenuItemData(
|
||||
icon: Icons.delete_outline,
|
||||
label: '删除场景',
|
||||
isDangerous: true,
|
||||
onTap: (context, editorBloc, actId, chapterId, sceneId) async {
|
||||
final confirmed = await _confirmAndDelete(
|
||||
context,
|
||||
'删除场景',
|
||||
'确定要删除这个场景吗?此操作不可撤销。',
|
||||
);
|
||||
if (confirmed) {
|
||||
editorBloc.add(DeleteScene(
|
||||
novelId: editorBloc.novelId,
|
||||
actId: actId,
|
||||
chapterId: chapterId!,
|
||||
sceneId: sceneId!,
|
||||
));
|
||||
}
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// 通用确认删除对话框
|
||||
Future<bool> _confirmAndDelete(BuildContext context, String title, String message) async {
|
||||
final confirmed = await DialogUtils.showDangerousConfirmDialog(
|
||||
context: context,
|
||||
title: title,
|
||||
message: message,
|
||||
);
|
||||
return confirmed;
|
||||
}
|
||||
2595
AINoval/lib/screens/editor/widgets/novel_setting_detail.dart
Normal file
2595
AINoval/lib/screens/editor/widgets/novel_setting_detail.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,397 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:ainoval/models/setting_group.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:ainoval/screens/editor/managers/editor_layout_manager.dart';
|
||||
import 'package:ainoval/widgets/common/floating_card.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:ainoval/widgets/common/top_toast.dart';
|
||||
|
||||
|
||||
/// 浮动设定组管理器
|
||||
class FloatingNovelSettingGroupDialog {
|
||||
static bool _isShowing = false;
|
||||
|
||||
/// 显示浮动设定组卡片
|
||||
static void show({
|
||||
required BuildContext context,
|
||||
required String novelId,
|
||||
SettingGroup? group, // 若为null则表示创建新组
|
||||
required Function(SettingGroup) onSave, // 保存回调
|
||||
}) {
|
||||
if (_isShowing) {
|
||||
hide();
|
||||
}
|
||||
|
||||
// 获取布局信息
|
||||
final layoutManager = Provider.of<EditorLayoutManager>(context, listen: false);
|
||||
final sidebarWidth = layoutManager.isEditorSidebarVisible ? layoutManager.editorSidebarWidth : 0.0;
|
||||
|
||||
AppLogger.d('FloatingNovelSettingGroupDialog', '显示浮动卡片,侧边栏宽度: $sidebarWidth');
|
||||
|
||||
// 计算卡片大小
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final cardWidth = (screenSize.width * 0.25).clamp(400.0, 600.0);
|
||||
final cardHeight = (screenSize.height * 0.5).clamp(350.0, 500.0);
|
||||
|
||||
FloatingCard.show(
|
||||
context: context,
|
||||
position: FloatingCardPosition(
|
||||
left: sidebarWidth + 16.0,
|
||||
top: 80.0,
|
||||
),
|
||||
config: FloatingCardConfig(
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
showCloseButton: false,
|
||||
enableBackgroundTap: false,
|
||||
animationDuration: const Duration(milliseconds: 300),
|
||||
animationCurve: Curves.easeOutCubic,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
child: _NovelSettingGroupDialogContent(
|
||||
novelId: novelId,
|
||||
group: group,
|
||||
onSave: (settingGroup) {
|
||||
onSave(settingGroup);
|
||||
hide();
|
||||
},
|
||||
onCancel: hide,
|
||||
),
|
||||
onClose: hide,
|
||||
);
|
||||
|
||||
_isShowing = true;
|
||||
}
|
||||
|
||||
/// 隐藏浮动卡片
|
||||
static void hide() {
|
||||
if (_isShowing) {
|
||||
FloatingCard.hide();
|
||||
_isShowing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否正在显示
|
||||
static bool get isShowing => _isShowing;
|
||||
}
|
||||
|
||||
/// 小说设定组对话框内容
|
||||
///
|
||||
/// 用于创建或编辑设定组
|
||||
class _NovelSettingGroupDialogContent extends StatefulWidget {
|
||||
final String novelId;
|
||||
final SettingGroup? group; // 若为null则表示创建新组
|
||||
final Function(SettingGroup) onSave; // 保存回调
|
||||
final VoidCallback onCancel; // 取消回调
|
||||
|
||||
const _NovelSettingGroupDialogContent({
|
||||
Key? key,
|
||||
required this.novelId,
|
||||
this.group,
|
||||
required this.onSave,
|
||||
required this.onCancel,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<_NovelSettingGroupDialogContent> createState() => _NovelSettingGroupDialogContentState();
|
||||
}
|
||||
|
||||
class _NovelSettingGroupDialogContentState extends State<_NovelSettingGroupDialogContent> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// 表单控制器
|
||||
final _nameController = TextEditingController();
|
||||
final _descriptionController = TextEditingController();
|
||||
|
||||
// 激活状态
|
||||
bool _isActiveContext = false;
|
||||
|
||||
// 保存状态
|
||||
bool _isSaving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// 若为编辑模式,填充表单
|
||||
if (widget.group != null) {
|
||||
_nameController.text = widget.group!.name;
|
||||
if (widget.group!.description != null) {
|
||||
_descriptionController.text = widget.group!.description!;
|
||||
}
|
||||
if (widget.group!.isActiveContext != null) {
|
||||
_isActiveContext = widget.group!.isActiveContext!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_descriptionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// 保存设定组
|
||||
Future<void> _saveSettingGroup() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() {
|
||||
_isSaving = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// 构建设定组对象
|
||||
final settingGroup = SettingGroup(
|
||||
id: widget.group?.id,
|
||||
novelId: widget.novelId,
|
||||
name: _nameController.text,
|
||||
description: _descriptionController.text.isNotEmpty
|
||||
? _descriptionController.text
|
||||
: null,
|
||||
isActiveContext: _isActiveContext,
|
||||
itemIds: widget.group?.itemIds,
|
||||
);
|
||||
|
||||
// 调用保存回调
|
||||
widget.onSave(settingGroup);
|
||||
|
||||
setState(() {
|
||||
_isSaving = false;
|
||||
});
|
||||
|
||||
// 注意:不在这里关闭对话框,因为 FloatingNovelSettingGroupDialog.show() 的 onSave 回调会调用 hide()
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.e('NovelSettingGroupDialog', '保存设定组失败', e, stackTrace);
|
||||
setState(() {
|
||||
_isSaving = false;
|
||||
});
|
||||
|
||||
// 显示错误提示
|
||||
if (context.mounted) {
|
||||
TopToast.error(context, '保存失败: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
final isCreating = widget.group == null;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? WebTheme.darkBackground : WebTheme.lightBackground,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isDark ? WebTheme.darkGrey300 : WebTheme.grey300,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 头部
|
||||
_buildHeader(isDark, isCreating),
|
||||
|
||||
// 内容区域
|
||||
Expanded(
|
||||
child: _buildContent(isDark, isCreating),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(bool isDark, bool isCreating) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: isDark ? WebTheme.darkGrey300 : WebTheme.grey300,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
isCreating ? '创建设定组' : '编辑设定组',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: widget.onCancel,
|
||||
icon: Icon(
|
||||
Icons.close,
|
||||
size: 20,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
style: IconButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: const Size(32, 32),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(bool isDark, bool isCreating) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题
|
||||
Text(
|
||||
isCreating ? '创建设定组' : '编辑设定组',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 表单
|
||||
Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 名称
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
autofocus: true,
|
||||
maxLength: 30,
|
||||
decoration: WebTheme.getBorderedInputDecoration(
|
||||
labelText: '名称',
|
||||
hintText: '输入设定组名称 (30 字以内)',
|
||||
context: context,
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入设定组名称';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 描述
|
||||
TextFormField(
|
||||
controller: _descriptionController,
|
||||
maxLines: 3,
|
||||
maxLength: 200,
|
||||
decoration: WebTheme.getBorderedInputDecoration(
|
||||
labelText: '描述',
|
||||
hintText: '输入设定组描述(可选,200 字以内)',
|
||||
context: context,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 激活状态
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: isDark ? WebTheme.darkGrey300 : WebTheme.grey300,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Switch(
|
||||
value: _isActiveContext,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_isActiveContext = value;
|
||||
});
|
||||
},
|
||||
activeColor: WebTheme.getTextColor(context),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'设为活跃上下文',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'活跃上下文中的设定将用于AI生成和提示',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 按钮区域
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: widget.onCancel,
|
||||
style: WebTheme.getSecondaryButtonStyle(context),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: _isSaving ? null : _saveSettingGroup,
|
||||
style: WebTheme.getPrimaryButtonStyle(context),
|
||||
child: _isSaving
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
color: isDark ? WebTheme.darkBackground : WebTheme.lightBackground,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(isCreating ? '创建中...' : '保存中...'),
|
||||
],
|
||||
)
|
||||
: Text(isCreating ? '创建' : '保存'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:ainoval/models/setting_group.dart';
|
||||
import 'package:ainoval/blocs/setting/setting_bloc.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/novel_setting_group_dialog.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:ainoval/screens/editor/managers/editor_layout_manager.dart';
|
||||
import 'package:ainoval/widgets/common/floating_card.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
|
||||
/// 浮动设定组选择管理器
|
||||
class FloatingNovelSettingGroupSelectionDialog {
|
||||
static bool _isShowing = false;
|
||||
|
||||
/// 显示浮动设定组选择卡片
|
||||
static void show({
|
||||
required BuildContext context,
|
||||
required String novelId,
|
||||
required Function(String groupId, String groupName) onGroupSelected,
|
||||
}) {
|
||||
if (_isShowing) {
|
||||
hide();
|
||||
}
|
||||
|
||||
// 获取布局信息
|
||||
final layoutManager = Provider.of<EditorLayoutManager>(context, listen: false);
|
||||
final sidebarWidth = layoutManager.isEditorSidebarVisible ? layoutManager.editorSidebarWidth : 0.0;
|
||||
|
||||
AppLogger.d('FloatingNovelSettingGroupSelectionDialog', '显示浮动卡片,侧边栏宽度: $sidebarWidth');
|
||||
|
||||
// 计算卡片大小
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final cardWidth = (screenSize.width * 0.3).clamp(400.0, 600.0);
|
||||
final cardHeight = (screenSize.height * 0.6).clamp(400.0, 600.0);
|
||||
|
||||
// 获取当前的 Provider 实例
|
||||
final settingBloc = context.read<SettingBloc>();
|
||||
|
||||
FloatingCard.show(
|
||||
context: context,
|
||||
position: FloatingCardPosition(
|
||||
left: sidebarWidth + 16.0,
|
||||
top: 80.0,
|
||||
),
|
||||
config: FloatingCardConfig(
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
showCloseButton: false,
|
||||
enableBackgroundTap: false,
|
||||
animationDuration: const Duration(milliseconds: 300),
|
||||
animationCurve: Curves.easeOutCubic,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
child: MultiProvider(
|
||||
providers: [
|
||||
Provider<EditorLayoutManager>.value(value: layoutManager),
|
||||
BlocProvider<SettingBloc>.value(value: settingBloc),
|
||||
],
|
||||
child: _NovelSettingGroupSelectionDialogContent(
|
||||
novelId: novelId,
|
||||
onGroupSelected: onGroupSelected,
|
||||
onCancel: hide,
|
||||
),
|
||||
),
|
||||
onClose: hide,
|
||||
);
|
||||
|
||||
_isShowing = true;
|
||||
}
|
||||
|
||||
/// 隐藏浮动卡片
|
||||
static void hide() {
|
||||
if (_isShowing) {
|
||||
FloatingCard.hide();
|
||||
_isShowing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否正在显示
|
||||
static bool get isShowing => _isShowing;
|
||||
}
|
||||
|
||||
/// 小说设定组选择对话框内容
|
||||
///
|
||||
/// 用于选择现有设定组或创建新设定组
|
||||
class _NovelSettingGroupSelectionDialogContent extends StatefulWidget {
|
||||
final String novelId;
|
||||
final Function(String groupId, String groupName) onGroupSelected;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
const _NovelSettingGroupSelectionDialogContent({
|
||||
Key? key,
|
||||
required this.novelId,
|
||||
required this.onGroupSelected,
|
||||
required this.onCancel,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<_NovelSettingGroupSelectionDialogContent> createState() => _NovelSettingGroupSelectionDialogContentState();
|
||||
}
|
||||
|
||||
class _NovelSettingGroupSelectionDialogContentState extends State<_NovelSettingGroupSelectionDialogContent> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 加载设定组列表
|
||||
context.read<SettingBloc>().add(LoadSettingGroups(widget.novelId));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
elevation: 5,
|
||||
child: Container(
|
||||
width: 400,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'选择设定组',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 设定组列表
|
||||
BlocBuilder<SettingBloc, SettingState>(
|
||||
builder: (context, state) {
|
||||
if (state.groupsStatus == SettingStatus.loading) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24.0),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.groupsStatus == SettingStatus.failure) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24.0),
|
||||
child: Text(
|
||||
'加载设定组失败:${state.error}',
|
||||
style: const TextStyle(color: Colors.red),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.groupsStatus == SettingStatus.success && state.groups.isEmpty) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24.0),
|
||||
child: Text(
|
||||
'没有可用的设定组,请创建新设定组',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.groupsStatus == SettingStatus.success) {
|
||||
return SizedBox(
|
||||
height: 300,
|
||||
child: ListView.builder(
|
||||
itemCount: state.groups.length,
|
||||
itemBuilder: (context, index) {
|
||||
final group = state.groups[index];
|
||||
return ListTile(
|
||||
title: Text(group.name),
|
||||
subtitle: group.description != null && group.description!.isNotEmpty
|
||||
? Text(
|
||||
group.description!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: null,
|
||||
leading: Icon(
|
||||
Icons.folder_outlined,
|
||||
color: group.isActiveContext == true
|
||||
? Colors.blue
|
||||
: Colors.grey,
|
||||
),
|
||||
onTap: () {
|
||||
// 正确关闭浮动卡片,而不是使用Navigator.pop()
|
||||
// 使用Future.microtask确保回调在对话框处理之后执行
|
||||
Future.microtask(() {
|
||||
// 关闭浮动卡片
|
||||
FloatingNovelSettingGroupSelectionDialog.hide();
|
||||
// 延迟调用回调
|
||||
Future.delayed(Duration.zero, () {
|
||||
widget.onGroupSelected(group.id!, group.name);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24.0),
|
||||
child: Text('请加载设定组'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 操作按钮
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('创建新设定组'),
|
||||
onPressed: () {
|
||||
_showCreateGroupDialog(context);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: WebTheme.getPrimaryColor(context),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: widget.onCancel,
|
||||
child: const Text('取消'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 显示创建设定组对话框
|
||||
void _showCreateGroupDialog(BuildContext context) {
|
||||
FloatingNovelSettingGroupDialog.show(
|
||||
context: context,
|
||||
novelId: widget.novelId,
|
||||
onSave: (SettingGroup group) {
|
||||
AppLogger.i('NovelSettingGroupSelectionDialog', '创建设定组:${group.name}');
|
||||
|
||||
// 保存设定组
|
||||
context.read<SettingBloc>().add(CreateSettingGroup(
|
||||
novelId: widget.novelId,
|
||||
group: group,
|
||||
));
|
||||
|
||||
// 监听状态变化,找到新创建的设定组,但不要直接调用导航回调
|
||||
final settingBloc = context.read<SettingBloc>();
|
||||
late final subscription;
|
||||
subscription = settingBloc.stream.listen((state) {
|
||||
if (state.groupsStatus == SettingStatus.success) {
|
||||
// 检查是否有新添加的设定组
|
||||
final newGroup = state.groups.where((g) => g.name == group.name).lastOrNull;
|
||||
if (newGroup != null && newGroup.id != null) {
|
||||
subscription.cancel(); // 先停止监听
|
||||
|
||||
// 只显示成功提示,不执行选择回调,让用户手动选择
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('设定组 "${newGroup.name}" 创建成功!'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 刷新当前对话框的设定组列表
|
||||
if (context.mounted) {
|
||||
context.read<SettingBloc>().add(LoadSettingGroups(widget.novelId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.groupsStatus == SettingStatus.failure) {
|
||||
subscription.cancel();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('创建设定组失败:${state.error}'),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 一段时间后如果没有成功,取消订阅
|
||||
Future.delayed(const Duration(seconds: 10), () {
|
||||
subscription.cancel();
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:ainoval/models/novel_setting_item.dart';
|
||||
import 'package:ainoval/models/setting_type.dart'; // 导入设定类型枚举
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:ainoval/screens/editor/managers/editor_layout_manager.dart';
|
||||
import 'package:ainoval/widgets/common/floating_card.dart';
|
||||
// import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// 浮动设定关系管理器
|
||||
class FloatingNovelSettingRelationshipDialog {
|
||||
static bool _isShowing = false;
|
||||
|
||||
/// 显示浮动设定关系卡片
|
||||
static void show({
|
||||
required BuildContext context,
|
||||
required String novelId,
|
||||
required String sourceItemId, // 源条目ID
|
||||
required String sourceName, // 源条目名称,用于显示
|
||||
required List<NovelSettingItem> availableTargets, // 可选的目标条目
|
||||
required Function(String relationType, String targetItemId, String? description) onSave, // 保存回调(关系类型, 目标条目ID, 描述)
|
||||
}) {
|
||||
if (_isShowing) {
|
||||
hide();
|
||||
}
|
||||
|
||||
// 获取布局信息
|
||||
final layoutManager = Provider.of<EditorLayoutManager>(context, listen: false);
|
||||
final sidebarWidth = layoutManager.isEditorSidebarVisible ? layoutManager.editorSidebarWidth : 0.0;
|
||||
|
||||
AppLogger.d('FloatingNovelSettingRelationshipDialog', '显示浮动卡片,侧边栏宽度: $sidebarWidth');
|
||||
|
||||
// 计算卡片大小
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final cardWidth = (screenSize.width * 0.25).clamp(400.0, 600.0);
|
||||
final cardHeight = (screenSize.height * 0.6).clamp(400.0, 600.0);
|
||||
|
||||
FloatingCard.show(
|
||||
context: context,
|
||||
position: FloatingCardPosition(
|
||||
left: sidebarWidth + 16.0,
|
||||
top: 80.0,
|
||||
),
|
||||
config: FloatingCardConfig(
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
showCloseButton: false,
|
||||
enableBackgroundTap: false,
|
||||
animationDuration: const Duration(milliseconds: 300),
|
||||
animationCurve: Curves.easeOutCubic,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
child: _NovelSettingRelationshipDialogContent(
|
||||
novelId: novelId,
|
||||
sourceItemId: sourceItemId,
|
||||
sourceName: sourceName,
|
||||
availableTargets: availableTargets,
|
||||
onSave: (relationType, targetItemId, description) {
|
||||
onSave(relationType, targetItemId, description);
|
||||
hide();
|
||||
},
|
||||
onCancel: hide,
|
||||
),
|
||||
onClose: hide,
|
||||
);
|
||||
|
||||
_isShowing = true;
|
||||
}
|
||||
|
||||
/// 隐藏浮动卡片
|
||||
static void hide() {
|
||||
if (_isShowing) {
|
||||
FloatingCard.hide();
|
||||
_isShowing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否正在显示
|
||||
static bool get isShowing => _isShowing;
|
||||
}
|
||||
|
||||
/// 小说设定条目关系对话框内容
|
||||
///
|
||||
/// 用于创建条目之间的关系
|
||||
class _NovelSettingRelationshipDialogContent extends StatefulWidget {
|
||||
final String novelId;
|
||||
final String sourceItemId; // 源条目ID
|
||||
final String sourceName; // 源条目名称,用于显示
|
||||
final List<NovelSettingItem> availableTargets; // 可选的目标条目
|
||||
final Function(String relationType, String targetItemId, String? description) onSave; // 保存回调(关系类型, 目标条目ID, 描述)
|
||||
final VoidCallback onCancel; // 取消回调
|
||||
|
||||
const _NovelSettingRelationshipDialogContent({
|
||||
Key? key,
|
||||
required this.novelId,
|
||||
required this.sourceItemId,
|
||||
required this.sourceName,
|
||||
required this.availableTargets,
|
||||
required this.onSave,
|
||||
required this.onCancel,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<_NovelSettingRelationshipDialogContent> createState() => _NovelSettingRelationshipDialogContentState();
|
||||
}
|
||||
|
||||
class _NovelSettingRelationshipDialogContentState extends State<_NovelSettingRelationshipDialogContent> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// 表单控制器
|
||||
final _descriptionController = TextEditingController();
|
||||
|
||||
// 选中的目标条目
|
||||
String? _selectedTargetId;
|
||||
|
||||
// 关系类型
|
||||
String? _relationType;
|
||||
|
||||
// 常见关系类型
|
||||
final List<String> _relationTypes = [
|
||||
'朋友', '敌人', '亲戚', '同伴', '主从', '师徒', '恋人',
|
||||
'位于', '拥有', '使用', '创造', '参与', '影响',
|
||||
'属于', '领导', '成员', '其他'
|
||||
];
|
||||
|
||||
// 保存状态
|
||||
bool _isSaving = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_descriptionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// 保存关系
|
||||
void _saveRelationship() {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
setState(() {
|
||||
_isSaving = true;
|
||||
});
|
||||
|
||||
// 调用保存回调
|
||||
widget.onSave(
|
||||
_relationType!,
|
||||
_selectedTargetId!,
|
||||
_descriptionController.text.isNotEmpty ? _descriptionController.text : null,
|
||||
);
|
||||
|
||||
// 注意:不在这里关闭对话框,因为 FloatingNovelSettingRelationshipDialog.show() 的 onSave 回调会调用 hide()
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
elevation: 5,
|
||||
child: Container(
|
||||
width: 400,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'添加设定关系',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 源条目信息
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'源设定条目:',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.sourceName,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 关系类型
|
||||
DropdownButtonFormField<String>(
|
||||
decoration: const InputDecoration(
|
||||
labelText: '关系类型',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
value: _relationType,
|
||||
items: _relationTypes.map((type) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: type,
|
||||
child: Text(type),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_relationType = value;
|
||||
});
|
||||
},
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请选择关系类型';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 目标条目
|
||||
DropdownButtonFormField<String>(
|
||||
decoration: const InputDecoration(
|
||||
labelText: '目标设定条目',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
value: _selectedTargetId,
|
||||
items: widget.availableTargets.map((target) {
|
||||
// 使用SettingType枚举显示类型
|
||||
final typeEnum = SettingType.fromValue(target.type ?? 'OTHER');
|
||||
return DropdownMenuItem<String>(
|
||||
value: target.id,
|
||||
child: Row(
|
||||
children: [
|
||||
_buildTypeIcon(typeEnum),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
target.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedTargetId = value;
|
||||
});
|
||||
},
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请选择目标设定条目';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 描述
|
||||
TextFormField(
|
||||
controller: _descriptionController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '关系描述 (可选)',
|
||||
border: OutlineInputBorder(),
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
maxLines: 2,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 操作按钮
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: _isSaving ? null : _saveRelationship,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blue,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: _isSaving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Text('保存'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 构建类型图标
|
||||
Widget _buildTypeIcon(SettingType type) {
|
||||
final Color iconColor = _getTypeColor(type);
|
||||
return CircleAvatar(
|
||||
radius: 12,
|
||||
backgroundColor: iconColor.withOpacity(0.1),
|
||||
child: Icon(
|
||||
_getTypeIconData(type),
|
||||
size: 12,
|
||||
color: iconColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 获取类型图标
|
||||
IconData _getTypeIconData(SettingType type) {
|
||||
switch (type) {
|
||||
case SettingType.character:
|
||||
return Icons.person;
|
||||
case SettingType.location:
|
||||
return Icons.place;
|
||||
case SettingType.item:
|
||||
return Icons.inventory_2;
|
||||
case SettingType.lore:
|
||||
return Icons.public;
|
||||
case SettingType.event:
|
||||
return Icons.event;
|
||||
case SettingType.concept:
|
||||
return Icons.auto_awesome;
|
||||
case SettingType.faction:
|
||||
return Icons.groups;
|
||||
case SettingType.creature:
|
||||
return Icons.pets;
|
||||
case SettingType.magicSystem:
|
||||
return Icons.auto_fix_high;
|
||||
case SettingType.technology:
|
||||
return Icons.science;
|
||||
case SettingType.culture:
|
||||
return Icons.emoji_people;
|
||||
case SettingType.history:
|
||||
return Icons.history;
|
||||
case SettingType.organization:
|
||||
return Icons.apartment;
|
||||
case SettingType.worldview:
|
||||
return Icons.public;
|
||||
case SettingType.pleasurePoint:
|
||||
return Icons.whatshot;
|
||||
case SettingType.anticipationHook:
|
||||
return Icons.bolt;
|
||||
case SettingType.theme:
|
||||
return Icons.category;
|
||||
case SettingType.tone:
|
||||
return Icons.tonality;
|
||||
case SettingType.style:
|
||||
return Icons.brush;
|
||||
case SettingType.trope:
|
||||
return Icons.theater_comedy;
|
||||
case SettingType.plotDevice:
|
||||
return Icons.schema;
|
||||
case SettingType.powerSystem:
|
||||
return Icons.flash_on;
|
||||
case SettingType.timeline:
|
||||
return Icons.timeline;
|
||||
case SettingType.religion:
|
||||
return Icons.account_balance;
|
||||
case SettingType.politics:
|
||||
return Icons.gavel;
|
||||
case SettingType.economy:
|
||||
return Icons.attach_money;
|
||||
case SettingType.geography:
|
||||
return Icons.map;
|
||||
default:
|
||||
return Icons.article;
|
||||
}
|
||||
}
|
||||
|
||||
// 根据类型获取颜色
|
||||
Color _getTypeColor(SettingType type) {
|
||||
switch (type) {
|
||||
case SettingType.character:
|
||||
return Colors.blue;
|
||||
case SettingType.location:
|
||||
return Colors.green;
|
||||
case SettingType.item:
|
||||
return Colors.orange;
|
||||
case SettingType.lore:
|
||||
return Colors.purple;
|
||||
case SettingType.event:
|
||||
return Colors.red;
|
||||
case SettingType.concept:
|
||||
return Colors.teal;
|
||||
case SettingType.faction:
|
||||
return Colors.indigo;
|
||||
case SettingType.creature:
|
||||
return Colors.brown;
|
||||
case SettingType.magicSystem:
|
||||
return Colors.cyan;
|
||||
case SettingType.technology:
|
||||
return Colors.blueGrey;
|
||||
case SettingType.culture:
|
||||
return Colors.deepOrange;
|
||||
case SettingType.history:
|
||||
return Colors.brown;
|
||||
case SettingType.organization:
|
||||
return Colors.indigo;
|
||||
case SettingType.worldview:
|
||||
return Colors.purple;
|
||||
case SettingType.pleasurePoint:
|
||||
return Colors.redAccent;
|
||||
case SettingType.anticipationHook:
|
||||
return Colors.teal;
|
||||
case SettingType.theme:
|
||||
return Colors.blueGrey;
|
||||
case SettingType.tone:
|
||||
return Colors.amber;
|
||||
case SettingType.style:
|
||||
return Colors.cyan;
|
||||
case SettingType.trope:
|
||||
return Colors.pink;
|
||||
case SettingType.plotDevice:
|
||||
return Colors.green;
|
||||
case SettingType.powerSystem:
|
||||
return Colors.orange;
|
||||
case SettingType.timeline:
|
||||
return Colors.blue;
|
||||
case SettingType.religion:
|
||||
return Colors.deepPurple;
|
||||
case SettingType.politics:
|
||||
return Colors.red;
|
||||
case SettingType.economy:
|
||||
return Colors.lightGreen;
|
||||
case SettingType.geography:
|
||||
return Colors.lightBlue;
|
||||
default:
|
||||
return Colors.grey.shade700;
|
||||
}
|
||||
}
|
||||
}
|
||||
1589
AINoval/lib/screens/editor/widgets/novel_setting_sidebar.dart
Normal file
1589
AINoval/lib/screens/editor/widgets/novel_setting_sidebar.dart
Normal file
File diff suppressed because it is too large
Load Diff
1010
AINoval/lib/screens/editor/widgets/novel_settings_view.dart
Normal file
1010
AINoval/lib/screens/editor/widgets/novel_settings_view.dart
Normal file
File diff suppressed because it is too large
Load Diff
233
AINoval/lib/screens/editor/widgets/preset_menu_definitions.dart
Normal file
233
AINoval/lib/screens/editor/widgets/preset_menu_definitions.dart
Normal file
@@ -0,0 +1,233 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/models/preset_models.dart';
|
||||
import 'package:ainoval/services/ai_preset_service.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
|
||||
/// 预设菜单项数据
|
||||
class PresetMenuItemData {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool hasSubmenu;
|
||||
final bool disabled;
|
||||
final bool isDangerous;
|
||||
final Future<void> Function(BuildContext context, AIPresetService presetService, String featureType)? onTap;
|
||||
|
||||
const PresetMenuItemData({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
this.hasSubmenu = false,
|
||||
this.disabled = false,
|
||||
this.isDangerous = false,
|
||||
this.onTap,
|
||||
});
|
||||
}
|
||||
|
||||
/// 预设菜单分组数据
|
||||
class PresetMenuSectionData {
|
||||
final String? title;
|
||||
final List<PresetMenuItemData> items;
|
||||
final bool dividerAtBottom;
|
||||
|
||||
const PresetMenuSectionData({
|
||||
this.title,
|
||||
required this.items,
|
||||
this.dividerAtBottom = true,
|
||||
});
|
||||
}
|
||||
|
||||
/// 预设菜单定义
|
||||
class PresetMenuDefinitions {
|
||||
static List<dynamic> getMenuItems({
|
||||
required Function() onCreatePreset,
|
||||
required Function() onManagePresets,
|
||||
}) {
|
||||
return [
|
||||
// 主要操作
|
||||
PresetMenuSectionData(
|
||||
title: null,
|
||||
items: [
|
||||
PresetMenuItemData(
|
||||
icon: Icons.bookmark_add,
|
||||
label: 'Create Preset',
|
||||
onTap: (context, presetService, featureType) async {
|
||||
onCreatePreset();
|
||||
},
|
||||
),
|
||||
PresetMenuItemData(
|
||||
icon: Icons.edit_outlined,
|
||||
label: 'Update Preset',
|
||||
disabled: true, // 暂时禁用
|
||||
onTap: null,
|
||||
),
|
||||
],
|
||||
dividerAtBottom: true,
|
||||
),
|
||||
|
||||
// 最近使用的预设
|
||||
PresetMenuSectionData(
|
||||
title: '最近使用',
|
||||
items: [], // 动态加载
|
||||
dividerAtBottom: true,
|
||||
),
|
||||
|
||||
// 收藏预设
|
||||
PresetMenuSectionData(
|
||||
title: '收藏预设',
|
||||
items: [], // 动态加载
|
||||
dividerAtBottom: true,
|
||||
),
|
||||
|
||||
// 管理操作
|
||||
PresetMenuSectionData(
|
||||
title: null,
|
||||
items: [
|
||||
PresetMenuItemData(
|
||||
icon: Icons.settings,
|
||||
label: 'Manage Presets',
|
||||
onTap: (context, presetService, featureType) async {
|
||||
onManagePresets();
|
||||
},
|
||||
),
|
||||
],
|
||||
dividerAtBottom: false,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// 获取动态预设菜单项(包含实际预设数据)
|
||||
static Future<List<dynamic>> getDynamicMenuItems({
|
||||
required String featureType,
|
||||
required Function() onCreatePreset,
|
||||
required Function() onManagePresets,
|
||||
required Function(AIPromptPreset preset) onPresetSelected,
|
||||
String? novelId,
|
||||
}) async {
|
||||
final presetService = AIPresetService();
|
||||
|
||||
try {
|
||||
// 使用新的统一接口获取功能预设列表
|
||||
final presetListResponse = await presetService.getFeaturePresetList(featureType, novelId: novelId);
|
||||
|
||||
final recentPresets = presetListResponse.recentUsed.map((item) => item.preset).toList();
|
||||
final favoritePresets = presetListResponse.favorites.map((item) => item.preset).toList();
|
||||
|
||||
return [
|
||||
// 主要操作
|
||||
PresetMenuSectionData(
|
||||
title: null,
|
||||
items: [
|
||||
PresetMenuItemData(
|
||||
icon: Icons.bookmark_add,
|
||||
label: 'Create Preset',
|
||||
onTap: (context, presetService, featureType) async {
|
||||
onCreatePreset();
|
||||
},
|
||||
),
|
||||
PresetMenuItemData(
|
||||
icon: Icons.edit_outlined,
|
||||
label: 'Update Preset',
|
||||
disabled: true, // 暂时禁用
|
||||
onTap: null,
|
||||
),
|
||||
],
|
||||
dividerAtBottom: true,
|
||||
),
|
||||
|
||||
// 最近使用的预设
|
||||
if (recentPresets.isNotEmpty) ...[
|
||||
PresetMenuSectionData(
|
||||
title: '最近使用',
|
||||
items: recentPresets.map((preset) => PresetMenuItemData(
|
||||
icon: Icons.history,
|
||||
label: preset.presetName ?? '未命名预设',
|
||||
onTap: (context, presetService, featureType) async {
|
||||
onPresetSelected(preset);
|
||||
// 记录使用
|
||||
presetService.applyPreset(preset.presetId).catchError((e) {
|
||||
AppLogger.w('PresetMenu', '记录预设使用失败', e);
|
||||
});
|
||||
},
|
||||
)).toList(),
|
||||
dividerAtBottom: true,
|
||||
),
|
||||
],
|
||||
|
||||
// 收藏预设
|
||||
if (favoritePresets.isNotEmpty) ...[
|
||||
PresetMenuSectionData(
|
||||
title: '收藏预设',
|
||||
items: favoritePresets.map((preset) => PresetMenuItemData(
|
||||
icon: Icons.favorite,
|
||||
label: preset.presetName ?? '未命名预设',
|
||||
onTap: (context, presetService, featureType) async {
|
||||
onPresetSelected(preset);
|
||||
// 记录使用
|
||||
presetService.applyPreset(preset.presetId).catchError((e) {
|
||||
AppLogger.w('PresetMenu', '记录预设使用失败', e);
|
||||
});
|
||||
},
|
||||
)).toList(),
|
||||
dividerAtBottom: true,
|
||||
),
|
||||
],
|
||||
|
||||
// 空状态提示
|
||||
if (recentPresets.isEmpty && favoritePresets.isEmpty) ...[
|
||||
PresetMenuSectionData(
|
||||
title: null,
|
||||
items: [
|
||||
PresetMenuItemData(
|
||||
icon: Icons.info_outline,
|
||||
label: '暂无预设',
|
||||
disabled: true,
|
||||
onTap: null,
|
||||
),
|
||||
],
|
||||
dividerAtBottom: true,
|
||||
),
|
||||
],
|
||||
|
||||
// 管理操作
|
||||
PresetMenuSectionData(
|
||||
title: null,
|
||||
items: [
|
||||
PresetMenuItemData(
|
||||
icon: Icons.settings,
|
||||
label: 'Manage Presets',
|
||||
onTap: (context, presetService, featureType) async {
|
||||
onManagePresets();
|
||||
},
|
||||
),
|
||||
],
|
||||
dividerAtBottom: false,
|
||||
),
|
||||
];
|
||||
} catch (e) {
|
||||
AppLogger.e('PresetMenuDefinitions', '加载预设数据失败', e);
|
||||
|
||||
// 返回基础菜单
|
||||
return [
|
||||
PresetMenuSectionData(
|
||||
title: null,
|
||||
items: [
|
||||
PresetMenuItemData(
|
||||
icon: Icons.bookmark_add,
|
||||
label: 'Create Preset',
|
||||
onTap: (context, presetService, featureType) async {
|
||||
onCreatePreset();
|
||||
},
|
||||
),
|
||||
PresetMenuItemData(
|
||||
icon: Icons.settings,
|
||||
label: 'Manage Presets',
|
||||
onTap: (context, presetService, featureType) async {
|
||||
onManagePresets();
|
||||
},
|
||||
),
|
||||
],
|
||||
dividerAtBottom: false,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
1953
AINoval/lib/screens/editor/widgets/selection_toolbar.dart
Normal file
1953
AINoval/lib/screens/editor/widgets/selection_toolbar.dart
Normal file
File diff suppressed because it is too large
Load Diff
561
AINoval/lib/screens/editor/widgets/setting_preview_card.dart
Normal file
561
AINoval/lib/screens/editor/widgets/setting_preview_card.dart
Normal file
@@ -0,0 +1,561 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:ainoval/models/novel_setting_item.dart';
|
||||
import 'package:ainoval/models/setting_type.dart';
|
||||
import 'package:ainoval/models/setting_group.dart';
|
||||
import 'package:ainoval/blocs/setting/setting_bloc.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/novel_setting_detail.dart';
|
||||
|
||||
/// 设定信息预览卡片组件
|
||||
/// 显示设定的基本信息(分类、名称、设定组、图片、描述)
|
||||
class SettingPreviewCard extends StatefulWidget {
|
||||
final String settingId;
|
||||
final String novelId;
|
||||
final Offset position;
|
||||
final VoidCallback? onClose;
|
||||
|
||||
const SettingPreviewCard({
|
||||
Key? key,
|
||||
required this.settingId,
|
||||
required this.novelId,
|
||||
required this.position,
|
||||
this.onClose,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<SettingPreviewCard> createState() => _SettingPreviewCardState();
|
||||
}
|
||||
|
||||
class _SettingPreviewCardState extends State<SettingPreviewCard> with TickerProviderStateMixin {
|
||||
static const String _tag = 'SettingPreviewCard';
|
||||
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _scaleAnimation;
|
||||
late Animation<double> _opacityAnimation;
|
||||
|
||||
NovelSettingItem? _settingItem;
|
||||
SettingGroup? _settingGroup;
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 0.8,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOutCubic,
|
||||
));
|
||||
|
||||
_opacityAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOut,
|
||||
));
|
||||
|
||||
_loadSettingData();
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 加载设定数据
|
||||
void _loadSettingData() {
|
||||
try {
|
||||
final settingBloc = context.read<SettingBloc>();
|
||||
final state = settingBloc.state;
|
||||
|
||||
// 查找设定条目
|
||||
_settingItem = state.items.firstWhere(
|
||||
(item) => item.id == widget.settingId,
|
||||
orElse: () => NovelSettingItem(name: ''),
|
||||
);
|
||||
|
||||
if (_settingItem != null) {
|
||||
// 查找设定组
|
||||
_settingGroup = state.groups.firstWhere(
|
||||
(group) => group.itemIds?.any((item) => item == widget.settingId) == true,
|
||||
orElse: () => SettingGroup(name: ''),
|
||||
);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
AppLogger.d(_tag, '设定数据加载完成: ${_settingItem?.name ?? "未找到"}');
|
||||
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '加载设定数据失败', e);
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取设定类型图标
|
||||
IconData _getTypeIcon() {
|
||||
if (_settingItem?.type == null) return Icons.article;
|
||||
|
||||
final settingType = SettingType.fromValue(_settingItem!.type!);
|
||||
switch (settingType) {
|
||||
case SettingType.character:
|
||||
return Icons.person;
|
||||
case SettingType.location:
|
||||
return Icons.place;
|
||||
case SettingType.item:
|
||||
return Icons.inventory_2;
|
||||
case SettingType.lore:
|
||||
return Icons.public;
|
||||
case SettingType.event:
|
||||
return Icons.event;
|
||||
case SettingType.concept:
|
||||
return Icons.auto_awesome;
|
||||
case SettingType.faction:
|
||||
return Icons.groups;
|
||||
case SettingType.creature:
|
||||
return Icons.pets;
|
||||
case SettingType.magicSystem:
|
||||
return Icons.auto_fix_high;
|
||||
case SettingType.technology:
|
||||
return Icons.science;
|
||||
case SettingType.culture:
|
||||
return Icons.emoji_people;
|
||||
case SettingType.history:
|
||||
return Icons.history;
|
||||
case SettingType.organization:
|
||||
return Icons.apartment;
|
||||
case SettingType.worldview:
|
||||
return Icons.public;
|
||||
case SettingType.pleasurePoint:
|
||||
return Icons.whatshot;
|
||||
case SettingType.anticipationHook:
|
||||
return Icons.bolt;
|
||||
case SettingType.theme:
|
||||
return Icons.category;
|
||||
case SettingType.tone:
|
||||
return Icons.tonality;
|
||||
case SettingType.style:
|
||||
return Icons.brush;
|
||||
case SettingType.trope:
|
||||
return Icons.theater_comedy;
|
||||
case SettingType.plotDevice:
|
||||
return Icons.schema;
|
||||
case SettingType.powerSystem:
|
||||
return Icons.flash_on;
|
||||
case SettingType.timeline:
|
||||
return Icons.timeline;
|
||||
case SettingType.religion:
|
||||
return Icons.account_balance;
|
||||
case SettingType.politics:
|
||||
return Icons.gavel;
|
||||
case SettingType.economy:
|
||||
return Icons.attach_money;
|
||||
case SettingType.geography:
|
||||
return Icons.map;
|
||||
default:
|
||||
return Icons.article;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取设定类型显示名称
|
||||
String _getTypeDisplayName() {
|
||||
if (_settingItem?.type == null) return '其他';
|
||||
return SettingType.fromValue(_settingItem!.type!).displayName;
|
||||
}
|
||||
|
||||
/// 处理标题点击
|
||||
void _handleTitleTap() {
|
||||
AppLogger.d(_tag, '点击设定标题,打开详情卡片: ${_settingItem?.name}');
|
||||
|
||||
// 关闭当前预览卡片
|
||||
_close();
|
||||
|
||||
// 延迟一小段时间后打开详情卡片,确保预览卡片完全关闭
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
if (mounted && _settingItem != null) {
|
||||
FloatingNovelSettingDetail.show(
|
||||
context: context,
|
||||
itemId: _settingItem!.id,
|
||||
novelId: widget.novelId,
|
||||
groupId: _settingGroup?.id,
|
||||
isEditing: false,
|
||||
onSave: (item, groupId) {
|
||||
// 保存成功后可以做一些处理
|
||||
AppLogger.i(_tag, '设定详情保存成功: ${item.name}');
|
||||
},
|
||||
onCancel: () {
|
||||
// 取消操作
|
||||
AppLogger.d(_tag, '设定详情编辑取消');
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 关闭卡片
|
||||
void _close() {
|
||||
_animationController.reverse().then((_) {
|
||||
widget.onClose?.call();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
// 计算卡片位置,确保不超出屏幕边界
|
||||
const cardWidth = 320.0;
|
||||
const cardHeight = 200.0;
|
||||
|
||||
double left = widget.position.dx;
|
||||
double top = widget.position.dy;
|
||||
|
||||
// 调整水平位置
|
||||
if (left + cardWidth > screenSize.width) {
|
||||
left = screenSize.width - cardWidth - 16;
|
||||
}
|
||||
if (left < 16) {
|
||||
left = 16;
|
||||
}
|
||||
|
||||
// 调整垂直位置
|
||||
if (top + cardHeight > screenSize.height) {
|
||||
top = widget.position.dy - cardHeight - 10; // 显示在鼠标上方
|
||||
}
|
||||
if (top < 16) {
|
||||
top = 16;
|
||||
}
|
||||
|
||||
return Positioned(
|
||||
left: left,
|
||||
top: top,
|
||||
child: AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: Opacity(
|
||||
opacity: _opacityAnimation.value,
|
||||
child: Material(
|
||||
elevation: 12,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Colors.transparent,
|
||||
shadowColor: Theme.of(context).colorScheme.shadow.withOpacity(0.3),
|
||||
child: Container(
|
||||
width: cardWidth,
|
||||
constraints: const BoxConstraints(
|
||||
maxHeight: cardHeight,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getSurfaceColor(context),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isDark ? WebTheme.darkGrey700 : WebTheme.grey300,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: _buildCardContent(isDark),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建卡片内容
|
||||
Widget _buildCardContent(bool isDark) {
|
||||
if (_isLoading) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_settingItem == null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 32,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'设定不存在',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 头部区域
|
||||
_buildHeader(isDark),
|
||||
|
||||
// 分隔线
|
||||
Container(
|
||||
height: 1,
|
||||
color: isDark ? WebTheme.darkGrey800 : WebTheme.grey200,
|
||||
),
|
||||
|
||||
// 内容区域
|
||||
Flexible(
|
||||
child: _buildContent(isDark),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建头部区域
|
||||
Widget _buildHeader(bool isDark) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
// 设定图片或类型图标
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? WebTheme.darkGrey800 : WebTheme.grey100,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isDark ? WebTheme.darkGrey700 : WebTheme.grey300,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: _settingItem!.imageUrl != null && _settingItem!.imageUrl!.isNotEmpty
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
child: Image.network(
|
||||
_settingItem!.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Icon(
|
||||
_getTypeIcon(),
|
||||
size: 24,
|
||||
color: WebTheme.getTextColor(context),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
_getTypeIcon(),
|
||||
size: 24,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// 设定信息
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 设定名称(可点击)
|
||||
GestureDetector(
|
||||
onTap: _handleTitleTap,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: Text(
|
||||
_settingItem!.name,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: WebTheme.getTextColor(context).withOpacity(0.3),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// 类型和设定组
|
||||
Row(
|
||||
children: [
|
||||
// 设定类型
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getTextColor(context).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
_getTypeDisplayName(),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (_settingGroup != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
// 设定组
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getSecondaryTextColor(context).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
_settingGroup!.name,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 关闭按钮
|
||||
GestureDetector(
|
||||
onTap: _close,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.close,
|
||||
size: 16,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建内容区域
|
||||
Widget _buildContent(bool isDark) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 描述内容
|
||||
if (_settingItem!.description != null && _settingItem!.description!.isNotEmpty) ...[
|
||||
Text(
|
||||
'描述',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
_settingItem!.description!,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.4,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
maxLines: 4,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
] else if (_settingItem!.content != null && _settingItem!.content!.isNotEmpty) ...[
|
||||
Text(
|
||||
'内容',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
_settingItem!.content!,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.4,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
maxLines: 4,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
Center(
|
||||
child: Text(
|
||||
'暂无描述',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: WebTheme.getSecondaryTextColor(context).withOpacity(0.6),
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 提示文本
|
||||
Text(
|
||||
'点击标题查看详情',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: WebTheme.getSecondaryTextColor(context).withOpacity(0.7),
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
162
AINoval/lib/screens/editor/widgets/setting_reference_hover.dart
Normal file
162
AINoval/lib/screens/editor/widgets/setting_reference_hover.dart
Normal file
@@ -0,0 +1,162 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter_quill/flutter_quill.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:ainoval/utils/setting_reference_processor.dart';
|
||||
|
||||
/// 🎯 简化版设定引用悬停状态管理器
|
||||
/// 使用TextStyle.backgroundColor实现悬停效果,比复杂的位置计算更简单高效
|
||||
class SettingReferenceHoverManager extends ChangeNotifier {
|
||||
static final SettingReferenceHoverManager _instance = SettingReferenceHoverManager._internal();
|
||||
factory SettingReferenceHoverManager() => _instance;
|
||||
SettingReferenceHoverManager._internal();
|
||||
|
||||
String? _hoveredSettingId;
|
||||
String? get hoveredSettingId => _hoveredSettingId;
|
||||
|
||||
/// 设置悬停的设定引用ID
|
||||
void setHoveredSetting(String? settingId) {
|
||||
if (_hoveredSettingId != settingId) {
|
||||
_hoveredSettingId = settingId;
|
||||
notifyListeners();
|
||||
AppLogger.d('SettingReferenceHoverManager',
|
||||
_hoveredSettingId != null
|
||||
? '🖱️ 设定引用悬停开始: $_hoveredSettingId'
|
||||
: '🖱️ 设定引用悬停结束');
|
||||
}
|
||||
}
|
||||
|
||||
/// 清除悬停状态
|
||||
void clearHover() {
|
||||
setHoveredSetting(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// 设定引用交互混入 - 为 SceneEditor 提供设定引用交互功能
|
||||
mixin SettingReferenceInteractionMixin {
|
||||
/// 🎯 获取支持悬停效果的设定引用样式构建器
|
||||
/// 这是最核心的方法,直接在customStyleBuilder中处理悬停效果
|
||||
static TextStyle Function(Attribute) getCustomStyleBuilderWithHover({
|
||||
required String? hoveredSettingId,
|
||||
}) {
|
||||
return (Attribute attribute) {
|
||||
// 处理设定引用的样式标记
|
||||
if (attribute.key == SettingReferenceProcessor.settingStyleAttr &&
|
||||
attribute.value == 'reference') {
|
||||
|
||||
// 🎯 关键:使用TextStyle.backgroundColor实现悬停效果
|
||||
return const TextStyle(
|
||||
decoration: TextDecoration.underline,
|
||||
decorationStyle: TextDecorationStyle.dotted,
|
||||
decorationColor: WebTheme.grey400,
|
||||
decorationThickness: 1.5,
|
||||
// 🎯 核心:直接使用TextStyle的backgroundColor属性
|
||||
backgroundColor: Color(0x00FFF3CD),
|
||||
).copyWith(
|
||||
backgroundColor: hoveredSettingId != null ? const Color(0xFFFFF3CD) : null,
|
||||
);
|
||||
}
|
||||
|
||||
return const TextStyle();
|
||||
};
|
||||
}
|
||||
|
||||
/// 获取设定引用的自定义手势识别器构建器
|
||||
static GestureRecognizer? Function(Attribute, Node) getCustomRecognizerBuilder({
|
||||
required Function(String settingId)? onSettingReferenceClicked,
|
||||
required Function(String settingId)? onSettingReferenceHovered,
|
||||
required VoidCallback? onSettingReferenceHoverEnd,
|
||||
}) {
|
||||
return (Attribute attribute, Node node) {
|
||||
|
||||
// 检查是否是设定引用属性
|
||||
if (attribute.key == SettingReferenceProcessor.settingReferenceAttr ) {
|
||||
final settingId = attribute.value as String?;
|
||||
if (settingId != null && settingId.isNotEmpty) {
|
||||
//AppLogger.d('SettingReferenceInteraction', '🎯 创建设定引用手势识别器: $settingId');
|
||||
|
||||
// 创建支持点击和悬停的手势识别器
|
||||
final tapRecognizer = TapGestureRecognizer()
|
||||
..onTap = () {
|
||||
AppLogger.i('SettingReferenceInteraction', '🖱️ 设定引用被点击: $settingId');
|
||||
onSettingReferenceClicked?.call(settingId);
|
||||
};
|
||||
|
||||
return tapRecognizer;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// 获取设定引用的自定义样式构建器(基础版本)
|
||||
static TextStyle Function(Attribute) getCustomStyleBuilder() {
|
||||
return (Attribute attribute) {
|
||||
// 处理设定引用的样式标记
|
||||
if (attribute.key == SettingReferenceProcessor.settingStyleAttr &&
|
||||
attribute.value == 'reference') {
|
||||
return const TextStyle(
|
||||
decoration: TextDecoration.underline,
|
||||
decorationStyle: TextDecorationStyle.dotted,
|
||||
decorationColor: WebTheme.grey400,
|
||||
decorationThickness: 1.5,
|
||||
);
|
||||
}
|
||||
|
||||
return const TextStyle();
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// 🎯 设定引用鼠标悬停检测器Widget
|
||||
/// 使用MouseRegion包装编辑器,检测鼠标悬停并更新状态
|
||||
class SettingReferenceMouseDetector extends StatefulWidget {
|
||||
final Widget child;
|
||||
final QuillController controller;
|
||||
final String? novelId;
|
||||
|
||||
const SettingReferenceMouseDetector({
|
||||
Key? key,
|
||||
required this.child,
|
||||
required this.controller,
|
||||
this.novelId,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<SettingReferenceMouseDetector> createState() => _SettingReferenceMouseDetectorState();
|
||||
}
|
||||
|
||||
class _SettingReferenceMouseDetectorState extends State<SettingReferenceMouseDetector> {
|
||||
final _hoverManager = SettingReferenceHoverManager();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onHover: _handleMouseMove,
|
||||
onExit: (_) => _hoverManager.clearHover(),
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
|
||||
void _handleMouseMove(PointerHoverEvent event) {
|
||||
// 🎯 这里可以实现基于鼠标位置的设定引用检测
|
||||
// 为了简化,暂时先处理基本的悬停状态
|
||||
try {
|
||||
// TODO: 实现更精确的位置检测逻辑
|
||||
// 目前先简化处理,后续可以根据需要优化
|
||||
|
||||
// 暂时用一个简单的方式来模拟检测
|
||||
// 实际项目中可能需要更复杂的位置计算
|
||||
|
||||
AppLogger.v('SettingReferenceMouseDetector', '🖱️ 鼠标移动: ${event.localPosition}');
|
||||
|
||||
} catch (e) {
|
||||
AppLogger.w('SettingReferenceMouseDetector', '检测设定引用悬停失败', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
697
AINoval/lib/screens/editor/widgets/snippet_edit_form.dart
Normal file
697
AINoval/lib/screens/editor/widgets/snippet_edit_form.dart
Normal file
@@ -0,0 +1,697 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:ainoval/models/novel_snippet.dart';
|
||||
import 'package:ainoval/services/api_service/repositories/novel_snippet_repository.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:ainoval/screens/editor/managers/editor_layout_manager.dart';
|
||||
import 'package:ainoval/widgets/common/floating_card.dart';
|
||||
import 'package:ainoval/utils/event_bus.dart';
|
||||
|
||||
/// 浮动片段编辑卡片管理器
|
||||
class FloatingSnippetEditor {
|
||||
static bool _isShowing = false;
|
||||
|
||||
/// 显示浮动编辑卡片
|
||||
static void show({
|
||||
required BuildContext context,
|
||||
required NovelSnippet snippet,
|
||||
Function(NovelSnippet)? onSaved,
|
||||
Function(String)? onDeleted,
|
||||
}) {
|
||||
if (_isShowing) {
|
||||
hide();
|
||||
}
|
||||
|
||||
// 在创建 Overlay 前获取布局信息
|
||||
final layoutManager = Provider.of<EditorLayoutManager>(context, listen: false);
|
||||
final sidebarWidth = layoutManager.isEditorSidebarVisible ? layoutManager.editorSidebarWidth : 0.0;
|
||||
|
||||
AppLogger.d('FloatingSnippetEditor', '显示浮动卡片,侧边栏宽度: $sidebarWidth, 是否可见: ${layoutManager.isEditorSidebarVisible}');
|
||||
|
||||
// 计算卡片大小(保持原有逻辑)
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final cardWidth = (screenSize.width * 0.2).clamp(500.0, 800.0);
|
||||
final cardHeight = (screenSize.height * 0.2).clamp(300.0, 500.0);
|
||||
|
||||
FloatingCard.show(
|
||||
context: context,
|
||||
position: FloatingCardPosition(
|
||||
left: sidebarWidth + 16.0, // 与侧边栏保持16px间隙
|
||||
top: 80.0, // 距离顶部适当距离
|
||||
),
|
||||
config: FloatingCardConfig(
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
showCloseButton: false, // 我们使用自定义头部
|
||||
enableBackgroundTap: false, // 让点击穿透到底层编辑区
|
||||
animationDuration: const Duration(milliseconds: 300),
|
||||
animationCurve: Curves.easeOutCubic,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
padding: EdgeInsets.zero, // 自定义内容的padding
|
||||
),
|
||||
child: _SnippetEditContent(
|
||||
snippet: snippet,
|
||||
onSaved: (updatedSnippet) {
|
||||
onSaved?.call(updatedSnippet);
|
||||
hide();
|
||||
},
|
||||
onDeleted: (snippetId) {
|
||||
onDeleted?.call(snippetId);
|
||||
hide();
|
||||
},
|
||||
onClose: hide,
|
||||
),
|
||||
onClose: hide,
|
||||
);
|
||||
|
||||
_isShowing = true;
|
||||
}
|
||||
|
||||
/// 隐藏浮动编辑卡片
|
||||
static void hide() {
|
||||
if (_isShowing) {
|
||||
FloatingCard.hide();
|
||||
_isShowing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否正在显示
|
||||
static bool get isShowing => _isShowing;
|
||||
}
|
||||
|
||||
/// 片段编辑内容组件
|
||||
class _SnippetEditContent extends StatefulWidget {
|
||||
final NovelSnippet snippet;
|
||||
final Function(NovelSnippet)? onSaved;
|
||||
final Function(String)? onDeleted;
|
||||
final VoidCallback? onClose;
|
||||
|
||||
const _SnippetEditContent({
|
||||
required this.snippet,
|
||||
this.onSaved,
|
||||
this.onDeleted,
|
||||
this.onClose,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_SnippetEditContent> createState() => _SnippetEditContentState();
|
||||
}
|
||||
|
||||
class _SnippetEditContentState extends State<_SnippetEditContent> {
|
||||
late TextEditingController _titleController;
|
||||
late TextEditingController _contentController;
|
||||
|
||||
bool _isLoading = false;
|
||||
bool _isFavorite = false;
|
||||
|
||||
late NovelSnippetRepository _snippetRepository;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// 初始化数据
|
||||
_snippetRepository = context.read<NovelSnippetRepository>();
|
||||
_titleController = TextEditingController(text: widget.snippet.title);
|
||||
_contentController = TextEditingController(text: widget.snippet.content);
|
||||
_isFavorite = widget.snippet.isFavorite;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleController.dispose();
|
||||
_contentController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _saveSnippet() async {
|
||||
if (_isLoading) return;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// 检查是否为创建模式(ID为空)
|
||||
if (widget.snippet.id.isEmpty) {
|
||||
// 创建新片段
|
||||
final createRequest = CreateSnippetRequest(
|
||||
novelId: widget.snippet.novelId,
|
||||
title: _titleController.text,
|
||||
content: _contentController.text,
|
||||
notes: null,
|
||||
);
|
||||
|
||||
final newSnippet = await _snippetRepository.createSnippet(createRequest);
|
||||
|
||||
// 如果需要更新收藏状态,创建包含收藏状态的最终片段
|
||||
NovelSnippet finalSnippet = newSnippet;
|
||||
if (_isFavorite) {
|
||||
final favoriteRequest = UpdateSnippetFavoriteRequest(
|
||||
snippetId: newSnippet.id,
|
||||
isFavorite: _isFavorite,
|
||||
);
|
||||
await _snippetRepository.updateSnippetFavorite(favoriteRequest);
|
||||
|
||||
// 更新本地片段数据的收藏状态
|
||||
finalSnippet = newSnippet.copyWith(isFavorite: _isFavorite);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
widget.onSaved?.call(finalSnippet);
|
||||
|
||||
// 触发事件总线,通知片段列表刷新
|
||||
EventBus.instance.fire(SnippetCreatedEvent(snippet: finalSnippet));
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('片段创建成功', style: WebTheme.bodyMedium.copyWith(color: WebTheme.white)),
|
||||
backgroundColor: WebTheme.success,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 更新现有片段
|
||||
// 更新标题
|
||||
if (_titleController.text != widget.snippet.title) {
|
||||
final titleRequest = UpdateSnippetTitleRequest(
|
||||
snippetId: widget.snippet.id,
|
||||
title: _titleController.text,
|
||||
changeDescription: '更新标题',
|
||||
);
|
||||
await _snippetRepository.updateSnippetTitle(titleRequest);
|
||||
}
|
||||
|
||||
// 更新内容
|
||||
if (_contentController.text != widget.snippet.content) {
|
||||
final contentRequest = UpdateSnippetContentRequest(
|
||||
snippetId: widget.snippet.id,
|
||||
content: _contentController.text,
|
||||
changeDescription: '更新内容',
|
||||
);
|
||||
await _snippetRepository.updateSnippetContent(contentRequest);
|
||||
}
|
||||
|
||||
// 更新收藏状态
|
||||
if (_isFavorite != widget.snippet.isFavorite) {
|
||||
final favoriteRequest = UpdateSnippetFavoriteRequest(
|
||||
snippetId: widget.snippet.id,
|
||||
isFavorite: _isFavorite,
|
||||
);
|
||||
await _snippetRepository.updateSnippetFavorite(favoriteRequest);
|
||||
}
|
||||
|
||||
// 获取最新的片段数据
|
||||
final updatedSnippet = await _snippetRepository.getSnippetDetail(widget.snippet.id);
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
widget.onSaved?.call(updatedSnippet);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('片段保存成功', style: WebTheme.bodyMedium.copyWith(color: WebTheme.white)),
|
||||
backgroundColor: WebTheme.success,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.e('FloatingSnippetEditor', '保存片段失败', e);
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('保存失败: $e', style: WebTheme.bodyMedium.copyWith(color: WebTheme.white)),
|
||||
backgroundColor: WebTheme.error,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteSnippet() async {
|
||||
final confirmed = await _showDeleteConfirmDialog();
|
||||
if (!confirmed) return;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
await _snippetRepository.deleteSnippet(widget.snippet.id);
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
widget.onDeleted?.call(widget.snippet.id);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('片段删除成功', style: WebTheme.bodyMedium.copyWith(color: WebTheme.white)),
|
||||
backgroundColor: WebTheme.success,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.e('FloatingSnippetEditor', '删除片段失败', e);
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('删除失败: $e', style: WebTheme.bodyMedium.copyWith(color: WebTheme.white)),
|
||||
backgroundColor: WebTheme.error,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _showDeleteConfirmDialog() async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: WebTheme.isDarkMode(context) ? WebTheme.darkCard : WebTheme.lightCard,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
title: Text(
|
||||
'确认删除',
|
||||
style: WebTheme.titleMedium.copyWith(
|
||||
color: WebTheme.isDarkMode(context) ? WebTheme.darkGrey900 : WebTheme.grey900,
|
||||
),
|
||||
),
|
||||
content: Text(
|
||||
'确定要删除片段"${widget.snippet.title}"吗?此操作无法撤销。',
|
||||
style: WebTheme.bodyMedium.copyWith(
|
||||
color: WebTheme.isDarkMode(context) ? WebTheme.darkGrey700 : WebTheme.grey700,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(
|
||||
'取消',
|
||||
style: WebTheme.labelMedium.copyWith(
|
||||
color: WebTheme.isDarkMode(context) ? WebTheme.darkGrey600 : WebTheme.grey600,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: TextButton.styleFrom(foregroundColor: WebTheme.error),
|
||||
child: Text(
|
||||
'删除',
|
||||
style: WebTheme.labelMedium.copyWith(color: WebTheme.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getSurfaceColor(context), // 🚀 修复:使用动态表面色
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: WebTheme.isDarkMode(context)
|
||||
? Border.all(color: WebTheme.darkGrey300, width: 1)
|
||||
: Border.all(color: WebTheme.grey300, width: 1),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: WebTheme.getShadowColor(context, opacity: 0.2),
|
||||
offset: const Offset(0, 8),
|
||||
blurRadius: 32,
|
||||
spreadRadius: 0,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 头部:标题输入框和操作按钮
|
||||
_buildHeader(),
|
||||
|
||||
// 内容区域
|
||||
Expanded(
|
||||
child: _buildContent(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getSurfaceColor(context), // 🚀 修复:使用动态表面色
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(12),
|
||||
topRight: Radius.circular(12),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: WebTheme.getShadowColor(context, opacity: 0.05),
|
||||
offset: const Offset(0, 1),
|
||||
blurRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 标题输入框
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 36,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _titleController,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText: 'Name your snippet...',
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 14,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 收藏按钮
|
||||
_buildIconButton(
|
||||
icon: _isFavorite ? Icons.star : Icons.star_border,
|
||||
onPressed: () => setState(() => _isFavorite = !_isFavorite),
|
||||
color: _isFavorite ? Theme.of(context).colorScheme.tertiary : WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
|
||||
// 更多操作按钮
|
||||
_buildIconButton(
|
||||
icon: Icons.more_vert,
|
||||
onPressed: _showMoreOptions,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildIconButton({
|
||||
required IconData icon,
|
||||
required VoidCallback onPressed,
|
||||
Color? color,
|
||||
}) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
margin: const EdgeInsets.only(left: 6),
|
||||
child: IconButton(
|
||||
onPressed: onPressed,
|
||||
icon: Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: color ?? WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
style: IconButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
backgroundColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showMoreOptions() {
|
||||
// 显示更多选项菜单
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getSurfaceColor(context), // 🚀 修复:使用动态表面色
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.snippet.id.isNotEmpty)
|
||||
ListTile(
|
||||
leading: Icon(Icons.delete_outline, color: Theme.of(context).colorScheme.error),
|
||||
title: const Text('删除片段'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_deleteSnippet();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.close),
|
||||
title: const Text('关闭'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
widget.onClose?.call();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent() {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// 内容编辑区域
|
||||
Expanded(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(12),
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
border: Border.all(
|
||||
color: WebTheme.isDarkMode(context) ? WebTheme.darkGrey300 : WebTheme.grey300,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _contentController,
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.6,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText: '请输入内容...',
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 14,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 底部状态栏
|
||||
_buildFooter(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFooter() {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
final wordCount = _contentController.text.split(RegExp(r'\s+')).where((word) => word.isNotEmpty).length;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
// 字数统计
|
||||
Text(
|
||||
'$wordCount Words',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// 功能按钮
|
||||
_buildFooterButton(
|
||||
icon: Icons.history,
|
||||
label: 'History',
|
||||
onPressed: () {
|
||||
// TODO: 实现历史记录功能
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
_buildFooterButton(
|
||||
icon: Icons.content_copy,
|
||||
label: 'Copy',
|
||||
onPressed: () {
|
||||
// TODO: 实现复制功能
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// 保存按钮
|
||||
_buildSaveButton(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFooterButton({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required VoidCallback onPressed,
|
||||
}) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 14,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSaveButton() {
|
||||
if (_isLoading) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return InkWell(
|
||||
onTap: _saveSnippet,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
widget.snippet.id.isEmpty ? Icons.add : Icons.save,
|
||||
size: 14,
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
widget.snippet.id.isEmpty ? 'Create' : 'Save',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容性:保留原有的 SnippetEditForm 类,避免破坏现有代码
|
||||
@Deprecated('请使用 FloatingSnippetEditor.show() 代替')
|
||||
class SnippetEditForm extends StatelessWidget {
|
||||
final NovelSnippet snippet;
|
||||
final VoidCallback? onClose;
|
||||
final Function(NovelSnippet)? onSaved;
|
||||
final Function(String)? onDeleted;
|
||||
|
||||
const SnippetEditForm({
|
||||
super.key,
|
||||
required this.snippet,
|
||||
this.onClose,
|
||||
this.onSaved,
|
||||
this.onDeleted,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 直接返回一个空容器,因为现在使用 FloatingSnippetEditor
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
470
AINoval/lib/screens/editor/widgets/snippet_list_tab.dart
Normal file
470
AINoval/lib/screens/editor/widgets/snippet_list_tab.dart
Normal file
@@ -0,0 +1,470 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:ainoval/models/novel_snippet.dart';
|
||||
import 'package:ainoval/models/novel_summary.dart';
|
||||
import 'package:ainoval/services/api_service/repositories/novel_snippet_repository.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:ainoval/widgets/common/loading_indicator.dart';
|
||||
import 'package:ainoval/widgets/common/empty_state_placeholder.dart';
|
||||
import 'package:ainoval/widgets/common/search_action_bar.dart';
|
||||
import 'package:ainoval/utils/event_bus.dart';
|
||||
import 'dart:async';
|
||||
|
||||
/// 片段列表标签页
|
||||
class SnippetListTab extends StatefulWidget {
|
||||
final NovelSummary novel;
|
||||
final Function(NovelSnippet)? onSnippetTap;
|
||||
final Function(VoidCallback)? onRefreshCallbackChanged;
|
||||
final Function(Function(NovelSnippet))? onAddSnippetCallbackChanged;
|
||||
final Function(Function(NovelSnippet))? onUpdateSnippetCallbackChanged;
|
||||
final Function(Function(String))? onRemoveSnippetCallbackChanged;
|
||||
|
||||
const SnippetListTab({
|
||||
super.key,
|
||||
required this.novel,
|
||||
this.onSnippetTap,
|
||||
this.onRefreshCallbackChanged,
|
||||
this.onAddSnippetCallbackChanged,
|
||||
this.onUpdateSnippetCallbackChanged,
|
||||
this.onRemoveSnippetCallbackChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SnippetListTab> createState() => _SnippetListTabState();
|
||||
}
|
||||
|
||||
class _SnippetListTabState extends State<SnippetListTab>
|
||||
with AutomaticKeepAliveClientMixin<SnippetListTab> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
|
||||
List<NovelSnippet> _snippets = [];
|
||||
bool _isLoading = false;
|
||||
bool _hasMore = true;
|
||||
int _currentPage = 0;
|
||||
String _searchText = '';
|
||||
|
||||
late NovelSnippetRepository _snippetRepository;
|
||||
// 事件订阅
|
||||
StreamSubscription<SnippetCreatedEvent>? _snippetCreatedSubscription;
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true; // 🚀 保持页面存活状态
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_snippetRepository = context.read<NovelSnippetRepository>();
|
||||
_scrollController.addListener(_onScroll);
|
||||
_loadSnippets();
|
||||
|
||||
// 通知父组件各种回调方法
|
||||
widget.onRefreshCallbackChanged?.call(refreshSnippets);
|
||||
widget.onAddSnippetCallbackChanged?.call(addSnippet);
|
||||
widget.onUpdateSnippetCallbackChanged?.call(updateSnippet);
|
||||
widget.onRemoveSnippetCallbackChanged?.call(removeSnippet);
|
||||
// 订阅片段创建事件
|
||||
_snippetCreatedSubscription = EventBus.instance
|
||||
.on<SnippetCreatedEvent>()
|
||||
.listen((event) {
|
||||
if (event.snippet.novelId == widget.novel.id) {
|
||||
addSnippet(event.snippet);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
_searchController.dispose();
|
||||
_snippetCreatedSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_scrollController.position.pixels == _scrollController.position.maxScrollExtent) {
|
||||
if (!_isLoading && _hasMore) {
|
||||
_loadMoreSnippets();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadSnippets() async {
|
||||
if (_isLoading) return;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_currentPage = 0;
|
||||
_snippets.clear();
|
||||
});
|
||||
|
||||
try {
|
||||
late SnippetPageResult<NovelSnippet> result;
|
||||
|
||||
if (_searchText.isNotEmpty) {
|
||||
result = await _snippetRepository.searchSnippets(
|
||||
widget.novel.id,
|
||||
_searchText,
|
||||
page: _currentPage,
|
||||
size: 20,
|
||||
);
|
||||
} else {
|
||||
result = await _snippetRepository.getSnippetsByNovelId(
|
||||
widget.novel.id,
|
||||
page: _currentPage,
|
||||
size: 20,
|
||||
);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_snippets = result.content;
|
||||
_hasMore = result.hasNext;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
AppLogger.e('SnippetListTab', '加载片段失败', e);
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('加载片段失败: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMoreSnippets() async {
|
||||
if (_isLoading || !_hasMore) return;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
late SnippetPageResult<NovelSnippet> result;
|
||||
|
||||
if (_searchText.isNotEmpty) {
|
||||
result = await _snippetRepository.searchSnippets(
|
||||
widget.novel.id,
|
||||
_searchText,
|
||||
page: _currentPage + 1,
|
||||
size: 20,
|
||||
);
|
||||
} else {
|
||||
result = await _snippetRepository.getSnippetsByNovelId(
|
||||
widget.novel.id,
|
||||
page: _currentPage + 1,
|
||||
size: 20,
|
||||
);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_snippets.addAll(result.content);
|
||||
_hasMore = result.hasNext;
|
||||
_currentPage++;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
AppLogger.e('SnippetListTab', '加载更多片段失败', e);
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearchChanged(String value) {
|
||||
if (_searchText != value) {
|
||||
_searchText = value;
|
||||
_loadSnippets();
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新片段列表(公共方法)
|
||||
void refreshSnippets() {
|
||||
_loadSnippets();
|
||||
}
|
||||
|
||||
/// 添加新片段到列表顶部(公共方法)
|
||||
void addSnippet(NovelSnippet snippet) {
|
||||
setState(() {
|
||||
// 避免重复添加
|
||||
_snippets.removeWhere((s) => s.id == snippet.id);
|
||||
_snippets.insert(0, snippet); // 添加到列表顶部
|
||||
});
|
||||
}
|
||||
|
||||
/// 更新现有片段(公共方法)
|
||||
void updateSnippet(NovelSnippet updatedSnippet) {
|
||||
setState(() {
|
||||
final index = _snippets.indexWhere((s) => s.id == updatedSnippet.id);
|
||||
if (index != -1) {
|
||||
_snippets[index] = updatedSnippet;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 删除片段(公共方法)
|
||||
void removeSnippet(String snippetId) {
|
||||
setState(() {
|
||||
_snippets.removeWhere((s) => s.id == snippetId);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context); // 🚀 必须调用父类的build方法
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return Container(
|
||||
color: WebTheme.getBackgroundColor(context), // 🚀 修复:使用背景色而不是表面色
|
||||
child: Column(
|
||||
children: [
|
||||
// 搜索和操作栏
|
||||
SearchActionBar(
|
||||
searchController: _searchController,
|
||||
searchHint: '搜索片段...',
|
||||
newButtonText: '创建片段',
|
||||
onSearchChanged: _onSearchChanged,
|
||||
onFilterPressed: _showFilterDialog,
|
||||
onNewPressed: _showCreateSnippetDialog,
|
||||
onSettingsPressed: _showSnippetSettings,
|
||||
showFilterButton: true,
|
||||
showNewButton: true,
|
||||
showSettingsButton: true,
|
||||
),
|
||||
|
||||
// 片段统计信息
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'共 ${_snippets.length} 个片段',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isDark ? WebTheme.darkGrey600 : WebTheme.grey600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 片段列表
|
||||
Expanded(
|
||||
child: _buildSnippetList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSnippetList() {
|
||||
if (_isLoading && _snippets.isEmpty) {
|
||||
return const Center(
|
||||
child: LoadingIndicator(
|
||||
message: '正在加载片段...',
|
||||
size: 32,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_snippets.isEmpty) {
|
||||
return EmptyStatePlaceholder(
|
||||
icon: Icons.bookmark_border,
|
||||
title: '暂无片段',
|
||||
message: _searchText.isNotEmpty ? '未找到匹配的片段' : '还没有创建任何片段\n点击上方"创建片段"按钮创建第一个片段',
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _snippets.length + (_hasMore ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == _snippets.length) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: LoadingIndicator(size: 24),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final snippet = _snippets[index];
|
||||
return _buildSnippetItem(snippet);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSnippetItem(NovelSnippet snippet) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getSurfaceColor(context), // 🚀 修复:使用动态表面色
|
||||
border: Border.all(
|
||||
color: isDark ? WebTheme.darkGrey200 : WebTheme.grey200,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => widget.onSnippetTap?.call(snippet),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题行
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
snippet.title,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark ? WebTheme.darkGrey900 : WebTheme.grey900,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (snippet.isFavorite)
|
||||
Icon(
|
||||
Icons.star,
|
||||
size: 16,
|
||||
color: WebTheme.isDarkMode(context) ? WebTheme.darkGrey600 : WebTheme.grey600,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 内容预览
|
||||
Text(
|
||||
snippet.content,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isDark ? WebTheme.darkGrey600 : WebTheme.grey600,
|
||||
height: 1.4,
|
||||
),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 元数据
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.text_fields,
|
||||
size: 12,
|
||||
color: isDark ? WebTheme.darkGrey500 : WebTheme.grey500,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${snippet.metadata.wordCount}字',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: isDark ? WebTheme.darkGrey500 : WebTheme.grey500,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 12,
|
||||
color: isDark ? WebTheme.darkGrey500 : WebTheme.grey500,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_formatDate(snippet.updatedAt),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: isDark ? WebTheme.darkGrey500 : WebTheme.grey500,
|
||||
),
|
||||
),
|
||||
if (snippet.tags?.isNotEmpty == true) ...[
|
||||
const SizedBox(width: 16),
|
||||
Icon(
|
||||
Icons.local_offer,
|
||||
size: 12,
|
||||
color: isDark ? WebTheme.darkGrey500 : WebTheme.grey500,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
snippet.tags!.first,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: isDark ? WebTheme.darkGrey500 : WebTheme.grey500,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(date);
|
||||
|
||||
if (difference.inDays > 0) {
|
||||
return '${difference.inDays}天前';
|
||||
} else if (difference.inHours > 0) {
|
||||
return '${difference.inHours}小时前';
|
||||
} else if (difference.inMinutes > 0) {
|
||||
return '${difference.inMinutes}分钟前';
|
||||
} else {
|
||||
return '刚刚';
|
||||
}
|
||||
}
|
||||
|
||||
void _showCreateSnippetDialog() {
|
||||
// 创建一个新的空片段用于创建模式
|
||||
final newSnippet = NovelSnippet(
|
||||
id: '', // 空ID表示创建模式
|
||||
userId: '',
|
||||
novelId: widget.novel.id,
|
||||
title: '',
|
||||
content: '',
|
||||
metadata: const SnippetMetadata(
|
||||
wordCount: 0,
|
||||
characterCount: 0,
|
||||
viewCount: 0,
|
||||
sortWeight: 0,
|
||||
),
|
||||
isFavorite: false,
|
||||
status: 'draft',
|
||||
version: 1,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
// 使用FloatingSnippetEditor显示表单
|
||||
widget.onSnippetTap?.call(newSnippet);
|
||||
}
|
||||
|
||||
void _showFilterDialog() {
|
||||
// TODO: 实现过滤器对话框
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('过滤器功能待实现')),
|
||||
);
|
||||
}
|
||||
|
||||
void _showSnippetSettings() {
|
||||
// TODO: 实现片段设置
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('片段设置功能待实现')),
|
||||
);
|
||||
}
|
||||
}
|
||||
118
AINoval/lib/screens/editor/widgets/word_count_display.dart
Normal file
118
AINoval/lib/screens/editor/widgets/word_count_display.dart
Normal file
@@ -0,0 +1,118 @@
|
||||
import 'package:ainoval/utils/word_count_analyzer.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_quill/flutter_quill.dart';
|
||||
|
||||
class WordCountDisplay extends StatefulWidget {
|
||||
const WordCountDisplay({
|
||||
super.key,
|
||||
required this.controller,
|
||||
});
|
||||
final QuillController controller;
|
||||
|
||||
@override
|
||||
State<WordCountDisplay> createState() => _WordCountDisplayState();
|
||||
}
|
||||
|
||||
class _WordCountDisplayState extends State<WordCountDisplay> {
|
||||
WordCountStats _stats = const WordCountStats(
|
||||
words: 0,
|
||||
charactersWithSpaces: 0,
|
||||
charactersNoSpaces: 0,
|
||||
paragraphs: 0,
|
||||
readTimeMinutes: 0,
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_updateStats();
|
||||
|
||||
// 监听内容变化
|
||||
widget.controller.document.changes.listen((_) {
|
||||
_updateStats();
|
||||
});
|
||||
}
|
||||
|
||||
void _updateStats() {
|
||||
final text = widget.controller.document.toPlainText();
|
||||
final stats = WordCountAnalyzer.analyze(text);
|
||||
|
||||
setState(() {
|
||||
_stats = stats;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 使用 Material 增加背景色和圆角
|
||||
return Material(
|
||||
color:
|
||||
Theme.of(context).chipTheme.backgroundColor ?? Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(8), // 增加圆角
|
||||
child: InkWell(
|
||||
onTap: () => _showStatsDialog(context),
|
||||
borderRadius: BorderRadius.circular(8), // 保持与 Material 一致
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Text(
|
||||
'${_stats.words}字',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).textTheme.bodySmall?.color,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 显示详细统计信息对话框
|
||||
void _showStatsDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
// 为对话框添加圆角
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
title: const Text('字数统计'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildStatRow('总字数', '${_stats.words}'),
|
||||
_buildStatRow('字符数(含空格)', '${_stats.charactersWithSpaces}'),
|
||||
_buildStatRow('字符数(不含空格)', '${_stats.charactersNoSpaces}'),
|
||||
_buildStatRow('段落数', '${_stats.paragraphs}'),
|
||||
_buildStatRow('预计阅读时间', '${_stats.readTimeMinutes}分钟'),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('关闭'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 构建统计行
|
||||
Widget _buildStatRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(value),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user