马良AI写作初始化仓库
This commit is contained in:
465
AINoval/lib/utils/ai_generated_content_processor.dart
Normal file
465
AINoval/lib/utils/ai_generated_content_processor.dart
Normal file
@@ -0,0 +1,465 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_quill/flutter_quill.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:flutter_quill/quill_delta.dart';
|
||||
|
||||
/// AI生成内容处理器
|
||||
/// 用于为AI生成的内容添加蓝色样式标识,并管理临时状态
|
||||
class AIGeneratedContentProcessor {
|
||||
static const String _tag = 'AIGeneratedContentProcessor';
|
||||
|
||||
/// AI生成内容的自定义属性名
|
||||
static const String aiGeneratedAttr = 'ai-generated';
|
||||
|
||||
/// AI生成内容样式属性名(用于CSS选择器识别)
|
||||
static const String aiGeneratedStyleAttr = 'ai-generated-style';
|
||||
|
||||
/// 🆕 隐藏文本的自定义属性名(用于重构时隐藏原文本)
|
||||
static const String hiddenTextAttr = 'hidden-text';
|
||||
|
||||
/// 🆕 隐藏文本样式属性名(用于CSS选择器识别)
|
||||
static const String hiddenTextStyleAttr = 'hidden-text-style';
|
||||
|
||||
/// 🎯 为指定范围的文本添加AI生成标识
|
||||
static void markAsAIGenerated({
|
||||
required QuillController controller,
|
||||
required int startOffset,
|
||||
required int length,
|
||||
}) {
|
||||
try {
|
||||
//AppLogger.d(_tag, '🎨 标记AI生成内容: 位置 $startOffset-${startOffset + length}');
|
||||
|
||||
// 保存当前选择
|
||||
final originalSelection = controller.selection;
|
||||
|
||||
// 创建AI生成内容的自定义属性
|
||||
const aiGeneratedAttribute = Attribute(
|
||||
aiGeneratedAttr,
|
||||
AttributeScope.inline,
|
||||
'true',
|
||||
);
|
||||
|
||||
// 创建AI生成内容样式属性(用于CSS识别)
|
||||
const aiGeneratedStyleAttribute = Attribute(
|
||||
aiGeneratedStyleAttr,
|
||||
AttributeScope.inline,
|
||||
'generated',
|
||||
);
|
||||
|
||||
// 应用AI生成标识属性
|
||||
controller.formatText(startOffset, length, aiGeneratedAttribute);
|
||||
controller.formatText(startOffset, length, aiGeneratedStyleAttribute);
|
||||
|
||||
// 恢复选择状态
|
||||
controller.updateSelection(originalSelection, ChangeSource.silent);
|
||||
|
||||
//AppLogger.v(_tag, '✅ AI生成内容标记完成');
|
||||
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '标记AI生成内容失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🆕 为指定范围的文本添加隐藏标识(用于重构时隐藏原文本)
|
||||
static void markAsHidden({
|
||||
required QuillController controller,
|
||||
required int startOffset,
|
||||
required int length,
|
||||
}) {
|
||||
try {
|
||||
AppLogger.i(_tag, '🫥 标记隐藏文本: 位置 $startOffset-${startOffset + length}');
|
||||
|
||||
// 保存当前选择
|
||||
final originalSelection = controller.selection;
|
||||
|
||||
// 创建隐藏文本的自定义属性
|
||||
const hiddenAttribute = Attribute(
|
||||
hiddenTextAttr,
|
||||
AttributeScope.inline,
|
||||
'true',
|
||||
);
|
||||
|
||||
// 创建隐藏文本样式属性(用于CSS识别)
|
||||
const hiddenStyleAttribute = Attribute(
|
||||
hiddenTextStyleAttr,
|
||||
AttributeScope.inline,
|
||||
'hidden',
|
||||
);
|
||||
|
||||
// 应用隐藏标识属性
|
||||
controller.formatText(startOffset, length, hiddenAttribute);
|
||||
controller.formatText(startOffset, length, hiddenStyleAttribute);
|
||||
|
||||
// 恢复选择状态
|
||||
controller.updateSelection(originalSelection, ChangeSource.silent);
|
||||
|
||||
AppLogger.v(_tag, '✅ 隐藏文本标记完成');
|
||||
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '标记隐藏文本失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🎯 移除AI生成标识,将内容转为正常文本
|
||||
static void removeAIGeneratedMarks({
|
||||
required QuillController controller,
|
||||
int? startOffset,
|
||||
int? length,
|
||||
}) {
|
||||
try {
|
||||
AppLogger.i(_tag, '🗑️ 移除AI生成标识');
|
||||
|
||||
final document = controller.document;
|
||||
final plainText = document.toPlainText();
|
||||
|
||||
final removeStart = startOffset ?? 0;
|
||||
final removeLength = length ?? plainText.length;
|
||||
|
||||
if (removeLength <= 0) return;
|
||||
|
||||
// 保存当前选择
|
||||
final originalSelection = controller.selection;
|
||||
|
||||
// 移除AI生成相关的属性
|
||||
final removeAttributes = [
|
||||
Attribute(aiGeneratedAttr, AttributeScope.inline, null),
|
||||
Attribute(aiGeneratedStyleAttr, AttributeScope.inline, null),
|
||||
];
|
||||
|
||||
for (final attr in removeAttributes) {
|
||||
controller.formatText(removeStart, removeLength, attr);
|
||||
}
|
||||
|
||||
// 恢复选择状态
|
||||
controller.updateSelection(originalSelection, ChangeSource.silent);
|
||||
|
||||
AppLogger.i(_tag, '✅ AI生成标识移除完成');
|
||||
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '移除AI生成标识失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🆕 移除隐藏标识,显示文本(用于恢复原文本)
|
||||
static void removeHiddenMarks({
|
||||
required QuillController controller,
|
||||
int? startOffset,
|
||||
int? length,
|
||||
}) {
|
||||
try {
|
||||
AppLogger.i(_tag, '👁️ 移除隐藏标识,显示文本');
|
||||
|
||||
final document = controller.document;
|
||||
final plainText = document.toPlainText();
|
||||
|
||||
final removeStart = startOffset ?? 0;
|
||||
final removeLength = length ?? plainText.length;
|
||||
|
||||
if (removeLength <= 0) return;
|
||||
|
||||
// 保存当前选择
|
||||
final originalSelection = controller.selection;
|
||||
|
||||
// 移除隐藏相关的属性
|
||||
final removeAttributes = [
|
||||
Attribute(hiddenTextAttr, AttributeScope.inline, null),
|
||||
Attribute(hiddenTextStyleAttr, AttributeScope.inline, null),
|
||||
];
|
||||
|
||||
for (final attr in removeAttributes) {
|
||||
controller.formatText(removeStart, removeLength, attr);
|
||||
}
|
||||
|
||||
// 恢复选择状态
|
||||
controller.updateSelection(originalSelection, ChangeSource.silent);
|
||||
|
||||
AppLogger.i(_tag, '✅ 隐藏标识移除完成,文本已显示');
|
||||
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '移除隐藏标识失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🎯 检查指定范围是否包含AI生成内容
|
||||
static bool hasAIGeneratedContent({
|
||||
required QuillController controller,
|
||||
required int startOffset,
|
||||
required int length,
|
||||
}) {
|
||||
try {
|
||||
final document = controller.document;
|
||||
|
||||
// 遍历指定范围内的所有节点,检查是否有AI生成标识
|
||||
final delta = document.toDelta();
|
||||
int currentOffset = 0;
|
||||
|
||||
for (final operation in delta.operations) {
|
||||
if (operation.isInsert) {
|
||||
final opLength = operation.length!;
|
||||
final opEnd = currentOffset + opLength;
|
||||
|
||||
// 检查操作是否与指定范围重叠
|
||||
if (currentOffset < startOffset + length && opEnd > startOffset) {
|
||||
// 检查操作的属性中是否包含AI生成标识
|
||||
final attributes = operation.attributes;
|
||||
if (attributes != null && attributes.containsKey(aiGeneratedAttr)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
currentOffset = opEnd;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '检查AI生成内容失败', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 🎯 获取所有AI生成内容的范围
|
||||
static List<({int start, int length})> getAIGeneratedRanges({
|
||||
required QuillController controller,
|
||||
}) {
|
||||
final ranges = <({int start, int length})>[];
|
||||
|
||||
try {
|
||||
final document = controller.document;
|
||||
final delta = document.toDelta();
|
||||
int currentOffset = 0;
|
||||
|
||||
for (final operation in delta.operations) {
|
||||
if (operation.isInsert) {
|
||||
final opLength = operation.length!;
|
||||
|
||||
// 检查操作的属性中是否包含AI生成标识
|
||||
final attributes = operation.attributes;
|
||||
if (attributes != null && attributes.containsKey(aiGeneratedAttr)) {
|
||||
ranges.add((start: currentOffset, length: opLength));
|
||||
}
|
||||
|
||||
currentOffset += opLength;
|
||||
}
|
||||
}
|
||||
|
||||
AppLogger.d(_tag, '📍 找到 ${ranges.length} 个AI生成内容范围');
|
||||
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '获取AI生成内容范围失败', e);
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
/// 🎯 获取自定义样式构建器,用于处理AI生成内容和隐藏文本的显示样式
|
||||
static TextStyle Function(Attribute) getCustomStyleBuilder() {
|
||||
return (Attribute attribute) {
|
||||
// 处理AI生成内容的样式标记
|
||||
if (attribute.key == aiGeneratedStyleAttr &&
|
||||
attribute.value == 'generated') {
|
||||
return const TextStyle(
|
||||
color: Color(0xFF2196F3), // 蓝色文字
|
||||
// 可以添加更多样式,如背景色、下划线等
|
||||
);
|
||||
}
|
||||
|
||||
// 🆕 处理隐藏文本的样式标记
|
||||
if (attribute.key == hiddenTextStyleAttr &&
|
||||
attribute.value == 'hidden') {
|
||||
return const TextStyle(
|
||||
color: Color(0x40000000), // 25%透明度的黑色,几乎看不见
|
||||
decoration: TextDecoration.lineThrough, // 删除线
|
||||
decorationColor: Color(0x60FF0000), // 半透明红色删除线
|
||||
decorationThickness: 1.5,
|
||||
// 可选:背景色表示这是被隐藏的内容
|
||||
// backgroundColor: Color(0x10FF0000), // 淡红色背景
|
||||
);
|
||||
}
|
||||
|
||||
return const TextStyle();
|
||||
};
|
||||
}
|
||||
|
||||
/// 🎯 清除所有AI生成标识(通常在apply时调用)
|
||||
static void clearAllAIGeneratedMarks({
|
||||
required QuillController controller,
|
||||
}) {
|
||||
try {
|
||||
AppLogger.i(_tag, '🧹 清除所有AI生成标识');
|
||||
|
||||
removeAIGeneratedMarks(
|
||||
controller: controller,
|
||||
startOffset: 0,
|
||||
length: controller.document.toPlainText().length,
|
||||
);
|
||||
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '清除所有AI生成标识失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🆕 获取所有隐藏文本的范围
|
||||
static List<({int start, int length})> getHiddenTextRanges({
|
||||
required QuillController controller,
|
||||
}) {
|
||||
final ranges = <({int start, int length})>[];
|
||||
|
||||
try {
|
||||
final document = controller.document;
|
||||
final delta = document.toDelta();
|
||||
int currentOffset = 0;
|
||||
|
||||
for (final operation in delta.operations) {
|
||||
if (operation.isInsert) {
|
||||
final opLength = operation.length!;
|
||||
|
||||
// 检查操作的属性中是否包含隐藏标识
|
||||
final attributes = operation.attributes;
|
||||
if (attributes != null && attributes.containsKey(hiddenTextAttr)) {
|
||||
ranges.add((start: currentOffset, length: opLength));
|
||||
}
|
||||
|
||||
currentOffset += opLength;
|
||||
}
|
||||
}
|
||||
|
||||
AppLogger.d(_tag, '📍 找到 ${ranges.length} 个隐藏文本范围');
|
||||
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '获取隐藏文本范围失败', e);
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
/// 🆕 检查指定范围是否包含隐藏文本
|
||||
static bool hasHiddenText({
|
||||
required QuillController controller,
|
||||
required int startOffset,
|
||||
required int length,
|
||||
}) {
|
||||
try {
|
||||
final document = controller.document;
|
||||
|
||||
// 遍历指定范围内的所有节点,检查是否有隐藏标识
|
||||
final delta = document.toDelta();
|
||||
int currentOffset = 0;
|
||||
|
||||
for (final operation in delta.operations) {
|
||||
if (operation.isInsert) {
|
||||
final opLength = operation.length!;
|
||||
final opEnd = currentOffset + opLength;
|
||||
|
||||
// 检查操作是否与指定范围重叠
|
||||
if (currentOffset < startOffset + length && opEnd > startOffset) {
|
||||
// 检查操作的属性中是否包含隐藏标识
|
||||
final attributes = operation.attributes;
|
||||
if (attributes != null && attributes.containsKey(hiddenTextAttr)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
currentOffset = opEnd;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '检查隐藏文本失败', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 🆕 获取过滤掉隐藏文本的纯文本内容(用于保存)
|
||||
static String getVisibleTextOnly({
|
||||
required QuillController controller,
|
||||
}) {
|
||||
try {
|
||||
final document = controller.document;
|
||||
final delta = document.toDelta();
|
||||
final visibleText = StringBuffer();
|
||||
|
||||
for (final operation in delta.operations) {
|
||||
if (operation.isInsert) {
|
||||
final text = operation.data.toString();
|
||||
final attributes = operation.attributes;
|
||||
|
||||
// 只包含非隐藏的文本
|
||||
if (attributes == null || !attributes.containsKey(hiddenTextAttr)) {
|
||||
visibleText.write(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final result = visibleText.toString();
|
||||
AppLogger.d(_tag, '📝 过滤后可见文本长度: ${result.length}');
|
||||
return result;
|
||||
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '获取可见文本失败', e);
|
||||
return controller.document.toPlainText(); // 回退到原始文本
|
||||
}
|
||||
}
|
||||
|
||||
/// 🆕 获取过滤掉隐藏文本的Delta JSON(用于保存)
|
||||
static String getVisibleDeltaJsonOnly({
|
||||
required QuillController controller,
|
||||
}) {
|
||||
try {
|
||||
final document = controller.document;
|
||||
final originalDelta = document.toDelta();
|
||||
final visibleOperations = <Map<String, dynamic>>[];
|
||||
|
||||
for (final operation in originalDelta.operations) {
|
||||
if (operation.isInsert) {
|
||||
final attributes = operation.attributes;
|
||||
|
||||
// 只包含非隐藏的操作
|
||||
if (attributes == null || !attributes.containsKey(hiddenTextAttr)) {
|
||||
visibleOperations.add(operation.toJson());
|
||||
}
|
||||
} else {
|
||||
// 保留非插入操作(删除、保持等)
|
||||
visibleOperations.add(operation.toJson());
|
||||
}
|
||||
}
|
||||
|
||||
final visibleDeltaJson = {'ops': visibleOperations};
|
||||
AppLogger.d(_tag, '📝 过滤后Delta操作数量: ${visibleOperations.length}');
|
||||
return jsonEncode(visibleDeltaJson);
|
||||
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '获取可见Delta JSON失败', e);
|
||||
return jsonEncode(controller.document.toDelta().toJson()); // 回退到原始Delta
|
||||
}
|
||||
}
|
||||
|
||||
/// 🎯 检查文档是否包含任何AI生成内容
|
||||
static bool hasAnyAIGeneratedContent({
|
||||
required QuillController controller,
|
||||
}) {
|
||||
try {
|
||||
final ranges = getAIGeneratedRanges(controller: controller);
|
||||
return ranges.isNotEmpty;
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '检查AI生成内容失败', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 🆕 检查文档是否包含任何隐藏文本
|
||||
static bool hasAnyHiddenText({
|
||||
required QuillController controller,
|
||||
}) {
|
||||
try {
|
||||
final ranges = getHiddenTextRanges(controller: controller);
|
||||
return ranges.isNotEmpty;
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '检查隐藏文本失败', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
112
AINoval/lib/utils/app_theme.dart
Normal file
112
AINoval/lib/utils/app_theme.dart
Normal file
@@ -0,0 +1,112 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppTheme {
|
||||
static const Color primaryColor = Color(0xFF1A73E8);
|
||||
static const Color secondaryColor = Color(0xFF009688);
|
||||
|
||||
// 浅色主题
|
||||
static final ThemeData lightTheme = ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: primaryColor,
|
||||
secondary: secondaryColor,
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
scaffoldBackgroundColor: Colors.grey.shade50,
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
elevation: 0,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
),
|
||||
floatingActionButtonTheme: const FloatingActionButtonThemeData(
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
);
|
||||
|
||||
// 深色主题
|
||||
static final ThemeData darkTheme = ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: primaryColor,
|
||||
secondary: secondaryColor,
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
scaffoldBackgroundColor: const Color(0xFF121212),
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Color(0xFF1E1E1E),
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
),
|
||||
floatingActionButtonTheme: const FloatingActionButtonThemeData(
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
);
|
||||
}
|
||||
315
AINoval/lib/utils/content_formatter.dart
Normal file
315
AINoval/lib/utils/content_formatter.dart
Normal file
@@ -0,0 +1,315 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// 内容格式化工具类
|
||||
/// 智能识别文本内容类型并进行相应的格式化
|
||||
class ContentFormatter {
|
||||
|
||||
/// 格式化内容
|
||||
///
|
||||
/// 自动检测内容类型并应用相应的格式化
|
||||
///
|
||||
/// 支持的格式:
|
||||
/// - XML(默认优先)
|
||||
/// - JSON
|
||||
/// - YAML
|
||||
/// - Markdown
|
||||
/// - 普通文本(保持原样)
|
||||
static FormattedContent formatContent(String content) {
|
||||
if (content.trim().isEmpty) {
|
||||
return FormattedContent(
|
||||
content: content,
|
||||
type: ContentType.xml, // 默认为XML类型
|
||||
formatted: content,
|
||||
);
|
||||
}
|
||||
|
||||
// 优先检测和格式化XML
|
||||
final xmlResult = _tryFormatXml(content);
|
||||
if (xmlResult != null) {
|
||||
return xmlResult;
|
||||
}
|
||||
|
||||
// 检测和格式化JSON
|
||||
final jsonResult = _tryFormatJson(content);
|
||||
if (jsonResult != null) {
|
||||
return jsonResult;
|
||||
}
|
||||
|
||||
// 检测YAML格式
|
||||
final yamlResult = _tryDetectYaml(content);
|
||||
if (yamlResult != null) {
|
||||
return yamlResult;
|
||||
}
|
||||
|
||||
// 检测Markdown格式
|
||||
final markdownResult = _tryDetectMarkdown(content);
|
||||
if (markdownResult != null) {
|
||||
return markdownResult;
|
||||
}
|
||||
|
||||
// 默认为XML格式(即使不是标准XML也使用XML高亮)
|
||||
return FormattedContent(
|
||||
content: content,
|
||||
type: ContentType.xml,
|
||||
formatted: _formatAsXml(content),
|
||||
);
|
||||
}
|
||||
|
||||
/// 尝试格式化XML内容
|
||||
static FormattedContent? _tryFormatXml(String content) {
|
||||
final trimmed = content.trim();
|
||||
|
||||
// XML检测:宽松检测,包含标签特征即认为是XML
|
||||
if (_looksLikeXml(trimmed)) {
|
||||
try {
|
||||
final formatted = _formatXmlString(trimmed);
|
||||
|
||||
return FormattedContent(
|
||||
content: content,
|
||||
type: ContentType.xml,
|
||||
formatted: formatted,
|
||||
);
|
||||
} catch (e) {
|
||||
// 即使格式化失败,仍然作为XML处理
|
||||
return FormattedContent(
|
||||
content: content,
|
||||
type: ContentType.xml,
|
||||
formatted: trimmed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 检查内容是否看起来像XML
|
||||
static bool _looksLikeXml(String content) {
|
||||
// 宽松的XML检测
|
||||
if (content.contains('<') && content.contains('>')) {
|
||||
// 检查是否包含XML标签模式
|
||||
final xmlTagPattern = RegExp(r'<[^>]+>');
|
||||
return xmlTagPattern.hasMatch(content);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 将任何内容格式化为XML样式
|
||||
static String _formatAsXml(String content) {
|
||||
// 如果内容不包含XML标签,将其包装在XML标签中
|
||||
if (!_looksLikeXml(content)) {
|
||||
return '<content>\n${content.split('\n').map((line) => ' $line').join('\n')}\n</content>';
|
||||
}
|
||||
|
||||
// 如果已经是XML样式,尝试格式化
|
||||
try {
|
||||
return _formatXmlString(content);
|
||||
} catch (e) {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
/// 尝试格式化JSON内容
|
||||
static FormattedContent? _tryFormatJson(String content) {
|
||||
final trimmed = content.trim();
|
||||
|
||||
// 基本JSON检测(仅在明确是JSON时才处理)
|
||||
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) ||
|
||||
(trimmed.startsWith('[') && trimmed.endsWith(']'))) {
|
||||
try {
|
||||
// 尝试解析JSON
|
||||
final dynamic parsed = jsonDecode(trimmed);
|
||||
|
||||
// 格式化JSON
|
||||
const encoder = JsonEncoder.withIndent(' ');
|
||||
final formatted = encoder.convert(parsed);
|
||||
|
||||
return FormattedContent(
|
||||
content: content,
|
||||
type: ContentType.json,
|
||||
formatted: formatted,
|
||||
);
|
||||
} catch (e) {
|
||||
// 不是有效的JSON,返回null让其他格式处理
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 尝试检测YAML内容
|
||||
static FormattedContent? _tryDetectYaml(String content) {
|
||||
final lines = content.split('\n');
|
||||
bool hasYamlPattern = false;
|
||||
|
||||
// 检测YAML特征(只有明确的YAML模式才识别)
|
||||
for (String line in lines) {
|
||||
final trimmed = line.trim();
|
||||
if (trimmed.isEmpty || trimmed.startsWith('#')) continue;
|
||||
|
||||
// YAML键值对模式(更严格的检测)
|
||||
if (RegExp(r'^[a-zA-Z_][a-zA-Z0-9_]*\s*:\s*[^<>]+$').hasMatch(trimmed)) {
|
||||
hasYamlPattern = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// YAML列表模式
|
||||
if (RegExp(r'^\s*-\s+[^<>]+$').hasMatch(trimmed)) {
|
||||
hasYamlPattern = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 确保不是XML内容被误认为YAML
|
||||
if (hasYamlPattern && !_looksLikeXml(content)) {
|
||||
return FormattedContent(
|
||||
content: content,
|
||||
type: ContentType.yaml,
|
||||
formatted: content, // YAML通常已经是格式化的
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 尝试检测Markdown内容
|
||||
static FormattedContent? _tryDetectMarkdown(String content) {
|
||||
final lines = content.split('\n');
|
||||
bool hasMarkdownPattern = false;
|
||||
|
||||
// 检测Markdown特征(只有明确的Markdown模式才识别)
|
||||
for (String line in lines) {
|
||||
final trimmed = line.trim();
|
||||
|
||||
// Markdown标题
|
||||
if (RegExp(r'^#{1,6}\s+.+').hasMatch(trimmed)) {
|
||||
hasMarkdownPattern = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Markdown代码块
|
||||
if (trimmed.startsWith('```')) {
|
||||
hasMarkdownPattern = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Markdown链接(更严格的检测)
|
||||
if (RegExp(r'\[.+\]\(.+\)').hasMatch(trimmed)) {
|
||||
hasMarkdownPattern = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 确保不是XML内容被误认为Markdown
|
||||
if (hasMarkdownPattern && !_looksLikeXml(content)) {
|
||||
return FormattedContent(
|
||||
content: content,
|
||||
type: ContentType.markdown,
|
||||
formatted: content,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 改进的XML格式化
|
||||
static String _formatXmlString(String xml) {
|
||||
final buffer = StringBuffer();
|
||||
int indent = 0;
|
||||
bool inTag = false;
|
||||
bool inClosingTag = false;
|
||||
bool inText = false;
|
||||
|
||||
String currentLine = '';
|
||||
|
||||
for (int i = 0; i < xml.length; i++) {
|
||||
final char = xml[i];
|
||||
|
||||
if (char == '<') {
|
||||
// 处理之前积累的文本内容
|
||||
if (inText && currentLine.trim().isNotEmpty) {
|
||||
buffer.writeln('${' ' * indent}${currentLine.trim()}');
|
||||
currentLine = '';
|
||||
}
|
||||
inText = false;
|
||||
|
||||
// 检查是否是闭合标签
|
||||
if (xml.length > i + 1 && xml[i + 1] == '/') {
|
||||
inClosingTag = true;
|
||||
indent = (indent - 1).clamp(0, 100);
|
||||
}
|
||||
|
||||
// 添加缩进和标签开始
|
||||
if (buffer.isNotEmpty && !buffer.toString().endsWith('\n')) {
|
||||
buffer.writeln();
|
||||
}
|
||||
buffer.write('${' ' * indent}<');
|
||||
inTag = true;
|
||||
|
||||
// 如果不是闭合标签,增加缩进
|
||||
if (!inClosingTag) {
|
||||
indent++;
|
||||
}
|
||||
} else if (char == '>') {
|
||||
buffer.write(char);
|
||||
inTag = false;
|
||||
inClosingTag = false;
|
||||
|
||||
// 检查下一个字符,决定是否换行
|
||||
if (i < xml.length - 1) {
|
||||
final nextChar = xml[i + 1];
|
||||
if (nextChar == '<') {
|
||||
buffer.writeln();
|
||||
} else if (nextChar.trim().isNotEmpty) {
|
||||
inText = true;
|
||||
currentLine = '';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (inText) {
|
||||
currentLine += char;
|
||||
} else {
|
||||
buffer.write(char);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理最后的文本内容
|
||||
if (inText && currentLine.trim().isNotEmpty) {
|
||||
buffer.writeln('${' ' * indent}${currentLine.trim()}');
|
||||
}
|
||||
|
||||
return buffer.toString().trim();
|
||||
}
|
||||
}
|
||||
|
||||
/// 格式化后的内容
|
||||
class FormattedContent {
|
||||
const FormattedContent({
|
||||
required this.content,
|
||||
required this.type,
|
||||
required this.formatted,
|
||||
});
|
||||
|
||||
/// 原始内容
|
||||
final String content;
|
||||
|
||||
/// 内容类型
|
||||
final ContentType type;
|
||||
|
||||
/// 格式化后的内容
|
||||
final String formatted;
|
||||
}
|
||||
|
||||
/// 内容类型枚举(XML优先)
|
||||
enum ContentType {
|
||||
xml('XML'),
|
||||
json('JSON'),
|
||||
yaml('YAML'),
|
||||
markdown('Markdown'),
|
||||
plain('文本');
|
||||
|
||||
const ContentType(this.displayName);
|
||||
|
||||
final String displayName;
|
||||
}
|
||||
314
AINoval/lib/utils/context_selection_helper.dart
Normal file
314
AINoval/lib/utils/context_selection_helper.dart
Normal file
@@ -0,0 +1,314 @@
|
||||
import 'dart:convert';
|
||||
|
||||
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/utils/logger.dart';
|
||||
|
||||
/// 上下文选择助手类
|
||||
///
|
||||
/// 提供统一的上下文选择管理方法,避免在不同组件中重复实现相同逻辑
|
||||
class ContextSelectionHelper {
|
||||
|
||||
/// 初始化上下文选择数据
|
||||
///
|
||||
/// 根据提供的小说、设定、片段数据构建完整的上下文选择结构
|
||||
static ContextSelectionData initializeContextData({
|
||||
Novel? novel,
|
||||
List<NovelSettingItem>? settings,
|
||||
List<SettingGroup>? settingGroups,
|
||||
List<NovelSnippet>? snippets,
|
||||
ContextSelectionData? initialSelections,
|
||||
}) {
|
||||
//AppLogger.d('ContextSelectionHelper', '🔧 初始化上下文选择数据');
|
||||
|
||||
ContextSelectionData contextData;
|
||||
|
||||
if (novel != null) {
|
||||
// 🚀 使用小说数据构建完整的上下文选择结构
|
||||
contextData = ContextSelectionDataBuilder.fromNovelWithContext(
|
||||
novel,
|
||||
settings: settings ?? [],
|
||||
settingGroups: settingGroups ?? [],
|
||||
snippets: snippets ?? [],
|
||||
);
|
||||
//AppLogger.d('ContextSelectionHelper', '✅ 从小说构建上下文数据成功: ${contextData.availableItems.length}个可选项');
|
||||
} else {
|
||||
// 🚀 创建演示数据作为回退
|
||||
contextData = _createFallbackContextData();
|
||||
//AppLogger.d('ContextSelectionHelper', '✅ 创建回退上下文数据: ${contextData.availableItems.length}个可选项');
|
||||
}
|
||||
|
||||
// 🚀 如果有初始选择,应用到构建的数据中
|
||||
if (initialSelections != null && initialSelections.selectedCount > 0) {
|
||||
contextData = contextData.applyPresetSelections(initialSelections);
|
||||
//AppLogger.d('ContextSelectionHelper', '✅ 应用初始选择: ${contextData.selectedCount}个已选项');
|
||||
}
|
||||
|
||||
return contextData;
|
||||
}
|
||||
|
||||
/// 处理上下文选择变化
|
||||
///
|
||||
/// 这是核心方法,用于正确处理级联菜单的选择变化
|
||||
/// [currentData] 当前的上下文选择数据
|
||||
/// [newData] 从下拉菜单组件返回的新选择数据
|
||||
/// [isAddOperation] 是否为添加操作(true=添加,false=删除)
|
||||
static ContextSelectionData handleSelectionChanged(
|
||||
ContextSelectionData currentData,
|
||||
ContextSelectionData newData, {
|
||||
bool isAddOperation = true,
|
||||
}) {
|
||||
//AppLogger.d('ContextSelectionHelper', '🔄 处理上下文选择变化');
|
||||
//AppLogger.d('ContextSelectionHelper', '当前选择数: ${currentData.selectedCount}');
|
||||
//AppLogger.d('ContextSelectionHelper', '新数据选择数: ${newData.selectedCount}');
|
||||
//AppLogger.d('ContextSelectionHelper', '操作类型: ${isAddOperation ? "添加" : "删除"}');
|
||||
|
||||
// 🚀 关键修复:直接使用新的选择数据,而不是合并
|
||||
// 下拉菜单组件已经处理了选择/取消选择的逻辑,我们只需要接受结果
|
||||
|
||||
// 确保新数据具有完整的菜单结构
|
||||
if (newData.availableItems.length < currentData.availableItems.length) {
|
||||
// 如果新数据的菜单结构不完整,保持当前的菜单结构,只更新选择状态
|
||||
//AppLogger.d('ContextSelectionHelper', '🔧 修复不完整的菜单结构');
|
||||
|
||||
// 重建具有完整结构的数据
|
||||
final updatedData = currentData.copyWith(
|
||||
selectedItems: {},
|
||||
flatItems: currentData.flatItems.map(
|
||||
(key, value) => MapEntry(key, value.copyWith(selectionState: SelectionState.unselected)),
|
||||
),
|
||||
);
|
||||
|
||||
// 应用新的选择
|
||||
ContextSelectionData result = updatedData;
|
||||
for (final selectedItem in newData.selectedItems.values) {
|
||||
if (result.flatItems.containsKey(selectedItem.id)) {
|
||||
result = result.selectItem(selectedItem.id);
|
||||
}
|
||||
}
|
||||
|
||||
//AppLogger.d('ContextSelectionHelper', '✅ 选择处理完成: ${result.selectedCount}个已选项');
|
||||
return result;
|
||||
} else {
|
||||
// 菜单结构完整,直接使用新数据
|
||||
//AppLogger.d('ContextSelectionHelper', '✅ 直接使用新选择数据: ${newData.selectedCount}个已选项');
|
||||
return newData;
|
||||
}
|
||||
}
|
||||
|
||||
/// 从保存的上下文选择字符串恢复选择状态
|
||||
///
|
||||
/// [baseData] 基础的完整菜单结构数据
|
||||
/// [savedContextSelectionsData] 保存的上下文选择JSON字符串
|
||||
static ContextSelectionData restoreSelectionsFromSaved(
|
||||
ContextSelectionData baseData,
|
||||
String? savedContextSelectionsData,
|
||||
) {
|
||||
if (savedContextSelectionsData == null || savedContextSelectionsData.isEmpty) {
|
||||
//AppLogger.d('ContextSelectionHelper', '📭 没有保存的上下文选择数据');
|
||||
return baseData;
|
||||
}
|
||||
|
||||
try {
|
||||
// 🚀 解析保存的选择数据
|
||||
final savedSelections = _parseSavedContextSelections(
|
||||
savedContextSelectionsData,
|
||||
baseData.novelId,
|
||||
);
|
||||
|
||||
if (savedSelections.selectedCount > 0) {
|
||||
// 应用保存的选择到基础数据
|
||||
final restoredData = baseData.applyPresetSelections(savedSelections);
|
||||
//AppLogger.d('ContextSelectionHelper', '✅ 恢复上下文选择: ${restoredData.selectedCount}个已选项');
|
||||
return restoredData;
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.e('ContextSelectionHelper', '恢复上下文选择失败', e);
|
||||
}
|
||||
|
||||
return baseData;
|
||||
}
|
||||
|
||||
/// 解析保存的上下文选择数据
|
||||
static ContextSelectionData _parseSavedContextSelections(String savedData, String novelId) {
|
||||
try {
|
||||
// 🚀 解析JSON数据
|
||||
final jsonData = jsonDecode(savedData) as Map<String, dynamic>;
|
||||
|
||||
// 检查是否有selectedItems字段
|
||||
if (!jsonData.containsKey('selectedItems')) {
|
||||
AppLogger.w('ContextSelectionHelper', '保存的数据中没有selectedItems字段');
|
||||
return ContextSelectionData(novelId: novelId, availableItems: [], flatItems: {});
|
||||
}
|
||||
|
||||
final contextList = jsonData['selectedItems'] as List<dynamic>;
|
||||
//AppLogger.d('ContextSelectionHelper', '解析保存的上下文选择: ${contextList.length}个项目');
|
||||
|
||||
// 将已选择的项目转换为ContextSelectionItem
|
||||
final selectedItems = <String, ContextSelectionItem>{};
|
||||
final availableItems = <ContextSelectionItem>[];
|
||||
final flatItems = <String, ContextSelectionItem>{};
|
||||
|
||||
for (var itemData in contextList) {
|
||||
final item = ContextSelectionItem(
|
||||
id: itemData['id'] ?? '',
|
||||
title: itemData['title'] ?? '',
|
||||
type: ContextSelectionType.values.firstWhere(
|
||||
(type) => type.displayName == itemData['type'],
|
||||
orElse: () => ContextSelectionType.fullNovelText,
|
||||
),
|
||||
metadata: Map<String, dynamic>.from(itemData['metadata'] ?? {}),
|
||||
parentId: itemData['parentId'],
|
||||
selectionState: SelectionState.fullySelected, // 标记为已选择
|
||||
);
|
||||
|
||||
selectedItems[item.id] = item;
|
||||
availableItems.add(item);
|
||||
flatItems[item.id] = item;
|
||||
|
||||
//AppLogger.d('ContextSelectionHelper', ' ✅ ${item.type.displayName}:${item.id} (${item.title})');
|
||||
}
|
||||
|
||||
return ContextSelectionData(
|
||||
novelId: novelId,
|
||||
selectedItems: selectedItems,
|
||||
availableItems: availableItems,
|
||||
flatItems: flatItems,
|
||||
);
|
||||
} catch (e) {
|
||||
AppLogger.e('ContextSelectionHelper', '解析保存的上下文选择数据失败', e);
|
||||
return ContextSelectionData(novelId: novelId, availableItems: [], flatItems: {});
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取用于保存的上下文选择字符串
|
||||
///
|
||||
/// [contextData] 当前的上下文选择数据
|
||||
static String? getSelectionsForSave(ContextSelectionData? contextData) {
|
||||
if (contextData == null || contextData.selectedCount == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return contextData.toSaveString();
|
||||
} catch (e) {
|
||||
AppLogger.e('ContextSelectionHelper', '序列化上下文选择失败', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 清除所有选择
|
||||
///
|
||||
/// [currentData] 当前的上下文选择数据
|
||||
static ContextSelectionData clearAllSelections(ContextSelectionData currentData) {
|
||||
//AppLogger.d('ContextSelectionHelper', '🧹 清除所有上下文选择');
|
||||
|
||||
return currentData.copyWith(
|
||||
selectedItems: {},
|
||||
flatItems: currentData.flatItems.map(
|
||||
(key, value) => MapEntry(key, value.copyWith(selectionState: SelectionState.unselected)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 创建回退的上下文选择数据(用于没有小说数据的情况)
|
||||
static ContextSelectionData _createFallbackContextData() {
|
||||
final demoItems = [
|
||||
ContextSelectionItem(
|
||||
id: 'demo_full_novel',
|
||||
title: 'Full Novel Text',
|
||||
type: ContextSelectionType.fullNovelText,
|
||||
subtitle: '包含所有小说文本,这将产生费用',
|
||||
metadata: {'wordCount': 0},
|
||||
),
|
||||
ContextSelectionItem(
|
||||
id: 'demo_full_outline',
|
||||
title: 'Full Outline',
|
||||
type: ContextSelectionType.fullOutline,
|
||||
subtitle: '包含所有卷、章节和场景的完整大纲',
|
||||
metadata: {'actCount': 0, 'chapterCount': 0, 'sceneCount': 0},
|
||||
),
|
||||
];
|
||||
|
||||
final flatItems = <String, ContextSelectionItem>{};
|
||||
for (final item in demoItems) {
|
||||
flatItems[item.id] = item;
|
||||
}
|
||||
|
||||
return ContextSelectionData(
|
||||
novelId: 'demo_novel',
|
||||
availableItems: demoItems,
|
||||
flatItems: flatItems,
|
||||
);
|
||||
}
|
||||
|
||||
/// 验证上下文选择数据的完整性
|
||||
///
|
||||
/// [contextData] 要验证的上下文选择数据
|
||||
static bool validateContextData(ContextSelectionData? contextData) {
|
||||
if (contextData == null) {
|
||||
AppLogger.w('ContextSelectionHelper', '❌ 上下文数据为null');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (contextData.availableItems.isEmpty) {
|
||||
AppLogger.w('ContextSelectionHelper', '❌ 上下文数据无可用项目');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (contextData.flatItems.isEmpty) {
|
||||
AppLogger.w('ContextSelectionHelper', '❌ 上下文数据扁平化映射为空');
|
||||
return false;
|
||||
}
|
||||
|
||||
//AppLogger.d('ContextSelectionHelper', '✅ 上下文数据验证通过');
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 获取上下文选择的统计信息
|
||||
///
|
||||
/// [contextData] 上下文选择数据
|
||||
static Map<String, dynamic> getSelectionStats(ContextSelectionData? contextData) {
|
||||
if (contextData == null) {
|
||||
return {'totalItems': 0, 'selectedItems': 0, 'selectionTypes': []};
|
||||
}
|
||||
|
||||
final selectedTypes = contextData.selectedItems.values
|
||||
.map((item) => item.type.displayName)
|
||||
.toSet()
|
||||
.toList();
|
||||
|
||||
return {
|
||||
'totalItems': contextData.availableItems.length,
|
||||
'selectedItems': contextData.selectedCount,
|
||||
'selectionTypes': selectedTypes,
|
||||
'novelId': contextData.novelId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 上下文选择数据扩展方法
|
||||
extension ContextSelectionDataExt on ContextSelectionData {
|
||||
|
||||
/// 转换为保存字符串
|
||||
String toSaveString() {
|
||||
if (selectedCount == 0) return '';
|
||||
|
||||
final saveData = {
|
||||
'novelId': novelId,
|
||||
'selectedItems': selectedItems.values.map((item) => {
|
||||
'id': item.id,
|
||||
'title': item.title,
|
||||
'type': item.type.displayName,
|
||||
'metadata': item.metadata,
|
||||
}).toList(),
|
||||
};
|
||||
|
||||
return saveData.toString(); // 简化的序列化,可以根据需要使用 jsonEncode
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
70
AINoval/lib/utils/date_formatter.dart
Normal file
70
AINoval/lib/utils/date_formatter.dart
Normal file
@@ -0,0 +1,70 @@
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class DateFormatter {
|
||||
// 格式化为相对时间(如:昨天、2小时前等)
|
||||
static String formatRelative(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(date);
|
||||
|
||||
if (difference.inSeconds < 60) {
|
||||
return '刚刚';
|
||||
} else if (difference.inMinutes < 60) {
|
||||
return '${difference.inMinutes}分钟前';
|
||||
} else if (difference.inHours < 24) {
|
||||
return '${difference.inHours}小时前';
|
||||
} else if (difference.inDays < 7) {
|
||||
return '${difference.inDays}天前';
|
||||
} else if (date.year == now.year) {
|
||||
return DateFormat('MM月dd日').format(date);
|
||||
} else {
|
||||
return DateFormat('yyyy年MM月dd日').format(date);
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化为月份字符串(用于分组显示)
|
||||
static String formatMonth(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
|
||||
if (date.year == now.year && date.month == now.month) {
|
||||
return '本月';
|
||||
} else if (date.year == now.year && date.month == now.month - 1) {
|
||||
return '上个月';
|
||||
} else if (date.year == now.year) {
|
||||
return DateFormat('MM月').format(date);
|
||||
} else {
|
||||
return DateFormat('yyyy年MM月').format(date);
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化为完整日期时间
|
||||
static String formatFull(DateTime date) {
|
||||
return DateFormat('yyyy年MM月dd日 HH:mm').format(date);
|
||||
}
|
||||
|
||||
static String formatDate(DateTime date) {
|
||||
final months = ['January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
final day = date.day;
|
||||
final month = months[date.month - 1];
|
||||
final year = date.year;
|
||||
final hour = date.hour;
|
||||
final minute = date.minute.toString().padLeft(2, '0');
|
||||
final period = hour >= 12 ? 'PM' : 'AM';
|
||||
final hour12 = hour > 12 ? hour - 12 : (hour == 0 ? 12 : hour);
|
||||
|
||||
return '$month ${_getOrdinal(day)}, $year at $hour12:$minute $period';
|
||||
}
|
||||
|
||||
static String _getOrdinal(int day) {
|
||||
if (day >= 11 && day <= 13) {
|
||||
return '${day}th';
|
||||
}
|
||||
|
||||
switch (day % 10) {
|
||||
case 1: return '${day}st';
|
||||
case 2: return '${day}nd';
|
||||
case 3: return '${day}rd';
|
||||
default: return '${day}th';
|
||||
}
|
||||
}
|
||||
}
|
||||
183
AINoval/lib/utils/date_time_parser.dart
Normal file
183
AINoval/lib/utils/date_time_parser.dart
Normal file
@@ -0,0 +1,183 @@
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
|
||||
/// 解析来自后端的多种日期时间格式 (String, List, double, int, Map)
|
||||
///
|
||||
/// 支持格式:
|
||||
/// - ISO 8601 字符串 (e.g., "2024-07-30T10:00:00Z", "2024-07-30T10:00:00.123Z")
|
||||
/// - Java LocalDateTime 数组格式 [year, month, day, hour, minute, second, nanoOfSecond]
|
||||
/// - Unix 时间戳 (秒, double 类型)
|
||||
/// - Unix 时间戳 (毫秒, int 类型)
|
||||
/// - 后端API响应中的嵌套时间字段 (Map格式)
|
||||
/// - null 值安全处理
|
||||
DateTime parseBackendDateTime(dynamic dateTimeValue) {
|
||||
if (dateTimeValue == null) {
|
||||
AppLogger.w('DateTimeParser', '接收到 null 日期时间值,返回当前时间');
|
||||
return DateTime.now();
|
||||
}
|
||||
|
||||
if (dateTimeValue is String) {
|
||||
// 如果是字符串格式,支持多种ISO 8601格式
|
||||
try {
|
||||
// 先尝试标准解析
|
||||
return DateTime.parse(dateTimeValue);
|
||||
} catch (e) {
|
||||
// 尝试其他常见格式
|
||||
try {
|
||||
// 处理可能缺少时区信息的格式
|
||||
if (!dateTimeValue.contains('Z') && !dateTimeValue.contains('+') && !dateTimeValue.contains('-', 10)) {
|
||||
// 假设为本地时间,添加本地时区
|
||||
return DateTime.parse('${dateTimeValue}Z');
|
||||
}
|
||||
// 处理可能的空格分隔格式 "2024-07-30 10:00:00"
|
||||
if (dateTimeValue.contains(' ')) {
|
||||
final spacedFormat = dateTimeValue.replaceFirst(' ', 'T');
|
||||
return DateTime.parse(spacedFormat);
|
||||
}
|
||||
} catch (e2) {
|
||||
AppLogger.e('DateTimeParser', '多种格式解析均失败, 值: "$dateTimeValue"', e2);
|
||||
}
|
||||
AppLogger.e('DateTimeParser', '解析日期时间字符串失败, 值: "$dateTimeValue"', e);
|
||||
return DateTime.now(); // 解析失败时返回当前时间
|
||||
}
|
||||
} else if (dateTimeValue is Map) {
|
||||
// 处理Map格式,可能来自嵌套的API响应
|
||||
try {
|
||||
// 尝试从Map中提取时间信息
|
||||
if (dateTimeValue.containsKey('timestamp')) {
|
||||
return parseBackendDateTime(dateTimeValue['timestamp']);
|
||||
} else if (dateTimeValue.containsKey('time')) {
|
||||
return parseBackendDateTime(dateTimeValue['time']);
|
||||
} else if (dateTimeValue.containsKey('datetime')) {
|
||||
return parseBackendDateTime(dateTimeValue['datetime']);
|
||||
} else if (dateTimeValue.containsKey('createdAt')) {
|
||||
return parseBackendDateTime(dateTimeValue['createdAt']);
|
||||
} else if (dateTimeValue.containsKey('updatedAt')) {
|
||||
return parseBackendDateTime(dateTimeValue['updatedAt']);
|
||||
} else {
|
||||
// 如果Map包含year, month, day等字段,构造LocalDateTime数组
|
||||
if (dateTimeValue.containsKey('year') && dateTimeValue.containsKey('month') && dateTimeValue.containsKey('day')) {
|
||||
final year = dateTimeValue['year'] as int;
|
||||
final month = dateTimeValue['month'] as int;
|
||||
final day = dateTimeValue['day'] as int;
|
||||
final hour = (dateTimeValue['hour'] as int?) ?? 0;
|
||||
final minute = (dateTimeValue['minute'] as int?) ?? 0;
|
||||
final second = (dateTimeValue['second'] as int?) ?? 0;
|
||||
final millisecond = (dateTimeValue['millisecond'] as int?) ?? 0;
|
||||
final microsecond = (dateTimeValue['microsecond'] as int?) ?? 0;
|
||||
|
||||
return DateTime(year, month, day, hour, minute, second, millisecond, microsecond);
|
||||
}
|
||||
}
|
||||
AppLogger.w('DateTimeParser', '无法识别的Map时间格式: $dateTimeValue');
|
||||
return DateTime.now();
|
||||
} catch (e) {
|
||||
AppLogger.e('DateTimeParser', '解析Map格式时间失败, 值: $dateTimeValue', e);
|
||||
return DateTime.now();
|
||||
}
|
||||
} else if (dateTimeValue is List) {
|
||||
// 如果是Java LocalDateTime数组格式 [year, month, day, hour, minute, second, nanoOfSecond]
|
||||
try {
|
||||
// 确保列表元素足够,并进行安全转换
|
||||
final year = dateTimeValue.isNotEmpty ? (dateTimeValue[0] as num).toInt() : DateTime.now().year;
|
||||
final month = dateTimeValue.length > 1 ? (dateTimeValue[1] as num).toInt() : 1;
|
||||
final day = dateTimeValue.length > 2 ? (dateTimeValue[2] as num).toInt() : 1;
|
||||
final hour = dateTimeValue.length > 3 ? (dateTimeValue[3] as num).toInt() : 0;
|
||||
final minute = dateTimeValue.length > 4 ? (dateTimeValue[4] as num).toInt() : 0;
|
||||
final second = dateTimeValue.length > 5 ? (dateTimeValue[5] as num).toInt() : 0;
|
||||
// 可选:处理纳秒,转换为毫秒和微秒
|
||||
final nanoOfSecond = dateTimeValue.length > 6 ? (dateTimeValue[6] as num).toInt() : 0;
|
||||
final millisecond = nanoOfSecond ~/ 1000000;
|
||||
final microsecond = (nanoOfSecond % 1000000) ~/ 1000;
|
||||
|
||||
return DateTime(
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
hour,
|
||||
minute,
|
||||
second,
|
||||
millisecond,
|
||||
microsecond,
|
||||
);
|
||||
} catch (e) {
|
||||
AppLogger.e('DateTimeParser', '解析LocalDateTime数组失败, 值: $dateTimeValue', e);
|
||||
return DateTime.now(); // 解析失败时返回当前时间
|
||||
}
|
||||
} else if (dateTimeValue is double) {
|
||||
// 如果是Instant格式的时间戳(秒为单位)
|
||||
try {
|
||||
// 将秒转换为毫秒
|
||||
final milliseconds = (dateTimeValue * 1000).round();
|
||||
return DateTime.fromMillisecondsSinceEpoch(milliseconds, isUtc: false); // 假设后端时间戳是本地时间,如果确定是UTC,改为true
|
||||
} catch (e) {
|
||||
AppLogger.e('DateTimeParser', '解析Instant时间戳(double)失败, 值: $dateTimeValue', e);
|
||||
return DateTime.now();
|
||||
}
|
||||
} else if (dateTimeValue is int) {
|
||||
// 假设是毫秒时间戳
|
||||
try {
|
||||
// 检查时间戳范围,区分秒和毫秒 (一个简单的启发式方法)
|
||||
if (dateTimeValue > 3000000000) { // 大约到 2065 年的毫秒数
|
||||
return DateTime.fromMillisecondsSinceEpoch(dateTimeValue, isUtc: false); // 假设是毫秒
|
||||
} else {
|
||||
return DateTime.fromMillisecondsSinceEpoch(dateTimeValue * 1000, isUtc: false); // 假设是秒
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.e('DateTimeParser', '解析时间戳(int)失败, 值: $dateTimeValue', e);
|
||||
return DateTime.now();
|
||||
}
|
||||
} else {
|
||||
// 其他未知情况返回当前时间
|
||||
AppLogger.w('DateTimeParser', '未知的日期时间格式: $dateTimeValue (${dateTimeValue.runtimeType})');
|
||||
return DateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
/// 安全解析时间字段,专门用于处理可能为null的时间值
|
||||
DateTime? parseBackendDateTimeSafely(dynamic dateTimeValue) {
|
||||
if (dateTimeValue == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return parseBackendDateTime(dateTimeValue);
|
||||
} catch (e) {
|
||||
AppLogger.e('DateTimeParser', '安全解析时间失败, 值: $dateTimeValue', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析策略响应中的时间字段
|
||||
/// 专门处理策略管理相关API响应中的时间字段
|
||||
Map<String, dynamic> parseStrategyResponseTimestamps(Map<String, dynamic> response) {
|
||||
final parsed = Map<String, dynamic>.from(response);
|
||||
|
||||
// 常见的时间字段名称列表
|
||||
const timeFields = [
|
||||
'createdAt', 'updatedAt', 'publishedAt', 'reviewedAt',
|
||||
'submittedAt', 'approvedAt', 'rejectedAt', 'lastModifiedAt',
|
||||
'timestamp', 'time', 'date'
|
||||
];
|
||||
|
||||
for (final field in timeFields) {
|
||||
if (parsed.containsKey(field) && parsed[field] != null) {
|
||||
try {
|
||||
parsed[field] = parseBackendDateTime(parsed[field]);
|
||||
} catch (e) {
|
||||
AppLogger.w('DateTimeParser', '解析响应中的时间字段 $field 失败: ${parsed[field]}');
|
||||
// 保持原值,避免数据丢失
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/// 批量解析响应列表中的时间字段
|
||||
List<Map<String, dynamic>> parseResponseListTimestamps(List<dynamic> responseList) {
|
||||
return responseList.map((item) {
|
||||
if (item is Map<String, dynamic>) {
|
||||
return parseStrategyResponseTimestamps(item);
|
||||
}
|
||||
return item as Map<String, dynamic>;
|
||||
}).toList();
|
||||
}
|
||||
17
AINoval/lib/utils/debouncer.dart
Normal file
17
AINoval/lib/utils/debouncer.dart
Normal file
@@ -0,0 +1,17 @@
|
||||
import 'dart:async';
|
||||
|
||||
class Debouncer {
|
||||
|
||||
Debouncer({this.delay = const Duration(milliseconds: 500)});
|
||||
Timer? _timer;
|
||||
final Duration delay;
|
||||
|
||||
void run(Function() action) {
|
||||
_timer?.cancel();
|
||||
_timer = Timer(delay, action);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
}
|
||||
}
|
||||
70
AINoval/lib/utils/event_bus.dart
Normal file
70
AINoval/lib/utils/event_bus.dart
Normal file
@@ -0,0 +1,70 @@
|
||||
import 'dart:async';
|
||||
import 'package:ainoval/models/novel_snippet.dart';
|
||||
|
||||
// 事件基类
|
||||
abstract class AppEvent {
|
||||
const AppEvent();
|
||||
}
|
||||
|
||||
// 小说结构更新事件
|
||||
class NovelStructureUpdatedEvent extends AppEvent {
|
||||
final String novelId;
|
||||
final String updateType; // 'outline_saved', 'chapter_added', 'scene_added', etc.
|
||||
final Map<String, dynamic> data;
|
||||
|
||||
const NovelStructureUpdatedEvent({
|
||||
required this.novelId,
|
||||
required this.updateType,
|
||||
required this.data,
|
||||
});
|
||||
}
|
||||
|
||||
// 片段创建事件
|
||||
class SnippetCreatedEvent extends AppEvent {
|
||||
final NovelSnippet snippet;
|
||||
const SnippetCreatedEvent({required this.snippet});
|
||||
}
|
||||
|
||||
// 片段更新事件(可扩展)
|
||||
class SnippetUpdatedEvent extends AppEvent {
|
||||
final NovelSnippet snippet;
|
||||
const SnippetUpdatedEvent({required this.snippet});
|
||||
}
|
||||
|
||||
// 片段删除事件(可扩展)
|
||||
class SnippetDeletedEvent extends AppEvent {
|
||||
final String snippetId;
|
||||
final String novelId;
|
||||
const SnippetDeletedEvent({required this.snippetId, required this.novelId});
|
||||
}
|
||||
|
||||
// 事件总线单例
|
||||
class EventBus {
|
||||
// 单例实例
|
||||
static final EventBus _instance = EventBus._internal();
|
||||
static EventBus get instance => _instance;
|
||||
|
||||
// 事件流控制器
|
||||
final StreamController<AppEvent> _eventController = StreamController<AppEvent>.broadcast();
|
||||
|
||||
// 获取事件流
|
||||
Stream<AppEvent> get eventStream => _eventController.stream;
|
||||
|
||||
// 发送事件
|
||||
void fire(AppEvent event) {
|
||||
_eventController.add(event);
|
||||
}
|
||||
|
||||
// 获取特定类型的事件流
|
||||
Stream<T> on<T extends AppEvent>() {
|
||||
return eventStream.where((event) => event is T).cast<T>();
|
||||
}
|
||||
|
||||
// 私有构造函数,确保单例模式
|
||||
EventBus._internal();
|
||||
|
||||
// 关闭事件总线
|
||||
void dispose() {
|
||||
_eventController.close();
|
||||
}
|
||||
}
|
||||
218
AINoval/lib/utils/logger.dart
Normal file
218
AINoval/lib/utils/logger.dart
Normal file
@@ -0,0 +1,218 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
/// 日志级别
|
||||
enum LogLevel {
|
||||
verbose, // 详细信息
|
||||
debug, // 调试信息
|
||||
info, // 普通信息
|
||||
warning, // 警告信息
|
||||
error, // 错误信息
|
||||
wtf // 严重错误
|
||||
}
|
||||
|
||||
/// 应用程序日志管理类
|
||||
class AppLogger {
|
||||
static bool _initialized = false;
|
||||
static final Map<String, Logger> _loggers = {};
|
||||
|
||||
// 日志级别与Logging包级别的映射
|
||||
static final Map<LogLevel, Level> _levelMap = {
|
||||
LogLevel.verbose: Level.FINEST,
|
||||
LogLevel.debug: Level.FINE,
|
||||
LogLevel.info: Level.INFO,
|
||||
LogLevel.warning: Level.WARNING,
|
||||
LogLevel.error: Level.SEVERE,
|
||||
LogLevel.wtf: Level.SHOUT,
|
||||
};
|
||||
|
||||
/// 初始化日志系统
|
||||
static void init() {
|
||||
if (_initialized) return;
|
||||
|
||||
hierarchicalLoggingEnabled = true;
|
||||
|
||||
// 在调试模式下显示所有日志,在生产模式下只显示INFO级别以上
|
||||
Logger.root.level = kDebugMode ? Level.ALL : Level.INFO;
|
||||
|
||||
// 配置日志监听器
|
||||
Logger.root.onRecord.listen((record) {
|
||||
// 不在生产环境打印Verbose和Debug日志,即使 Root Level 允许
|
||||
if (!kDebugMode &&
|
||||
(record.level == Level.FINEST ||
|
||||
record.level == Level.FINER ||
|
||||
record.level == Level.FINE)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final lvlColor = _getLogLevelColor(record.level);
|
||||
const resetColor = '\x1B[0m'; // ANSI 重置颜色代码
|
||||
final emoji = _getLogEmoji(record.level);
|
||||
final timestamp = DateTime.now().toString().substring(0, 19);
|
||||
// 格式: 时间戳 [级别] [模块名] Emoji 日志内容
|
||||
final messageHeader =
|
||||
'$lvlColor$timestamp [${record.level.name}] [${record.loggerName}] $emoji $resetColor';
|
||||
final messageBody = '$lvlColor${record.message}$resetColor';
|
||||
|
||||
final String logMessage;
|
||||
|
||||
if (record.error != null) {
|
||||
// 添加错误详情和格式化的堆栈信息
|
||||
final errorString = '$lvlColor错误: ${record.error}$resetColor';
|
||||
// StackTrace 过滤:只显示应用相关的堆栈,限制行数
|
||||
final stackTraceString = _formatStackTrace(record.stackTrace,
|
||||
filterAppCode: true, maxLines: 15);
|
||||
logMessage =
|
||||
'$messageHeader $messageBody\n$errorString${stackTraceString.isNotEmpty ? '\n$lvlColor堆栈:$resetColor\n$stackTraceString' : ''}';
|
||||
} else {
|
||||
logMessage = '$messageHeader $messageBody';
|
||||
}
|
||||
|
||||
// 使用 print 输出,以便颜色代码生效
|
||||
// 在 release 版本中,由于 Logger.root.level 的限制,低于 INFO 的日志不会走到这里
|
||||
print(logMessage);
|
||||
});
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// 获取指定模块的日志记录器
|
||||
static Logger getLogger(String name) {
|
||||
if (!_initialized) init();
|
||||
|
||||
return _loggers.putIfAbsent(name, () {
|
||||
final logger = Logger(name);
|
||||
logger.level = Logger.root.level;
|
||||
return logger;
|
||||
});
|
||||
}
|
||||
|
||||
/// 记录详细日志
|
||||
static void v(String tag, String message,
|
||||
[Object? error, StackTrace? stackTrace]) {
|
||||
_log(tag, LogLevel.verbose, message, error, stackTrace);
|
||||
}
|
||||
|
||||
/// 记录调试日志
|
||||
static void d(String tag, String message,
|
||||
[Object? error, StackTrace? stackTrace]) {
|
||||
_log(tag, LogLevel.debug, message, error, stackTrace);
|
||||
}
|
||||
|
||||
/// 记录信息日志
|
||||
static void i(String tag, String message,
|
||||
[Object? error, StackTrace? stackTrace]) {
|
||||
_log(tag, LogLevel.info, message, error, stackTrace);
|
||||
}
|
||||
|
||||
/// 记录警告日志
|
||||
static void w(String tag, String message,
|
||||
[Object? error, StackTrace? stackTrace]) {
|
||||
_log(tag, LogLevel.warning, message, error, stackTrace);
|
||||
}
|
||||
|
||||
/// 记录错误日志
|
||||
static void e(String tag, String message,
|
||||
[Object? error, StackTrace? stackTrace]) {
|
||||
_log(tag, LogLevel.error, message, error, stackTrace);
|
||||
}
|
||||
|
||||
/// 记录严重错误日志
|
||||
static void wtf(String tag, String message,
|
||||
[Object? error, StackTrace? stackTrace]) {
|
||||
_log(tag, LogLevel.wtf, message, error, stackTrace);
|
||||
}
|
||||
|
||||
// 为了向后兼容,添加简化的方法名
|
||||
/// 记录信息日志(简化版)
|
||||
static void info(String tag, String message,
|
||||
[Object? error, StackTrace? stackTrace]) {
|
||||
_log(tag, LogLevel.info, message, error, stackTrace);
|
||||
}
|
||||
|
||||
/// 记录错误日志(简化版)
|
||||
static void error(String tag, String message,
|
||||
[Object? error, StackTrace? stackTrace]) {
|
||||
_log(tag, LogLevel.error, message, error, stackTrace);
|
||||
}
|
||||
|
||||
/// 内部日志记录方法
|
||||
static void _log(String tag, LogLevel level, String message,
|
||||
[Object? error, StackTrace? stackTrace]) {
|
||||
final logger = getLogger(tag);
|
||||
final logLevel = _levelMap[level]!;
|
||||
|
||||
logger.log(logLevel, message, error, stackTrace);
|
||||
}
|
||||
|
||||
/// 获取日志级别对应的emoji
|
||||
static String _getLogEmoji(Level level) {
|
||||
if (level == Level.FINEST || level == Level.FINER || level == Level.FINE) {
|
||||
return '🔍'; // 调试
|
||||
}
|
||||
if (level == Level.CONFIG || level == Level.INFO) return '📘'; // 信息
|
||||
if (level == Level.WARNING) return '⚠️'; // 警告
|
||||
if (level == Level.SEVERE) return '❌'; // 错误
|
||||
if (level == Level.SHOUT) return '💥'; // 严重错误
|
||||
return '📝'; // 默认
|
||||
}
|
||||
|
||||
/// 获取日志级别对应的ANSI颜色代码
|
||||
static String _getLogLevelColor(Level level) {
|
||||
if (level == Level.FINEST || level == Level.FINER || level == Level.FINE) {
|
||||
return '\x1B[90m'; // 灰色 (Verbose/Debug)
|
||||
}
|
||||
if (level == Level.CONFIG || level == Level.INFO) {
|
||||
return '\x1B[34m'; // 蓝色 (Info/Config)
|
||||
}
|
||||
if (level == Level.WARNING) return '\x1B[33m'; // 黄色 (Warning)
|
||||
if (level == Level.SEVERE) return '\x1B[31m'; // 红色 (Error)
|
||||
if (level == Level.SHOUT) return '\x1B[35;41m'; // 紫色 + 红色背景 (WTF/Shout)
|
||||
return '\x1B[0m'; // 默认 (重置)
|
||||
}
|
||||
|
||||
/// 格式化并过滤堆栈信息
|
||||
static String _formatStackTrace(StackTrace? stackTrace,
|
||||
{int maxLines = 10, bool filterAppCode = true}) {
|
||||
if (stackTrace == null) return '';
|
||||
|
||||
final lines = stackTrace.toString().split('\n');
|
||||
final formattedLines = <String>[];
|
||||
const appPackagePrefix = 'package:ainoval/'; // 修改为你的应用包名
|
||||
const flutterPackagePrefix = 'package:flutter/';
|
||||
const dartPrefix = 'dart:';
|
||||
|
||||
int linesAdded = 0;
|
||||
for (final line in lines) {
|
||||
final trimmedLine = line.trim();
|
||||
if (trimmedLine.isEmpty) continue;
|
||||
|
||||
bool isAppCode = trimmedLine.contains(appPackagePrefix);
|
||||
bool isFrameworkCode = trimmedLine.contains(flutterPackagePrefix) ||
|
||||
trimmedLine.startsWith(dartPrefix);
|
||||
|
||||
// 如果开启过滤,只保留应用代码;否则不过滤
|
||||
// 同时,排除纯dart:前缀和flutter框架内部调用(除非没有应用代码帧时酌情显示)
|
||||
if (!filterAppCode ||
|
||||
isAppCode ||
|
||||
(!isFrameworkCode && !trimmedLine.startsWith('#'))) {
|
||||
// 也包含一些非 package 的项目内部调用格式
|
||||
// 尝试保持可点击的格式
|
||||
// IDE 通常能识别类似 'package:my_app/my_file.dart:123:45' 的格式
|
||||
formattedLines.add(' $trimmedLine'); // 添加缩进
|
||||
linesAdded++;
|
||||
if (linesAdded >= maxLines) break; // 限制最大行数
|
||||
}
|
||||
}
|
||||
|
||||
// 如果过滤后为空(可能错误发生在框架深处),则显示原始堆栈的前几行
|
||||
if (formattedLines.isEmpty && lines.isNotEmpty) {
|
||||
formattedLines.addAll(lines
|
||||
.take(maxLines)
|
||||
.map((l) => ' ${l.trim()}')
|
||||
.where((l) => l.length > 2));
|
||||
}
|
||||
|
||||
return formattedLines.join('\n');
|
||||
}
|
||||
}
|
||||
143
AINoval/lib/utils/mock_data_generator.dart
Normal file
143
AINoval/lib/utils/mock_data_generator.dart
Normal file
@@ -0,0 +1,143 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:ainoval/models/novel_structure.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// 模拟数据生成器,用于生成符合数据结构的模拟数据
|
||||
class MockDataGenerator {
|
||||
static final Random _random = Random();
|
||||
static const Uuid _uuid = Uuid();
|
||||
|
||||
/// 生成模拟小说数据
|
||||
static Novel generateMockNovel(String id, String title) {
|
||||
final now = DateTime.now();
|
||||
|
||||
// 创建摘要
|
||||
final summary1 = Summary(
|
||||
id: 'summary_${_uuid.v4()}',
|
||||
content: 'While reading, Emperor Zhu Yijun is startled by a servant announcing Eunuch Feng\'s accidental drowning. Overwhelmed, Zhu Yijun reacts with disbelief and distress, questioning the event\'s timing, as he had recently administered poison to Feng. Upon seeing his mother, Empress Dowager Li, Zhu Yijun expresses his concern and grief. They visit Feng\'s residence, where news of Feng\'s death is confirmed, causing Zhu Yijun to dramatically faint. Physicians determine Zhu Yijun\'s collapse stems from grief and shock, and Empress Dowager Li summons Zhang Juzheng.',
|
||||
);
|
||||
|
||||
final summary2 = Summary(
|
||||
id: 'summary_${_uuid.v4()}',
|
||||
content: 'Zhang Juzheng arrives at the palace and meets with Empress Dowager Li. They discuss the suspicious circumstances of Feng\'s death and the political implications. Zhang suggests an investigation while maintaining public appearances.',
|
||||
);
|
||||
|
||||
// 创建场景
|
||||
final scene1 = Scene(
|
||||
id: 'scene_${_uuid.v4()}',
|
||||
content: '{"ops":[{"insert":"朱翊钧读完手中的奏折,正全神贯注地看着,有两滴清澈的水珠,不时还会滑下来。\\n\\n露出一抹儿呢没有挂去,龙袍穿在身上感觉很分外。\\n\\n"来人,不好了!"\\n\\n一声喊叫打破了宫中的宁静,紧接着脚步声越来越近,朝廷上下不知所措。\\n\\n朱翊钧抬眼一瞧,看到了一个嬷嬷,有些惊恐的抬起头,"出了何事。"\\n\\n转头,陛下,"太监吓得跪在地上说道:"陛下,冯公公落水了,被人从水里救上来了。"\\n\\n"什么?"朱翊钧一脸惊愕的站起身,不敢置信的追问太监的身边,"你再说一遍!"\\n\\n太监抬头一声就跪在了地上说道:"陛下,冯公公落水了。陛下不必惊慌,人已经救上来了。"\\n\\n"怎么会这样呢?怎么会这样呢?"朱翊钧一脸茫然的举起了手,"不可能!"\\n\\n这个时候,远处响起了脚步声,一个衣着华丽的女人在一群人的簇拥下走了进来。\\n\\n他们走到朱翊钧的面前,李太后问道:"孩儿这是怎么了?你可千万别信。"\\n\\n朱翊钧抬起头看了一眼母亲李太后后,十分忧心的说道:"母后,他们说冯保落水了,是不是?"\\n\\n太监抬地一声就跪在了地上说道:"陛下,冯公公落水了。陛下不必惊慌,人已经救上来了。"\\n\\n"怎么会这样呢?怎么会这样呢?"朱翊钧一脸茫然的举起了手,"不可能!"\\n"}]}',
|
||||
wordCount: 1168,
|
||||
summary: summary1,
|
||||
lastEdited: now.subtract(const Duration(days: 1)),
|
||||
);
|
||||
|
||||
final scene2 = Scene(
|
||||
id: 'scene_${_uuid.v4()}',
|
||||
content: '{"ops":[{"insert":"张居正匆匆赶到宫中,李太后已经在等候。\\n\\n"张先生,情况如何?"李太后问道。\\n\\n张居正行礼后回答:"回太后,冯公公确实溺水身亡,但死因尚不明确。"\\n\\n"这太蹊跷了,"李太后低声说,"冯保水性很好,怎会溺水?"\\n\\n"微臣也有疑虑,但现在最重要的是稳定局势,以免朝中生变。"\\n\\n李太后点头:"你说得对,先不要声张。皇上情绪很不稳定,你去看看他吧。"\\n"}]}',
|
||||
wordCount: 350,
|
||||
summary: summary2,
|
||||
lastEdited: now.subtract(const Duration(hours: 5)),
|
||||
);
|
||||
|
||||
// 创建章节
|
||||
final chapter1 = Chapter(
|
||||
id: 'chapter_${_uuid.v4()}',
|
||||
title: 'Chapter 1',
|
||||
order: 1,
|
||||
scenes: [scene1],
|
||||
);
|
||||
|
||||
final chapter2 = Chapter(
|
||||
id: 'chapter_${_uuid.v4()}',
|
||||
title: 'Chapter 2',
|
||||
order: 2,
|
||||
scenes: [scene2],
|
||||
);
|
||||
|
||||
// 创建Act
|
||||
final act1 = Act(
|
||||
id: 'act_${_uuid.v4()}',
|
||||
title: 'Act 1',
|
||||
order: 1,
|
||||
chapters: [chapter1, chapter2],
|
||||
);
|
||||
|
||||
// 创建第二个Act
|
||||
final summary3 = Summary(
|
||||
id: 'summary_${_uuid.v4()}',
|
||||
content: 'The emperor meets with his advisors to discuss the political situation after Feng\'s death. They strategize on how to maintain stability and prevent power struggles.',
|
||||
);
|
||||
|
||||
final scene3 = Scene(
|
||||
id: 'scene_${_uuid.v4()}',
|
||||
content: '{"ops":[{"insert":"朱翊钧坐在御书房中,面前站着几位重臣。\\n\\n"诸位爱卿,冯保之死已成定局,但朝中不可动荡。"朱翊钧沉声道。\\n\\n张居正拱手道:"陛下圣明。臣以为,应当尽快安排冯公公的后事,并妥善处理内廷事务,以免有人趁机生事。"\\n\\n"张先生所言极是,"申时行附和道,"内廷之事关系重大,不可有失。"\\n"}]}',
|
||||
wordCount: 420,
|
||||
summary: summary3,
|
||||
lastEdited: now.subtract(const Duration(hours: 2)),
|
||||
);
|
||||
|
||||
final chapter3 = Chapter(
|
||||
id: 'chapter_${_uuid.v4()}',
|
||||
title: 'Chapter 3',
|
||||
order: 1,
|
||||
scenes: [scene3],
|
||||
);
|
||||
|
||||
final act2 = Act(
|
||||
id: 'act_${_uuid.v4()}',
|
||||
title: 'Act 2',
|
||||
order: 2,
|
||||
chapters: [chapter3],
|
||||
);
|
||||
|
||||
// 创建小说
|
||||
return Novel(
|
||||
id: id,
|
||||
title: title,
|
||||
createdAt: now.subtract(const Duration(days: 30)),
|
||||
updatedAt: now,
|
||||
acts: [act1, act2],
|
||||
);
|
||||
}
|
||||
|
||||
/// 生成空的小说结构
|
||||
static Novel generateEmptyNovel(String id, String title) {
|
||||
final now = DateTime.now();
|
||||
|
||||
// 创建一个空的Act
|
||||
final act1 = Act(
|
||||
id: 'act_${_uuid.v4()}',
|
||||
title: 'Act 1',
|
||||
order: 1,
|
||||
chapters: [],
|
||||
);
|
||||
|
||||
// 创建小说
|
||||
return Novel(
|
||||
id: id,
|
||||
title: title,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
acts: [act1],
|
||||
);
|
||||
}
|
||||
|
||||
/// 生成一个空的场景
|
||||
static Scene generateEmptyScene() {
|
||||
final now = DateTime.now();
|
||||
|
||||
final summary = Summary(
|
||||
id: 'summary_${_uuid.v4()}',
|
||||
content: '',
|
||||
);
|
||||
|
||||
return Scene(
|
||||
id: 'scene_${_uuid.v4()}',
|
||||
content: '{"ops":[{"insert":"\\n"}]}',
|
||||
wordCount: 0,
|
||||
summary: summary,
|
||||
lastEdited: now,
|
||||
);
|
||||
}
|
||||
}
|
||||
45
AINoval/lib/utils/navigation_logger.dart
Normal file
45
AINoval/lib/utils/navigation_logger.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
|
||||
class NavigationLogger extends NavigatorObserver {
|
||||
@override
|
||||
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||||
AppLogger.i('NavigationLogger',
|
||||
'Pushed route: ${route.settings.name} | from: ${previousRoute?.settings.name}');
|
||||
_logRouteDetails(route);
|
||||
}
|
||||
|
||||
@override
|
||||
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||||
AppLogger.w('NavigationLogger',
|
||||
'Popped route: ${route.settings.name} | to: ${previousRoute?.settings.name}');
|
||||
_logRouteDetails(route);
|
||||
// Log the stack trace to find the trigger
|
||||
AppLogger.d('NavigationLogger', 'Pop stack trace: \n${StackTrace.current}');
|
||||
}
|
||||
|
||||
@override
|
||||
void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||||
AppLogger.i('NavigationLogger',
|
||||
'Removed route: ${route.settings.name} | previous: ${previousRoute?.settings.name}');
|
||||
_logRouteDetails(route);
|
||||
}
|
||||
|
||||
@override
|
||||
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
|
||||
AppLogger.i('NavigationLogger',
|
||||
'Replaced route: ${oldRoute?.settings.name} with ${newRoute?.settings.name}');
|
||||
if (newRoute != null) _logRouteDetails(newRoute);
|
||||
}
|
||||
|
||||
void _logRouteDetails(Route<dynamic> route) {
|
||||
String widgetType = "Unknown";
|
||||
if (route is MaterialPageRoute) {
|
||||
widgetType = route.builder.toString();
|
||||
} else if (route is PageRoute) {
|
||||
widgetType = route.toString();
|
||||
}
|
||||
AppLogger.d('NavigationLogger',
|
||||
'Route details: name=${route.settings.name}, arguments=${route.settings.arguments}, widget=${widgetType}');
|
||||
}
|
||||
}
|
||||
410
AINoval/lib/utils/quill_helper.dart
Normal file
410
AINoval/lib/utils/quill_helper.dart
Normal file
@@ -0,0 +1,410 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
|
||||
/// Quill富文本编辑器格式处理工具类
|
||||
///
|
||||
/// 用于统一处理Quill富文本编辑器的内容格式,确保正确转换和验证Delta格式
|
||||
class QuillHelper {
|
||||
static const String _tag = 'QuillHelper';
|
||||
|
||||
/// 确保内容是标准的Quill格式
|
||||
///
|
||||
/// 将{"ops":[...]}格式转换为更简洁的[...]格式
|
||||
/// 将非JSON文本转换为基本的Quill格式
|
||||
///
|
||||
/// @param content 输入的内容
|
||||
/// @return 标准化后的Quill Delta格式
|
||||
static String ensureQuillFormat(String content) {
|
||||
if (content.isEmpty) {
|
||||
return jsonEncode([{"insert": "\n"}]);
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查内容是否是纯文本(不是JSON格式)
|
||||
try {
|
||||
jsonDecode(content);
|
||||
} catch (e) {
|
||||
// 如果解析失败,说明是纯文本,直接转换为Delta格式
|
||||
return jsonEncode([{"insert": "$content\n"}]);
|
||||
}
|
||||
|
||||
// 尝试解析为JSON,检查是否已经是Quill格式
|
||||
final dynamic parsed = jsonDecode(content);
|
||||
|
||||
// 如果已经是数组格式,检查是否符合Quill格式要求
|
||||
if (parsed is List) {
|
||||
List<Map<String, dynamic>> ops = parsed.cast<Map<String, dynamic>>();
|
||||
bool isValidQuill = ops.isNotEmpty &&
|
||||
ops.every((item) => item is Map && (item.containsKey('insert') || item.containsKey('attributes')));
|
||||
|
||||
if (isValidQuill) {
|
||||
// 🚀 新增:检查和记录样式属性保存情况
|
||||
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 && (attributes.containsKey('color') || attributes.containsKey('background'))) {
|
||||
AppLogger.d('QuillHelper/ensureQuillFormat',
|
||||
'🎨 保存样式属性: ${attributes.keys.join(', ')}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasStyleAttributes) {
|
||||
AppLogger.i('QuillHelper/ensureQuillFormat',
|
||||
'🎨 确保包含样式属性的Quill格式,操作数量: ${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'});
|
||||
}
|
||||
}
|
||||
return jsonEncode(ops); // 返回修正后的Quill格式
|
||||
} else {
|
||||
// 转换为纯文本后重新格式化
|
||||
String plainText = _extractTextFromList(parsed);
|
||||
return jsonEncode([{"insert": "$plainText\n"}]);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是对象格式,检查是否符合Delta格式
|
||||
if (parsed is Map && parsed.containsKey('ops') && parsed['ops'] is List) {
|
||||
List<Map<String, dynamic>> ops = (parsed['ops'] as List).cast<Map<String, dynamic>>();
|
||||
|
||||
// 确保最后一个操作以换行符结尾
|
||||
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 jsonEncode(ops);
|
||||
}
|
||||
|
||||
// 其他JSON格式,转换为纯文本
|
||||
return jsonEncode([{"insert": "${jsonEncode(parsed)}\n"}]);
|
||||
} catch (e) {
|
||||
// 不是JSON格式,作为纯文本处理
|
||||
AppLogger.w('QuillHelper', '内容不是标准格式,作为纯文本处理');
|
||||
// 转义特殊字符,确保JSON格式有效
|
||||
String safeText = content
|
||||
.replaceAll('\\', '\\\\')
|
||||
.replaceAll('"', '\\"')
|
||||
.replaceAll('\n', '\\n')
|
||||
.replaceAll('\r', '\\r')
|
||||
.replaceAll('\t', '\\t');
|
||||
|
||||
return jsonEncode([{"insert": "$safeText\n"}]);
|
||||
}
|
||||
}
|
||||
|
||||
/// 将纯文本内容转换为Quill Delta格式
|
||||
///
|
||||
/// @param text 纯文本内容
|
||||
/// @return Quill Delta格式的字符串
|
||||
static String textToDelta(String text) {
|
||||
if (text.isEmpty) {
|
||||
return standardEmptyDelta;
|
||||
}
|
||||
|
||||
final String escapedText = _escapeQuillText(text);
|
||||
return '[{"insert":"$escapedText\\n"}]';
|
||||
}
|
||||
|
||||
/// 将Quill Delta格式转换为纯文本
|
||||
///
|
||||
/// @param delta Quill Delta格式的字符串
|
||||
/// @return 纯文本内容
|
||||
static String deltaToText(String deltaContent) {
|
||||
try {
|
||||
final dynamic parsed = jsonDecode(deltaContent);
|
||||
|
||||
if (parsed is List) {
|
||||
return _extractTextFromList(parsed);
|
||||
} else if (parsed is Map && parsed.containsKey('ops') && parsed['ops'] is List) {
|
||||
return _extractTextFromList(parsed['ops'] as List);
|
||||
}
|
||||
|
||||
// 如果不是标准格式,返回原始内容
|
||||
return deltaContent;
|
||||
} catch (e) {
|
||||
// 如果解析失败,返回原始内容
|
||||
return deltaContent;
|
||||
}
|
||||
}
|
||||
|
||||
/// 验证内容是否为有效的Quill格式
|
||||
///
|
||||
/// @param content 要验证的内容
|
||||
/// @return 是否为有效的Quill格式
|
||||
static bool isValidQuillFormat(String content) {
|
||||
try {
|
||||
final parsed = jsonDecode(content);
|
||||
if (parsed is List) {
|
||||
return parsed.every((item) => item is Map && item.containsKey('insert'));
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取标准的空Quill Delta格式
|
||||
static String get standardEmptyDelta => '[{"insert":"\\n"}]';
|
||||
|
||||
/// 获取包含ops的空Quill Delta格式
|
||||
static String get opsWrappedEmptyDelta => '{"ops":[{"insert":"\\n"}]}';
|
||||
|
||||
/// 转义Quill文本中的特殊字符
|
||||
static String _escapeQuillText(String text) {
|
||||
return text
|
||||
.replaceAll('\\', '\\\\')
|
||||
.replaceAll('"', '\\"')
|
||||
.replaceAll('\n', '\\n');
|
||||
}
|
||||
|
||||
/// 检测内容格式,确定是否需要转换
|
||||
///
|
||||
/// @param content 输入的内容
|
||||
/// @return 是否需要转换为标准格式
|
||||
static bool needsFormatConversion(String content) {
|
||||
if (content.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
final dynamic contentJson = jsonDecode(content);
|
||||
return contentJson is Map && contentJson.containsKey('ops');
|
||||
} catch (e) {
|
||||
return !content.startsWith('[{');
|
||||
}
|
||||
}
|
||||
|
||||
/// 计算Quill Delta内容的字数统计
|
||||
///
|
||||
/// @param delta Quill Delta格式的字符串
|
||||
/// @return 内容的字数
|
||||
static int countWords(String delta) {
|
||||
final String text = deltaToText(delta);
|
||||
if (text.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 移除所有换行符后计算字数
|
||||
final String cleanText = text.replaceAll('\n', '');
|
||||
return cleanText.length;
|
||||
}
|
||||
|
||||
/// 从List中提取文本内容
|
||||
static String _extractTextFromList(List list) {
|
||||
StringBuffer buffer = StringBuffer();
|
||||
for (var item in list) {
|
||||
if (item is Map && item.containsKey('insert')) {
|
||||
buffer.write(item['insert']);
|
||||
} else if (item is String) {
|
||||
buffer.write(item);
|
||||
} else {
|
||||
buffer.write(jsonEncode(item));
|
||||
}
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/// 将纯文本转换为Quill Delta格式
|
||||
static String convertPlainTextToQuillDelta(String text) {
|
||||
if (text.isEmpty) {
|
||||
return jsonEncode([{"insert": "\n"}]);
|
||||
}
|
||||
|
||||
// 处理换行符,确保JSON格式正确
|
||||
String safeText = text
|
||||
.replaceAll('\\', '\\\\')
|
||||
.replaceAll('"', '\\"')
|
||||
.replaceAll('\n', '\\n')
|
||||
.replaceAll('\r', '\\r')
|
||||
.replaceAll('\t', '\\t');
|
||||
|
||||
// 构建基本的Quill格式
|
||||
return jsonEncode([{"insert": "$safeText\n"}]);
|
||||
}
|
||||
|
||||
/// 验证并修复Delta格式
|
||||
///
|
||||
/// 确保Delta格式符合Flutter Quill的要求,特别是最后一个操作必须以换行符结尾
|
||||
///
|
||||
/// @param deltaJson Delta格式的JSON字符串
|
||||
/// @return 修复后的有效Delta格式
|
||||
static String validateAndFixDelta(String deltaJson) {
|
||||
if (deltaJson.isEmpty) {
|
||||
return jsonEncode([{"insert": "\n"}]);
|
||||
}
|
||||
|
||||
try {
|
||||
final dynamic parsed = jsonDecode(deltaJson);
|
||||
List<Map<String, dynamic>> ops;
|
||||
|
||||
if (parsed is List) {
|
||||
ops = parsed.cast<Map<String, dynamic>>();
|
||||
} else if (parsed is Map && parsed.containsKey('ops') && parsed['ops'] is List) {
|
||||
ops = (parsed['ops'] as List).cast<Map<String, dynamic>>();
|
||||
} else {
|
||||
// 不是有效的Delta格式,转换为纯文本
|
||||
return jsonEncode([{"insert": "$deltaJson\n"}]);
|
||||
}
|
||||
|
||||
// 确保最后一个操作以换行符结尾
|
||||
if (ops.isEmpty) {
|
||||
ops = [{"insert": "\n"}];
|
||||
} else {
|
||||
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"});
|
||||
}
|
||||
}
|
||||
|
||||
return jsonEncode(ops);
|
||||
} catch (e) {
|
||||
// 解析失败,作为纯文本处理
|
||||
AppLogger.w('QuillHelper', 'Delta验证失败,转换为纯文本: ${e.toString()}');
|
||||
return jsonEncode([{"insert": "$deltaJson\n"}]);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🚀 新增:测试样式属性的保存和解析
|
||||
///
|
||||
/// 用于验证包含颜色、背景等样式属性的内容是否能正确保存和加载
|
||||
static Map<String, dynamic> testStyleAttributeHandling() {
|
||||
final testResults = <String, dynamic>{};
|
||||
|
||||
try {
|
||||
// 测试数据:包含各种样式属性的Quill内容
|
||||
final testContents = [
|
||||
// 1. 包含背景颜色的内容
|
||||
'[{"insert":"这是红色背景的文字","attributes":{"background":"#f44336"}},{"insert":"\\n"}]',
|
||||
|
||||
// 2. 包含文字颜色的内容
|
||||
'[{"insert":"这是蓝色的文字","attributes":{"color":"#2196f3"}},{"insert":"\\n"}]',
|
||||
|
||||
// 3. 包含多种样式的内容
|
||||
'[{"insert":"粗体红色背景","attributes":{"bold":true,"background":"#f44336"}},{"insert":" 普通文字 "},{"insert":"蓝色斜体","attributes":{"color":"#2196f3","italic":true}},{"insert":"\\n"}]',
|
||||
|
||||
// 4. ops格式的内容
|
||||
'{"ops":[{"insert":"绿色背景文字","attributes":{"background":"#4caf50"}},{"insert":"\\n"}]}',
|
||||
];
|
||||
|
||||
final results = <Map<String, dynamic>>[];
|
||||
|
||||
for (int i = 0; i < testContents.length; i++) {
|
||||
final testContent = testContents[i];
|
||||
final testName = 'Test${i + 1}';
|
||||
|
||||
AppLogger.i('QuillHelper/testStyleAttributeHandling',
|
||||
'🧪 开始测试 $testName: ${testContent.length} 字符');
|
||||
|
||||
try {
|
||||
// 1. 测试ensureQuillFormat处理
|
||||
final processedContent = ensureQuillFormat(testContent);
|
||||
|
||||
// 2. 解析处理后的内容
|
||||
final parsedData = jsonDecode(processedContent);
|
||||
|
||||
// 3. 检查样式属性是否保留
|
||||
bool foundStyles = false;
|
||||
final foundAttributes = <String, dynamic>{};
|
||||
|
||||
if (parsedData is List) {
|
||||
for (final op in parsedData) {
|
||||
if (op is Map && op.containsKey('attributes')) {
|
||||
foundStyles = true;
|
||||
final attributes = op['attributes'] as Map<String, dynamic>;
|
||||
foundAttributes.addAll(attributes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.add({
|
||||
'testName': testName,
|
||||
'originalLength': testContent.length,
|
||||
'processedLength': processedContent.length,
|
||||
'foundStyles': foundStyles,
|
||||
'attributes': foundAttributes,
|
||||
'success': foundStyles,
|
||||
'originalContent': testContent.substring(0, math.min(100, testContent.length)),
|
||||
'processedContent': processedContent.substring(0, math.min(100, processedContent.length)),
|
||||
});
|
||||
|
||||
AppLogger.i('QuillHelper/testStyleAttributeHandling',
|
||||
'✅ $testName 成功: 找到样式=$foundStyles, 属性=${foundAttributes.keys.join(',')}');
|
||||
|
||||
} catch (e) {
|
||||
results.add({
|
||||
'testName': testName,
|
||||
'success': false,
|
||||
'error': e.toString(),
|
||||
});
|
||||
|
||||
AppLogger.e('QuillHelper/testStyleAttributeHandling',
|
||||
'❌ $testName 失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 汇总结果
|
||||
final successCount = results.where((r) => r['success'] == true).length;
|
||||
final totalCount = results.length;
|
||||
|
||||
testResults['summary'] = {
|
||||
'totalTests': totalCount,
|
||||
'successCount': successCount,
|
||||
'failureCount': totalCount - successCount,
|
||||
'successRate': totalCount > 0 ? (successCount / totalCount * 100).toStringAsFixed(1) + '%' : '0%',
|
||||
};
|
||||
|
||||
testResults['details'] = results;
|
||||
testResults['overallSuccess'] = successCount == totalCount;
|
||||
|
||||
AppLogger.i('QuillHelper/testStyleAttributeHandling',
|
||||
'🏁 测试完成: $successCount/$totalCount 成功 (${testResults['summary']['successRate']})');
|
||||
|
||||
} catch (e) {
|
||||
testResults['error'] = e.toString();
|
||||
testResults['overallSuccess'] = false;
|
||||
|
||||
AppLogger.e('QuillHelper/testStyleAttributeHandling',
|
||||
'💥 测试过程出错: $e');
|
||||
}
|
||||
|
||||
return testResults;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
75
AINoval/lib/utils/setting_node_utils.dart
Normal file
75
AINoval/lib/utils/setting_node_utils.dart
Normal file
@@ -0,0 +1,75 @@
|
||||
import '../models/setting_node.dart';
|
||||
|
||||
/// 设定节点工具类
|
||||
class SettingNodeUtils {
|
||||
/// 在节点树中查找节点
|
||||
static SettingNode? findNodeInTree(List<SettingNode> nodes, String id) {
|
||||
for (final node in nodes) {
|
||||
if (node.id == id) {
|
||||
return node;
|
||||
}
|
||||
if (node.children != null) {
|
||||
final found = findNodeInTree(node.children!, id);
|
||||
if (found != null) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 在节点树中查找父节点
|
||||
static SettingNode? findParentNodeInTree(List<SettingNode> nodes, String childId) {
|
||||
for (final node in nodes) {
|
||||
if (node.children != null) {
|
||||
// 检查是否是直接子节点
|
||||
for (final child in node.children!) {
|
||||
if (child.id == childId) {
|
||||
return node;
|
||||
}
|
||||
}
|
||||
// 递归检查更深层的子节点
|
||||
final found = findParentNodeInTree(node.children!, childId);
|
||||
if (found != null) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 获取可以渲染的节点ID列表(父节点为空或已渲染)
|
||||
static List<String> getRenderableNodeIds(
|
||||
List<SettingNode> rootNodes,
|
||||
List<String> renderQueue,
|
||||
Set<String> renderedNodeIds,
|
||||
) {
|
||||
final List<String> renderable = [];
|
||||
|
||||
print('🔍 [SettingNodeUtils] 检查渲染队列: ${renderQueue.length}个节点, 已渲染: ${renderedNodeIds.length}个');
|
||||
|
||||
for (final nodeId in renderQueue) {
|
||||
final node = findNodeInTree(rootNodes, nodeId);
|
||||
if (node == null) {
|
||||
print('🔍 [SettingNodeUtils] ❌ 找不到节点: $nodeId');
|
||||
continue;
|
||||
}
|
||||
|
||||
// 如果是根节点(没有父节点)或父节点已渲染,则可以渲染
|
||||
final parentNode = findParentNodeInTree(rootNodes, nodeId);
|
||||
|
||||
if (parentNode == null) {
|
||||
print('🔍 [SettingNodeUtils] ✅ 根节点可渲染: ${node.name}');
|
||||
renderable.add(nodeId);
|
||||
} else if (renderedNodeIds.contains(parentNode.id)) {
|
||||
print('🔍 [SettingNodeUtils] ✅ 父节点已渲染,子节点可渲染: ${node.name}');
|
||||
renderable.add(nodeId);
|
||||
} else {
|
||||
print('🔍 [SettingNodeUtils] ❌ 父节点未渲染: ${node.name} (需要: ${parentNode.name})');
|
||||
}
|
||||
}
|
||||
|
||||
print('🔍 [SettingNodeUtils] 最终可渲染: ${renderable.length}个节点');
|
||||
return renderable;
|
||||
}
|
||||
}
|
||||
811
AINoval/lib/utils/setting_reference_processor.dart
Normal file
811
AINoval/lib/utils/setting_reference_processor.dart
Normal file
@@ -0,0 +1,811 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:isolate';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_quill/flutter_quill.dart';
|
||||
import 'package:ainoval/models/novel_setting_item.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
/// AC自动机节点
|
||||
class _ACNode {
|
||||
Map<String, _ACNode> children = {};
|
||||
_ACNode? failure;
|
||||
List<String> outputs = [];
|
||||
|
||||
void addOutput(String settingId) {
|
||||
outputs.add(settingId);
|
||||
}
|
||||
}
|
||||
|
||||
/// Aho-Corasick 自动机
|
||||
class _AhoCorasick {
|
||||
final _ACNode root = _ACNode();
|
||||
|
||||
void build(Map<String, String> patterns) {
|
||||
// 构建 Trie
|
||||
patterns.forEach((name, settingId) {
|
||||
_ACNode current = root;
|
||||
for (int i = 0; i < name.length; i++) {
|
||||
final char = name[i];
|
||||
current.children[char] ??= _ACNode();
|
||||
current = current.children[char]!;
|
||||
}
|
||||
current.addOutput(settingId);
|
||||
});
|
||||
|
||||
// 构建失败函数
|
||||
_buildFailure();
|
||||
}
|
||||
|
||||
void _buildFailure() {
|
||||
final queue = <_ACNode>[];
|
||||
|
||||
// 第一层节点的失败函数指向根节点
|
||||
root.children.values.forEach((node) {
|
||||
node.failure = root;
|
||||
queue.add(node);
|
||||
});
|
||||
|
||||
while (queue.isNotEmpty) {
|
||||
final current = queue.removeAt(0);
|
||||
|
||||
current.children.forEach((char, child) {
|
||||
queue.add(child);
|
||||
|
||||
_ACNode? temp = current.failure;
|
||||
while (temp != null && !temp.children.containsKey(char)) {
|
||||
temp = temp.failure;
|
||||
}
|
||||
|
||||
child.failure = temp?.children[char] ?? root;
|
||||
child.outputs.addAll(child.failure!.outputs);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
List<SettingMatch> search(String text, Map<String, String> idToName) {
|
||||
final matches = <SettingMatch>[];
|
||||
_ACNode current = root;
|
||||
|
||||
for (int i = 0; i < text.length; i++) {
|
||||
final char = text[i];
|
||||
|
||||
while (current != root && !current.children.containsKey(char)) {
|
||||
current = current.failure!;
|
||||
}
|
||||
|
||||
if (current.children.containsKey(char)) {
|
||||
current = current.children[char]!;
|
||||
}
|
||||
|
||||
for (final settingId in current.outputs) {
|
||||
final name = idToName[settingId]!;
|
||||
final start = i - name.length + 1;
|
||||
matches.add(SettingMatch(
|
||||
text: name,
|
||||
start: start,
|
||||
end: i + 1,
|
||||
settingId: settingId,
|
||||
settingName: name,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
}
|
||||
|
||||
/// 设定引用处理器缓存
|
||||
class _ProcessorCache {
|
||||
int textHash = 0;
|
||||
String lastProcessedText = '';
|
||||
List<SettingMatch> lastMatches = [];
|
||||
int settingVersion = 0;
|
||||
_AhoCorasick? automaton;
|
||||
|
||||
void updateHash(String text) {
|
||||
textHash = text.hashCode;
|
||||
lastProcessedText = text;
|
||||
}
|
||||
}
|
||||
|
||||
/// 设定引用匹配结果
|
||||
class SettingMatch {
|
||||
final String text; // 匹配的文本
|
||||
final int start; // 开始位置
|
||||
final int end; // 结束位置
|
||||
final String settingId; // 设定ID
|
||||
final String settingName; // 设定名称
|
||||
|
||||
SettingMatch({
|
||||
required this.text,
|
||||
required this.start,
|
||||
required this.end,
|
||||
required this.settingId,
|
||||
required this.settingName,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => 'SettingMatch(text: "$text", pos: $start-$end, id: $settingId)';
|
||||
}
|
||||
|
||||
/// 设定引用处理器 - Flutter Quill原生实现
|
||||
/// 使用Flutter Quill的Attribute系统来实现设定引用高亮
|
||||
class SettingReferenceProcessor {
|
||||
static const String _tag = 'SettingReferenceProcessor';
|
||||
|
||||
/// 设定引用的自定义属性名(存储设定ID)
|
||||
static const String settingReferenceAttr = 'setting-reference';
|
||||
|
||||
/// 设定引用样式属性名(用于CSS选择器识别)
|
||||
static const String settingStyleAttr = 'setting-style';
|
||||
|
||||
// 🚀 三层架构:全局缓存映射
|
||||
static final Map<String, _ProcessorCache> _cacheMap = {};
|
||||
static int _globalSettingVersion = 0;
|
||||
|
||||
/// 更新全局设定版本(当设定发生变化时调用)
|
||||
static void updateSettingVersion() {
|
||||
_globalSettingVersion++;
|
||||
// 清空所有缓存的自动机,强制重建
|
||||
_cacheMap.values.forEach((cache) {
|
||||
cache.automaton = null;
|
||||
cache.settingVersion = 0;
|
||||
});
|
||||
}
|
||||
|
||||
/// 【第二层:扫描层】使用AC自动机进行高效匹配
|
||||
static List<SettingMatch> _scanForMatches(
|
||||
String sceneId,
|
||||
String text,
|
||||
List<NovelSettingItem> settings,
|
||||
) {
|
||||
final cache = _cacheMap[sceneId]!;
|
||||
|
||||
// 检查是否需要重建自动机
|
||||
if (cache.automaton == null || cache.settingVersion != _globalSettingVersion) {
|
||||
final patterns = <String, String>{};
|
||||
final idToName = <String, String>{};
|
||||
|
||||
for (final setting in settings) {
|
||||
final name = setting.name;
|
||||
final id = setting.id;
|
||||
if (name != null && name.trim().isNotEmpty && id != null && id.isNotEmpty) {
|
||||
patterns[name] = id;
|
||||
idToName[id] = name;
|
||||
}
|
||||
}
|
||||
|
||||
cache.automaton = _AhoCorasick();
|
||||
cache.automaton!.build(patterns);
|
||||
cache.settingVersion = _globalSettingVersion;
|
||||
|
||||
AppLogger.d(_tag, '重建AC自动机,设定数量: ${patterns.length}');
|
||||
}
|
||||
|
||||
// 使用自动机搜索
|
||||
final idToName = <String, String>{};
|
||||
for (final setting in settings) {
|
||||
final name = setting.name;
|
||||
final id = setting.id;
|
||||
if (name != null && id != null) {
|
||||
idToName[id] = name;
|
||||
}
|
||||
}
|
||||
|
||||
return cache.automaton!.search(text, idToName);
|
||||
}
|
||||
|
||||
/// 【第三层:修改层】异步应用样式
|
||||
static Future<void> _applyStylesAsync(
|
||||
QuillController controller,
|
||||
List<SettingMatch> matches,
|
||||
) async {
|
||||
if (matches.isEmpty) return;
|
||||
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
try {
|
||||
final originalSelection = controller.selection;
|
||||
|
||||
for (final match in matches.reversed) {
|
||||
final refAttr = Attribute(settingReferenceAttr, AttributeScope.inline, match.settingId);
|
||||
final styleAttr = Attribute(settingStyleAttr, AttributeScope.inline, 'reference');
|
||||
|
||||
controller.formatText(match.start, match.text.length, refAttr);
|
||||
controller.formatText(match.start, match.text.length, styleAttr);
|
||||
}
|
||||
|
||||
controller.updateSelection(originalSelection, ChangeSource.silent);
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '样式应用失败', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 悬停状态管理
|
||||
static String? _currentHoveredSettingId;
|
||||
static QuillController? _currentHoveringController;
|
||||
static int? _hoveredTextStart;
|
||||
static int? _hoveredTextLength;
|
||||
|
||||
/// 🎯 主要方法:处理文档中的设定引用
|
||||
/// 使用Flutter Quill原生Attribute系统添加样式
|
||||
static void processSettingReferences({
|
||||
required Document document,
|
||||
required List<NovelSettingItem> settingItems,
|
||||
required QuillController controller,
|
||||
}) {
|
||||
try {
|
||||
// 🚀 第一层:检测层 - 快速检测是否需要处理
|
||||
final currentText = document.toPlainText();
|
||||
final textHash = currentText.hashCode;
|
||||
|
||||
// 使用文档hashCode作为临时sceneId
|
||||
final sceneId = 'doc_${document.hashCode}';
|
||||
final cache = _cacheMap.putIfAbsent(sceneId, () => _ProcessorCache());
|
||||
|
||||
if (textHash == cache.textHash) {
|
||||
// 文本无变化,跳过处理
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.i(_tag, '🎯 开始三层架构设定引用处理');
|
||||
|
||||
if (settingItems.isEmpty) {
|
||||
//AppLogger.d(_tag, '无设定条目,跳过处理');
|
||||
return;
|
||||
}
|
||||
|
||||
// 🚀 第二层:扫描层 - 使用AC自动机进行高效匹配
|
||||
final matches = _scanForMatches(sceneId, currentText, settingItems);
|
||||
|
||||
// 更新缓存
|
||||
cache.updateHash(currentText);
|
||||
cache.lastMatches = matches;
|
||||
|
||||
AppLogger.i(_tag, '🎉 找到 ${matches.length} 个设定引用匹配');
|
||||
|
||||
if (matches.isEmpty) {
|
||||
//AppLogger.d(_tag, '未找到设定引用,跳过样式应用');
|
||||
return;
|
||||
}
|
||||
|
||||
// 🚀 第三层:修改层 - 异步应用样式
|
||||
_applyStylesAsync(controller, matches);
|
||||
|
||||
AppLogger.i(_tag, '✅ 设定引用处理完成');
|
||||
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '设定引用处理失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔍 查找设定匹配项
|
||||
static List<SettingMatch> findSettingMatches(String text, List<NovelSettingItem> settingItems) {
|
||||
final matches = <SettingMatch>[];
|
||||
|
||||
try {
|
||||
//AppLogger.d(_tag, '🔍 开始查找设定匹配,设定数量: ${settingItems.length}');
|
||||
|
||||
if (text.isEmpty || settingItems.isEmpty) {
|
||||
return matches;
|
||||
}
|
||||
|
||||
// 创建设定名称到ID的映射
|
||||
final settingNameToId = <String, String>{};
|
||||
for (final item in settingItems) {
|
||||
final name = item.name;
|
||||
final id = item.id;
|
||||
if (name != null && name.isNotEmpty && id != null && id.isNotEmpty) {
|
||||
settingNameToId[name] = id;
|
||||
}
|
||||
}
|
||||
|
||||
// 按长度排序设定名称,避免短名称覆盖长名称
|
||||
final sortedNames = settingNameToId.keys.toList()..sort((a, b) => b.length.compareTo(a.length));
|
||||
|
||||
//AppLogger.d(_tag, '📚 设定名称列表: ${sortedNames.join(', ')}');
|
||||
|
||||
// 🚀 调试:特别检查"小胖"是否在文本中
|
||||
final xiaoPangInText = text.contains('小胖');
|
||||
//AppLogger.d(_tag, '🔍 特别检查"小胖"是否在文本中: $xiaoPangInText');
|
||||
if (xiaoPangInText) {
|
||||
final positions = <int>[];
|
||||
int searchStart = 0;
|
||||
while (true) {
|
||||
final index = text.indexOf('小胖', searchStart);
|
||||
if (index == -1) break;
|
||||
positions.add(index);
|
||||
searchStart = index + 1;
|
||||
}
|
||||
//AppLogger.d(_tag, '🔍 "小胖"在文本中的位置: $positions');
|
||||
}
|
||||
|
||||
// 查找所有匹配
|
||||
for (final settingName in sortedNames) {
|
||||
final settingId = settingNameToId[settingName]!; // 使用!因为我们确定key存在
|
||||
|
||||
// 🚀 调试:特别关注"小胖"的处理过程
|
||||
if (settingName == '小胖') {
|
||||
//AppLogger.d(_tag, '🎯 开始处理设定"小胖", ID: $settingId');
|
||||
}
|
||||
|
||||
int searchStart = 0;
|
||||
while (true) {
|
||||
final index = text.indexOf(settingName, searchStart);
|
||||
if (index == -1) break;
|
||||
|
||||
// 🚀 调试:记录找到的位置
|
||||
if (settingName == '小胖') {
|
||||
//AppLogger.d(_tag, '🎯 找到"小胖"在位置: $index');
|
||||
}
|
||||
|
||||
// 检查是否是完整的词(可选:避免部分匹配)
|
||||
final isWordBoundary = _isWordBoundary(text, index, settingName.length);
|
||||
|
||||
// 🚀 调试:记录边界检查结果
|
||||
if (settingName == '小胖') {
|
||||
//AppLogger.d(_tag, '🎯 "小胖"边界检查结果: $isWordBoundary');
|
||||
}
|
||||
|
||||
if (isWordBoundary) {
|
||||
final match = SettingMatch(
|
||||
text: settingName,
|
||||
start: index,
|
||||
end: index + settingName.length,
|
||||
settingId: settingId,
|
||||
settingName: settingName,
|
||||
);
|
||||
|
||||
// 检查是否与已有匹配重叠
|
||||
if (!_hasOverlap(matches, match)) {
|
||||
matches.add(match);
|
||||
////AppLogger.v(_tag, '✅ 添加匹配: $match');
|
||||
} else {
|
||||
// 🚀 调试:记录重叠情况
|
||||
if (settingName == '小胖') {
|
||||
//AppLogger.d(_tag, '🎯 "小胖"匹配被跳过(与已有匹配重叠)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
searchStart = index + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 按位置排序
|
||||
matches.sort((a, b) => a.start.compareTo(b.start));
|
||||
|
||||
AppLogger.i(_tag, '🎉 总共找到 ${matches.length} 个有效匹配');
|
||||
for (final match in matches) {
|
||||
////AppLogger.v(_tag, ' 📍 ${match.settingName} (${match.start}-${match.end})');
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '查找设定匹配失败', e);
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/// 🎨 应用Flutter Quill样式
|
||||
static void _applyFlutterQuillStyles(QuillController controller, List<SettingMatch> matches) {
|
||||
if (matches.isEmpty) return;
|
||||
|
||||
final settingRefAttribute = Attribute.clone(
|
||||
Attribute.link,
|
||||
'setting_reference',
|
||||
);
|
||||
final settingStyleAttribute = Attribute.clone(
|
||||
Attribute.color,
|
||||
const Color(0xFF0066CC).value,
|
||||
);
|
||||
|
||||
try {
|
||||
// 🚀 批量应用样式,避免多次触发 document change
|
||||
final originalSelection = controller.selection;
|
||||
|
||||
// 逆序处理,避免位置偏移
|
||||
for (final match in matches.reversed) {
|
||||
controller.formatText(
|
||||
match.start,
|
||||
match.text.length,
|
||||
settingRefAttribute,
|
||||
);
|
||||
controller.formatText(
|
||||
match.start,
|
||||
match.text.length,
|
||||
settingStyleAttribute,
|
||||
);
|
||||
}
|
||||
|
||||
// 恢复原始选择
|
||||
controller.updateSelection(originalSelection, ChangeSource.silent);
|
||||
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, 'Flutter Quill样式应用失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否是完整的词边界
|
||||
static bool _isWordBoundary(String text, int start, int length) {
|
||||
// 🚀 修复:改进中文字符的词边界检查
|
||||
final before = start > 0 ? text[start - 1] : ' ';
|
||||
final after = start + length < text.length ? text[start + length] : ' ';
|
||||
|
||||
final beforeIsWord = _isWordChar(before);
|
||||
final afterIsWord = _isWordChar(after);
|
||||
|
||||
// 🚀 调试:添加详细的边界检查日志
|
||||
////AppLogger.v(_tag, '🔍 词边界检查: "${text.substring(start, start + length)}" | 前:"$before"(${beforeIsWord ? "词" : "非词"}) 后:"$after"(${afterIsWord ? "词" : "非词"})');
|
||||
|
||||
// 🚀 修复:对于中文,采用更宽松的边界检查
|
||||
// 如果前后都不是字母数字,则认为是完整的词
|
||||
return !beforeIsWord && !afterIsWord;
|
||||
}
|
||||
|
||||
/// 检查字符是否是单词字符
|
||||
static bool _isWordChar(String char) {
|
||||
if (char.isEmpty) return false;
|
||||
final code = char.codeUnitAt(0);
|
||||
|
||||
// 🚀 修复:简化单词字符判断,对中文更友好
|
||||
// 只有字母和数字才算单词字符,中文字符不算
|
||||
return (code >= 65 && code <= 90) || // A-Z
|
||||
(code >= 97 && code <= 122) || // a-z
|
||||
(code >= 48 && code <= 57); // 0-9
|
||||
// 移除中文字符判断,这样中文前后的字符不会影响匹配
|
||||
}
|
||||
|
||||
/// 检查匹配是否重叠
|
||||
static bool _hasOverlap(List<SettingMatch> existingMatches, SettingMatch newMatch) {
|
||||
for (final existing in existingMatches) {
|
||||
if ((newMatch.start < existing.end && newMatch.end > existing.start)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 🛡️ 清除格式传播,防止设定引用样式影响后续输入
|
||||
static void _clearFormattingPropagation(QuillController controller) {
|
||||
try {
|
||||
//AppLogger.d(_tag, '🛡️ 清除格式传播');
|
||||
|
||||
// 获取当前选择
|
||||
final selection = controller.selection;
|
||||
|
||||
// 🎯 简化格式传播清除逻辑
|
||||
// 不直接操作文档内容,而是通过设置光标样式状态来防止传播
|
||||
if (selection.isCollapsed) {
|
||||
final currentOffset = selection.baseOffset;
|
||||
|
||||
// 🛡️ 只在光标位置插入一个零宽字符来重置格式状态
|
||||
// 这样不会影响已经应用的设定引用样式
|
||||
try {
|
||||
// 保存当前选择
|
||||
final originalSelection = controller.selection;
|
||||
|
||||
// 临时在光标位置插入零宽空格,然后立即删除
|
||||
// 这可以重置光标位置的格式继承状态
|
||||
final zeroWidthSpace = '\u200B'; // 零宽空格
|
||||
controller.replaceText(currentOffset, 0, zeroWidthSpace, TextSelection.collapsed(offset: currentOffset + 1));
|
||||
controller.replaceText(currentOffset, 1, '', TextSelection.collapsed(offset: currentOffset));
|
||||
|
||||
// 恢复原始选择
|
||||
controller.updateSelection(originalSelection, ChangeSource.silent);
|
||||
|
||||
////AppLogger.v(_tag, '✅ 已重置光标位置的格式继承状态');
|
||||
|
||||
} catch (e) {
|
||||
AppLogger.w(_tag, '重置格式继承状态失败,使用备用方案', e);
|
||||
|
||||
// 备用方案:简单地清除当前选择的格式状态
|
||||
// 注意:这里不使用formatText,避免影响已有的设定引用样式
|
||||
////AppLogger.v(_tag, '✅ 使用备用格式传播清除方案');
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
AppLogger.w(_tag, '清除格式传播失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🎯 移除设定引用样式
|
||||
static void removeSettingReferenceStyles(QuillController controller) {
|
||||
try {
|
||||
AppLogger.i(_tag, '🗑️ 移除所有设定引用样式');
|
||||
|
||||
final document = controller.document;
|
||||
final text = document.toPlainText();
|
||||
|
||||
if (text.isEmpty) return;
|
||||
|
||||
// 移除所有设定引用相关的属性
|
||||
final removeAttributes = [
|
||||
Attribute(settingReferenceAttr, AttributeScope.inline, null),
|
||||
Attribute(settingStyleAttr, AttributeScope.inline, null),
|
||||
];
|
||||
|
||||
for (final attr in removeAttributes) {
|
||||
controller.formatText(0, text.length, attr);
|
||||
}
|
||||
|
||||
AppLogger.i(_tag, '✅ 设定引用样式移除完成');
|
||||
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '移除设定引用样式失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔄 刷新设定引用样式
|
||||
static void refreshSettingReferences({
|
||||
required QuillController controller,
|
||||
required List<NovelSettingItem> settingItems,
|
||||
}) {
|
||||
try {
|
||||
AppLogger.i(_tag, '🔄 刷新设定引用样式');
|
||||
|
||||
// 1. 先移除现有样式
|
||||
removeSettingReferenceStyles(controller);
|
||||
|
||||
// 2. 重新应用样式
|
||||
processSettingReferences(
|
||||
document: controller.document,
|
||||
settingItems: settingItems,
|
||||
controller: controller,
|
||||
);
|
||||
|
||||
AppLogger.i(_tag, '✅ 设定引用样式刷新完成');
|
||||
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '刷新设定引用样式失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🛡️ 清除光标位置的设定引用格式传播(公共方法)
|
||||
/// 应在用户输入时调用,防止设定引用样式影响新输入的文本
|
||||
static void clearFormattingPropagationAtCursor(QuillController controller) {
|
||||
_clearFormattingPropagation(controller);
|
||||
}
|
||||
|
||||
/// 🧹 用于保存时的设定引用样式过滤(保留原功能)
|
||||
static String filterSettingReferenceStylesForSave(String deltaJson, {String? caller}) {
|
||||
return filterSettingReferenceStyles(deltaJson, caller: caller ?? 'filterSettingReferenceStylesForSave');
|
||||
}
|
||||
|
||||
/// 🔄 用于编辑时的内容处理(不过滤设定引用样式)
|
||||
/// 在编辑过程中,我们要保留设定引用样式以便显示
|
||||
static String processContentForEditing(String deltaJson) {
|
||||
// 编辑时不过滤设定引用样式,直接返回原内容
|
||||
return deltaJson;
|
||||
}
|
||||
|
||||
/// 清理场景缓存
|
||||
static void clearSceneCache(String sceneId) {
|
||||
_cacheMap.remove(sceneId);
|
||||
}
|
||||
|
||||
/// 清理所有缓存
|
||||
static void clearAllCache() {
|
||||
_cacheMap.clear();
|
||||
}
|
||||
|
||||
/// 🧹 过滤设定引用相关的自定义样式,保留其他样式
|
||||
/// 用于保存时清理临时的设定引用样式,但保留用户的格式化样式
|
||||
static String filterSettingReferenceStyles(String deltaJson, {String? caller}) {
|
||||
try {
|
||||
// 🎯 优化:减少频繁日志输出,仅在调试模式或特定调用者时输出
|
||||
if (caller == null || caller == 'debug') {
|
||||
//AppLogger.d(_tag, '🧹 开始过滤设定引用样式${caller != null ? ' - 调用者: $caller' : ''}');
|
||||
}
|
||||
|
||||
// 解析Delta JSON
|
||||
final dynamic deltaData = jsonDecode(deltaJson);
|
||||
List<dynamic> ops;
|
||||
|
||||
if (deltaData is List) {
|
||||
// 格式1: 直接是ops数组 [{"insert": "text"}, ...]
|
||||
////AppLogger.v(_tag, '📋 检测到直接ops数组格式');
|
||||
ops = deltaData;
|
||||
} else if (deltaData is Map<String, dynamic>) {
|
||||
// 格式2: 标准Delta格式 {"ops": [{"insert": "text"}, ...]}
|
||||
////AppLogger.v(_tag, '📋 检测到标准Delta格式');
|
||||
final dynamic opsData = deltaData['ops'];
|
||||
|
||||
if (opsData is! List) {
|
||||
AppLogger.w(_tag, '❌ ops数据不是预期的List格式');
|
||||
return deltaJson;
|
||||
}
|
||||
ops = opsData;
|
||||
} else {
|
||||
AppLogger.w(_tag, '❌ Delta数据格式不支持: ${deltaData.runtimeType}');
|
||||
return deltaJson;
|
||||
}
|
||||
|
||||
// 过滤操作列表
|
||||
final List<dynamic> filteredOps = [];
|
||||
|
||||
for (int i = 0; i < ops.length; i++) {
|
||||
final dynamic op = ops[i];
|
||||
|
||||
// 只处理Map类型的操作
|
||||
if (op is Map<String, dynamic>) {
|
||||
// 创建新的操作副本
|
||||
final Map<String, dynamic> newOp = <String, dynamic>{};
|
||||
|
||||
// 复制所有字段
|
||||
op.forEach((key, value) {
|
||||
newOp[key] = value;
|
||||
});
|
||||
|
||||
// 检查是否有attributes字段
|
||||
if (newOp.containsKey('attributes') && newOp['attributes'] is Map) {
|
||||
final dynamic attributesData = newOp['attributes'];
|
||||
|
||||
if (attributesData is Map<String, dynamic>) {
|
||||
// 创建属性副本
|
||||
final Map<String, dynamic> attributes = <String, dynamic>{};
|
||||
attributesData.forEach((key, value) {
|
||||
attributes[key] = value;
|
||||
});
|
||||
|
||||
// 移除设定引用相关的属性
|
||||
bool hasRemovedAttrs = false;
|
||||
if (attributes.containsKey(settingReferenceAttr)) {
|
||||
attributes.remove(settingReferenceAttr);
|
||||
hasRemovedAttrs = true;
|
||||
}
|
||||
if (attributes.containsKey(settingStyleAttr)) {
|
||||
attributes.remove(settingStyleAttr);
|
||||
hasRemovedAttrs = true;
|
||||
}
|
||||
|
||||
// // 如果移除了属性,记录日志
|
||||
// if (hasRemovedAttrs) {
|
||||
// ////AppLogger.v(_tag, '🗑️ 已移除设定引用属性: op[$i]');
|
||||
// }
|
||||
|
||||
// 如果还有其他属性,保留attributes;否则移除整个attributes字段
|
||||
if (attributes.isNotEmpty) {
|
||||
newOp['attributes'] = attributes;
|
||||
} else {
|
||||
newOp.remove('attributes');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filteredOps.add(newOp);
|
||||
} else {
|
||||
// 非Map类型的操作直接保留(通常不应该发生)
|
||||
////AppLogger.v(_tag, '⚠️ 跳过非Map类型的操作: ${op.runtimeType}');
|
||||
filteredOps.add(op);
|
||||
}
|
||||
}
|
||||
|
||||
// 重新构造Delta,保持原有格式
|
||||
final dynamic filteredResult;
|
||||
if (deltaData is List) {
|
||||
// 如果原始数据是数组格式,返回数组
|
||||
filteredResult = filteredOps;
|
||||
} else {
|
||||
// 如果原始数据是标准Delta格式,返回包含ops的对象
|
||||
filteredResult = {
|
||||
'ops': filteredOps,
|
||||
};
|
||||
}
|
||||
|
||||
final String filteredJson = jsonEncode(filteredResult);
|
||||
|
||||
// 🎯 优化:减少频繁日志输出
|
||||
if (caller == null || caller == 'debug') {
|
||||
//AppLogger.d(_tag, '✅ 设定引用样式过滤完成${caller != null ? ' - 调用者: $caller' : ''}');
|
||||
////AppLogger.v(_tag, ' 原始长度: ${deltaJson.length}, 过滤后长度: ${filteredJson.length}');
|
||||
}
|
||||
|
||||
return filteredJson;
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.w(_tag, '过滤设定引用样式失败,返回原始内容', e);
|
||||
////AppLogger.v(_tag, '错误详情', e, stackTrace);
|
||||
return deltaJson; // 出错时返回原始内容
|
||||
}
|
||||
}
|
||||
|
||||
/// 🎯 处理设定引用悬停开始 - 使用精确位置(新版本,推荐使用)
|
||||
static void handleSettingReferenceHoverStartWithPosition({
|
||||
required QuillController controller,
|
||||
required String settingId,
|
||||
required int textStart,
|
||||
required int textLength,
|
||||
}) {
|
||||
try {
|
||||
//AppLogger.d(_tag, '🖱️ 开始处理设定引用悬停(使用精确位置): $settingId (位置: $textStart-${textStart + textLength})');
|
||||
|
||||
// 如果当前已有悬停状态,先清除
|
||||
if (_currentHoveredSettingId != null) {
|
||||
handleSettingReferenceHoverEnd();
|
||||
}
|
||||
|
||||
// 直接使用传递的位置信息,不再计算
|
||||
_currentHoveredSettingId = settingId;
|
||||
_currentHoveringController = controller;
|
||||
_hoveredTextStart = textStart;
|
||||
_hoveredTextLength = textLength;
|
||||
|
||||
// 添加黄色背景属性(使用Flutter Quill标准background属性)
|
||||
final hoverBackgroundAttribute = Attribute(
|
||||
'background',
|
||||
AttributeScope.inline,
|
||||
'#FFF3CD', // 浅黄色背景
|
||||
);
|
||||
|
||||
// 保存当前选择状态
|
||||
final originalSelection = controller.selection;
|
||||
|
||||
// 应用悬停背景
|
||||
controller.formatText(
|
||||
_hoveredTextStart!,
|
||||
_hoveredTextLength!,
|
||||
hoverBackgroundAttribute,
|
||||
);
|
||||
|
||||
// 恢复选择状态
|
||||
controller.updateSelection(originalSelection, ChangeSource.silent);
|
||||
|
||||
////AppLogger.v(_tag, '✅ 已添加悬停背景(精确位置): $settingId (${_hoveredTextStart}-${_hoveredTextStart! + _hoveredTextLength!})');
|
||||
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '处理设定引用悬停开始失败(精确位置): $settingId', e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🎯 处理设定引用悬停结束 - 移除黄色背景
|
||||
static void handleSettingReferenceHoverEnd() {
|
||||
try {
|
||||
if (_currentHoveredSettingId == null ||
|
||||
_currentHoveringController == null ||
|
||||
_hoveredTextStart == null ||
|
||||
_hoveredTextLength == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
//AppLogger.d(_tag, '🖱️ 结束处理设定引用悬停: $_currentHoveredSettingId');
|
||||
|
||||
// 移除悬停背景属性(使用Flutter Quill标准background属性)
|
||||
final removeHoverBackgroundAttribute = Attribute(
|
||||
'background',
|
||||
AttributeScope.inline,
|
||||
null, // null值表示移除属性
|
||||
);
|
||||
|
||||
// 保存当前选择状态
|
||||
final originalSelection = _currentHoveringController!.selection;
|
||||
|
||||
// 移除悬停背景
|
||||
_currentHoveringController!.formatText(
|
||||
_hoveredTextStart!,
|
||||
_hoveredTextLength!,
|
||||
removeHoverBackgroundAttribute,
|
||||
);
|
||||
|
||||
// 恢复选择状态
|
||||
_currentHoveringController!.updateSelection(originalSelection, ChangeSource.silent);
|
||||
|
||||
////AppLogger.v(_tag, '✅ 已移除悬停背景: $_currentHoveredSettingId');
|
||||
|
||||
// 清除悬停状态
|
||||
_currentHoveredSettingId = null;
|
||||
_currentHoveringController = null;
|
||||
_hoveredTextStart = null;
|
||||
_hoveredTextLength = null;
|
||||
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '处理设定引用悬停结束失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
1008
AINoval/lib/utils/web_theme.dart
Normal file
1008
AINoval/lib/utils/web_theme.dart
Normal file
File diff suppressed because it is too large
Load Diff
209
AINoval/lib/utils/word_count_analyzer.dart
Normal file
209
AINoval/lib/utils/word_count_analyzer.dart
Normal file
@@ -0,0 +1,209 @@
|
||||
import 'dart:convert';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:ainoval/utils/quill_helper.dart';
|
||||
|
||||
/// 字数统计信息
|
||||
class WordCountStats {
|
||||
const WordCountStats({
|
||||
required this.charactersNoSpaces,
|
||||
required this.charactersWithSpaces,
|
||||
required this.words,
|
||||
required this.paragraphs,
|
||||
required this.readTimeMinutes,
|
||||
});
|
||||
final int charactersNoSpaces;
|
||||
final int charactersWithSpaces;
|
||||
final int words;
|
||||
final int paragraphs;
|
||||
final int readTimeMinutes;
|
||||
}
|
||||
|
||||
/// 字数统计分析器
|
||||
class WordCountAnalyzer {
|
||||
static const String _tag = 'WordCountAnalyzer';
|
||||
static const int _averageReadingWordsPerMinute = 200;
|
||||
|
||||
/// 统计字数的方法
|
||||
///
|
||||
/// @param content 可能是Delta格式或纯文本的内容
|
||||
/// @return 内容的字数
|
||||
static int countWords(String? content) {
|
||||
if (content == null || content.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用QuillHelper工具类解析文本内容
|
||||
final plainText = QuillHelper.deltaToText(content);
|
||||
|
||||
// 计算字数 - 使用Unicode字符计数
|
||||
return _countUnicodeCharacters(plainText);
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '解析内容失败,尝试直接计数', e);
|
||||
// 如果解析失败,尝试直接计数
|
||||
try {
|
||||
return _countUnicodeCharacters(content);
|
||||
} catch (e2) {
|
||||
AppLogger.e(_tag, '字数统计失败,返回0', e2);
|
||||
return 0; // 完全失败时返回0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 统计基本字数信息
|
||||
///
|
||||
/// @param delta Quill Delta格式内容
|
||||
/// @return 包含字数、行数、字符数统计结果的Map
|
||||
static Map<String, int> getBasicStats(String? delta) {
|
||||
if (delta == null || delta.isEmpty) {
|
||||
return {'words': 0, 'lines': 0, 'chars': 0};
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用QuillHelper工具类解析文本内容
|
||||
final plainText = QuillHelper.deltaToText(delta);
|
||||
|
||||
// 计算字数、行数和字符数
|
||||
final int wordCount = _countUnicodeCharacters(plainText);
|
||||
final int lineCount = _countLines(plainText);
|
||||
final int charCount = plainText.length;
|
||||
|
||||
return {
|
||||
'words': wordCount,
|
||||
'lines': lineCount,
|
||||
'chars': charCount,
|
||||
};
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '解析内容失败,返回默认值', e);
|
||||
try {
|
||||
// 尝试直接对原始内容计数
|
||||
final int wordCount = _countUnicodeCharacters(delta);
|
||||
final int lineCount = _countLines(delta);
|
||||
final int charCount = delta.length;
|
||||
|
||||
return {
|
||||
'words': wordCount,
|
||||
'lines': lineCount,
|
||||
'chars': charCount,
|
||||
};
|
||||
} catch (e2) {
|
||||
AppLogger.e(_tag, '基本统计失败,返回零值', e2);
|
||||
return {'words': 0, 'lines': 0, 'chars': 0};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 分析文本并返回详细的字数统计信息
|
||||
///
|
||||
/// @param content 可能是Delta格式或纯文本的内容
|
||||
/// @return 详细的字数统计信息
|
||||
static WordCountStats analyze(String? content) {
|
||||
if (content == null || content.isEmpty) {
|
||||
return const WordCountStats(
|
||||
charactersNoSpaces: 0,
|
||||
charactersWithSpaces: 0,
|
||||
words: 0,
|
||||
paragraphs: 0,
|
||||
readTimeMinutes: 0,
|
||||
);
|
||||
}
|
||||
|
||||
// 提取纯文本
|
||||
String plainText;
|
||||
try {
|
||||
plainText = QuillHelper.deltaToText(content);
|
||||
} catch (e) {
|
||||
// 如果解析失败,假设是纯文本
|
||||
plainText = content;
|
||||
AppLogger.i(_tag, '内容格式解析失败,使用原始内容: ${e.toString()}');
|
||||
}
|
||||
|
||||
try {
|
||||
// 计算字符数(不含空格)
|
||||
final charactersNoSpaces = plainText.replaceAll(RegExp(r'\s'), '').length;
|
||||
|
||||
// 计算字符数(含空格)
|
||||
final charactersWithSpaces = plainText.length;
|
||||
|
||||
// 计算字数
|
||||
final words = _countUnicodeCharacters(plainText);
|
||||
|
||||
// 计算段落数
|
||||
final paragraphs = _countParagraphs(plainText);
|
||||
|
||||
// 估算阅读时间(假设平均每分钟阅读200个字)
|
||||
final readTimeMinutes = _calculateReadingTime(words);
|
||||
|
||||
return WordCountStats(
|
||||
charactersNoSpaces: charactersNoSpaces,
|
||||
charactersWithSpaces: charactersWithSpaces,
|
||||
words: words,
|
||||
paragraphs: paragraphs,
|
||||
readTimeMinutes: readTimeMinutes,
|
||||
);
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '字数分析失败,返回默认值', e);
|
||||
return const WordCountStats(
|
||||
charactersNoSpaces: 0,
|
||||
charactersWithSpaces: 0,
|
||||
words: 0,
|
||||
paragraphs: 0,
|
||||
readTimeMinutes: 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 统计Unicode字符数(更适合中文等非英语字符)
|
||||
static int _countUnicodeCharacters(String text) {
|
||||
if (text.isEmpty) return 0;
|
||||
|
||||
// 移除所有换行符和额外的空格
|
||||
final String cleanText = text
|
||||
.replaceAll('\n', '') // 移除换行符
|
||||
.replaceAll(RegExp(r'\s+'), ' '); // 连续空格替换为单个空格
|
||||
|
||||
// 如果清理后为空,返回0
|
||||
if (cleanText.trim().isEmpty) return 0;
|
||||
|
||||
// 返回清理后的字符串长度
|
||||
return cleanText.length;
|
||||
}
|
||||
|
||||
/// 统计行数
|
||||
static int _countLines(String text) {
|
||||
if (text.isEmpty) return 0;
|
||||
|
||||
// 计算换行符数量
|
||||
final lineCount = '\n'.allMatches(text).length;
|
||||
|
||||
// 如果文本不以换行符结尾,加1
|
||||
return text.endsWith('\n') ? lineCount : lineCount + 1;
|
||||
}
|
||||
|
||||
/// 统计段落数
|
||||
static int _countParagraphs(String text) {
|
||||
if (text.isEmpty) return 0;
|
||||
|
||||
// 按连续的换行符分割文本,并计算非空段落数
|
||||
return text.split(RegExp(r'\n+'))
|
||||
.where((p) => p.trim().isNotEmpty)
|
||||
.length;
|
||||
}
|
||||
|
||||
/// 计算阅读时间(分钟)
|
||||
///
|
||||
/// 假设平均阅读速度为每分钟200个字
|
||||
static int _calculateReadingTime(int wordCount) {
|
||||
if (wordCount <= 0) return 0;
|
||||
return (wordCount / _averageReadingWordsPerMinute).ceil();
|
||||
}
|
||||
|
||||
/// 计算阅读时间(分钟)
|
||||
///
|
||||
/// @param content 内容文本
|
||||
/// @return 估计的阅读时间(分钟)
|
||||
static int estimateReadingTime(String content) {
|
||||
final wordCount = countWords(content);
|
||||
return _calculateReadingTime(wordCount);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user