Files
MaliangAINovalWriter/AINoval/lib/screens/editor/widgets/novel_setting_detail.dart
2025-09-10 00:07:52 +08:00

2596 lines
86 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
import 'package:file_picker/file_picker.dart';
import 'dart:typed_data';
import 'dart:io';
import 'package:ainoval/blocs/setting/setting_bloc.dart';
import 'package:ainoval/models/novel_setting_item.dart';
import 'package:ainoval/models/setting_type.dart'; // 导入设定类型枚举
// import 'package:ainoval/screens/editor/widgets/floating_setting_dialogs.dart';
import 'package:ainoval/utils/logger.dart';
import 'package:ainoval/services/api_service/repositories/novel_setting_repository.dart';
import 'package:ainoval/services/api_service/repositories/storage_repository.dart';
import 'package:ainoval/screens/editor/managers/editor_layout_manager.dart';
import 'package:ainoval/widgets/common/floating_card.dart';
import 'package:ainoval/widgets/common/top_toast.dart';
import 'package:ainoval/utils/web_theme.dart';
import 'package:ainoval/widgets/setting/setting_relations_tab.dart';
import 'package:ainoval/widgets/setting/setting_tracking_tab.dart';
import 'package:ainoval/models/ai_context_tracking.dart';
import 'package:image/image.dart' as img;
/// 浮动设定详情管理器
class FloatingNovelSettingDetail {
static bool _isShowing = false;
/// 显示浮动设定详情卡片
static void show({
required BuildContext context,
String? itemId, // 若为null则表示创建新条目
required String novelId,
String? groupId, // 所属设定组ID可选
bool isEditing = false, // 是否处于编辑模式
String? prefilledDescription, // 预填充的描述内容
String? prefilledType, // 预填充的设定类型
required Function(NovelSettingItem, String?) onSave, // 保存回调第二个参数为所选组ID
required VoidCallback onCancel, // 取消回调
}) {
if (_isShowing) {
hide();
}
// 🚀 安全获取当前的 Provider 实例,添加错误处理
SettingBloc? settingBloc;
NovelSettingRepository? settingRepository;
StorageRepository? storageRepository;
try {
settingBloc = context.read<SettingBloc>();
settingRepository = context.read<NovelSettingRepository>();
storageRepository = context.read<StorageRepository>();
AppLogger.d('FloatingNovelSettingDetail', '✅ 成功获取所有必要的Provider实例');
} catch (e) {
AppLogger.e('FloatingNovelSettingDetail', '❌ 无法获取必要的Provider实例', e);
// 显示错误提示
if (context.mounted) {
TopToast.error(context, '无法打开设定详情:缺少必要的服务组件');
}
return;
}
// 获取布局信息
final layoutManager = Provider.of<EditorLayoutManager>(context, listen: false);
final sidebarWidth = layoutManager.isEditorSidebarVisible ? layoutManager.editorSidebarWidth : 0.0;
AppLogger.d('FloatingNovelSettingDetail', '显示浮动卡片,侧边栏宽度: $sidebarWidth');
// 计算卡片宽度 - 进一步优化尺寸
final screenSize = MediaQuery.of(context).size;
final cardWidth = (screenSize.width * 0.28).clamp(400.0, 600.0); // 进一步缩小并减少最大宽度
FloatingCard.show(
context: context,
position: FloatingCardPosition(
left: sidebarWidth + 16.0,
top: 60.0,
),
config: FloatingCardConfig(
width: cardWidth,
// 移除 height 参数,让内容自适应高度
maxHeight: screenSize.height * 0.85, // 增加可用高度
showCloseButton: false,
enableBackgroundTap: false,
animationDuration: const Duration(milliseconds: 300),
animationCurve: Curves.easeOutCubic,
borderRadius: BorderRadius.circular(12),
padding: EdgeInsets.zero,
backgroundColor: WebTheme.getBackgroundColor(context),
),
child: MultiProvider(
providers: [
BlocProvider<SettingBloc>.value(value: settingBloc),
Provider<NovelSettingRepository>.value(value: settingRepository),
Provider<StorageRepository>.value(value: storageRepository),
],
child: _NovelSettingDetailContent(
itemId: itemId,
novelId: novelId,
groupId: groupId,
isEditing: isEditing,
prefilledDescription: prefilledDescription,
prefilledType: prefilledType,
onSave: onSave,
onCancel: () {
onCancel();
hide();
},
),
),
onClose: () {
onCancel();
hide();
},
);
_isShowing = true;
}
/// 隐藏浮动卡片
static void hide() {
if (_isShowing) {
FloatingCard.hide();
_isShowing = false;
}
}
/// 检查是否正在显示
static bool get isShowing => _isShowing;
}
/// 小说设定条目详情和编辑组件
class _NovelSettingDetailContent extends StatefulWidget {
final String? itemId; // 若为null则表示创建新条目
final String novelId;
final String? groupId; // 所属设定组ID可选
final bool isEditing; // 是否处于编辑模式
final String? prefilledDescription; // 预填充的描述内容
final String? prefilledType; // 预填充的设定类型
final Function(NovelSettingItem, String?) onSave; // 保存回调第二个参数为所选组ID
final VoidCallback onCancel; // 取消回调
const _NovelSettingDetailContent({
Key? key,
this.itemId,
required this.novelId,
this.groupId,
this.isEditing = false,
this.prefilledDescription,
this.prefilledType,
required this.onSave,
required this.onCancel,
}) : super(key: key);
@override
State<_NovelSettingDetailContent> createState() => _NovelSettingDetailContentState();
}
class _NovelSettingDetailContentState extends State<_NovelSettingDetailContent> with TickerProviderStateMixin {
final _formKey = GlobalKey<FormState>();
// 表单控制器
final _nameController = TextEditingController();
final _descriptionController = TextEditingController();
final _aliasesController = TextEditingController();
// 新增:标签控制器
final _tagsController = TextEditingController();
// 新增:属性列表
final List<MapEntry<String, String>> _attributes = [];
// 设定条目数据
NovelSettingItem? _settingItem;
// 选择的类型 - 使用displayName
String? _selectedType;
// 选择的设定组ID
String? _selectedGroupId;
// 类型选项 - 使用枚举获取,确保没有重复
late final List<String> _typeOptions = SettingType.values
.map((type) => type.displayName)
.toSet() // 去重
.toList();
// 加载状态
bool _isLoading = true;
bool _isSaving = false;
// 标签页控制器
late TabController _tabController;
// 是否固定Pin
// bool _isPinned = false;
// 图片相关状态
bool _isImageHovered = false;
bool _isImageUploading = false;
String? _imageUrl;
// 下拉菜单状态
bool _isDropdownOpen = false;
final GlobalKey _dropdownKey = GlobalKey();
OverlayEntry? _dropdownOverlayEntry;
// 设定组下拉菜单状态
bool _isGroupDropdownOpen = false;
final GlobalKey _groupDropdownKey = GlobalKey();
OverlayEntry? _groupDropdownOverlayEntry;
@override
void initState() {
super.initState();
// 初始化标签页控制器
_tabController = TabController(length: 5, vsync: this);
// 加载设定组列表(仅当尚未成功加载过时)
final settingState = context.read<SettingBloc>().state;
if (settingState.groupsStatus != SettingStatus.success) {
AppLogger.i('FloatingNovelSettingDetail', '加载设定组(当前状态: ${settingState.groupsStatus}');
context.read<SettingBloc>().add(LoadSettingGroups(widget.novelId));
} else {
AppLogger.d('FloatingNovelSettingDetail', '跳过加载设定组,已成功加载(数量: ${settingState.groups.length}');
}
if (widget.itemId != null) {
_loadSettingItem();
} else {
// 创建新条目
setState(() {
_isLoading = false;
// 使用预填充的类型,如果没有则默认为角色
if (widget.prefilledType != null) {
final prefilledTypeEnum = SettingType.fromValue(widget.prefilledType!);
_selectedType = prefilledTypeEnum.displayName;
} else {
_selectedType = SettingType.character.displayName; // 使用displayName而不是数组索引
}
_selectedGroupId = widget.groupId; // 初始化选择的组ID
// 如果有预填充的描述内容,设置到描述字段
if (widget.prefilledDescription != null) {
_descriptionController.text = widget.prefilledDescription!;
}
// 如果没有传入 groupId但有可用的设定组默认选择第一个设定组
if (_selectedGroupId == null) {
final settingState = context.read<SettingBloc>().state;
if (settingState.groups.isNotEmpty) {
_selectedGroupId = settingState.groups.first.id;
}
}
});
}
}
@override
void dispose() {
// 清理下拉菜单overlay
_dropdownOverlayEntry?.remove();
_dropdownOverlayEntry = null;
_groupDropdownOverlayEntry?.remove();
_groupDropdownOverlayEntry = null;
_nameController.dispose();
_descriptionController.dispose();
_aliasesController.dispose();
_tagsController.dispose();
_tabController.dispose();
super.dispose();
}
// 加载设定条目详情
Future<void> _loadSettingItem() async {
setState(() {
_isLoading = true;
});
try {
// 从SettingBloc中查找设定条目
final settingBloc = context.read<SettingBloc>();
final state = settingBloc.state;
// 如果当前状态中有该条目,直接使用
if (state.items.isNotEmpty) {
final itemIndex = state.items.indexWhere((item) => item.id == widget.itemId);
if (itemIndex >= 0) {
_settingItem = state.items[itemIndex];
_initializeForm();
setState(() {
_isLoading = false;
});
return;
}
}
// 如果Bloc中找不到数据则请求详细数据
try {
final settingRepository = context.read<NovelSettingRepository>();
final item = await settingRepository.getSettingItemDetail(
novelId: widget.novelId,
itemId: widget.itemId!,
);
_settingItem = item;
// 不要在仅查看详情时触发全局更新或远程更新,避免引发全局重建
// 如需缓存到本地状态,可在未来添加专门的本地缓存事件
} catch (e) {
AppLogger.e('NovelSettingDetail', '从API加载设定条目详情失败', e);
// 如果API请求也失败使用默认值
_settingItem = NovelSettingItem(
id: widget.itemId,
novelId: widget.novelId,
name: "加载失败",
type: "OTHER",
content: "无法加载该设定条目数据",
);
}
// 初始化表单
_initializeForm();
setState(() {
_isLoading = false;
});
} catch (e) {
AppLogger.e('NovelSettingDetail', '加载设定条目详情失败', e);
setState(() {
_isLoading = false;
});
}
}
// 初始化表单
void _initializeForm() {
if (_settingItem == null) return;
_nameController.text = _settingItem!.name;
_descriptionController.text = (_settingItem!.description ?? _settingItem!.content)!;
// 初始化标签
if (_settingItem!.tags != null && _settingItem!.tags!.isNotEmpty) {
_tagsController.text = _settingItem!.tags!.join(', ');
}
// 初始化属性
_attributes.clear();
if (_settingItem!.attributes != null) {
_attributes.addAll(_settingItem!.attributes!.entries.toList());
}
// 修复类型初始化 - 确保使用displayName
final settingTypeEnum = SettingType.fromValue(_settingItem!.type ?? 'OTHER');
_selectedType = settingTypeEnum.displayName;
_selectedGroupId = widget.groupId; // 如果有传入groupId将其设为默认选择
if (_selectedGroupId == null && _settingItem!.id != null) {
// 未传入 groupId 时,尝试从当前状态反查所属组,改善“按类型视图打开详情”的体验
try {
final settingState = context.read<SettingBloc>().state;
for (final group in settingState.groups) {
if (group.itemIds != null && group.itemIds!.contains(_settingItem!.id)) {
_selectedGroupId = group.id;
break;
}
}
} catch (e) {
AppLogger.w('NovelSettingDetail', '初始化反查所属组失败', e);
}
}
// 初始化图片URL
_imageUrl = _settingItem!.imageUrl;
}
// 保存设定条目
Future<void> _saveSettingItem() async {
// 安全检查表单状态
if (_formKey.currentState?.validate() != true) {
AppLogger.w('NovelSettingDetail', '表单验证失败,无法保存');
// 显示错误提示
if (mounted) {
TopToast.error(context, '请检查输入内容是否正确');
}
return;
}
setState(() {
_isSaving = true;
});
AppLogger.d('NovelSettingDetail', '开始保存设定条目itemId: ${widget.itemId}');
try {
// 获取选择的类型枚举 - 使用displayName转换
final typeEnum = _getTypeEnumFromDisplayName(_selectedType ?? SettingType.character.displayName);
// 处理标签
List<String>? tags;
if (_tagsController.text.isNotEmpty) {
tags = _tagsController.text.split(',')
.map((tag) => tag.trim())
.where((tag) => tag.isNotEmpty)
.toList();
}
// 转换属性为Map
Map<String, String>? attributes;
if (_attributes.isNotEmpty) {
attributes = Map.fromEntries(_attributes);
}
// 构建设定条目对象
final settingItem = NovelSettingItem(
id: widget.itemId,
novelId: widget.novelId,
type: typeEnum.value, // 保存value值而不是displayName
name: _nameController.text,
content: "",
description: _descriptionController.text,
attributes: attributes,
tags: tags,
relationships: _settingItem?.relationships,
generatedBy: _settingItem?.generatedBy,
imageUrl: _imageUrl, // 使用更新的图片URL
sceneIds: _settingItem?.sceneIds,
priority: _settingItem?.priority,
status: _settingItem?.status,
isAiSuggestion: _settingItem?.isAiSuggestion ?? false,
nameAliasTracking: _settingItem?.nameAliasTracking ?? NameAliasTracking.track,
aiContextTracking: _settingItem?.aiContextTracking ?? AIContextTracking.detected,
referenceUpdatePolicy: _settingItem?.referenceUpdatePolicy ?? SettingReferenceUpdate.ask,
);
// 记录所选的组ID
final String? selectedGroupId = _selectedGroupId ?? widget.groupId;
AppLogger.i('NovelSettingDetail',
'保存设定条目: ${settingItem.name}, 类型: ${typeEnum.value}, '
'选择的组ID: ${selectedGroupId ?? ""}'
);
// 先更新本地状态,立即反馈给用户
setState(() {
_settingItem = settingItem;
_isSaving = false;
});
// 通知父组件并触发后端保存
widget.onSave(settingItem, selectedGroupId);
// 显示成功提示
if (mounted) {
TopToast.success(context, widget.itemId == null ? '设定条目创建成功' : '设定条目保存成功');
}
} catch (e) {
AppLogger.e('NovelSettingDetail', '保存设定条目失败', e);
// 显示错误提示
if (mounted) {
TopToast.error(context, '保存失败: ${e.toString()}');
}
setState(() {
_isSaving = false;
});
}
}
// 保存并关闭
Future<void> _saveAndClose() async {
await _saveSettingItem();
if (!_isSaving) {
// 只有在保存成功(不在保存状态)时才关闭
FloatingNovelSettingDetail.hide();
}
}
// 移除属性
void _removeAttribute(String key) {
setState(() {
_attributes.removeWhere((entry) => entry.key == key);
});
}
// 显示添加属性对话框
void _showAddAttributeDialog(bool isDark) {
final keyController = TextEditingController();
final valueController = TextEditingController();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('添加属性'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: keyController,
decoration: const InputDecoration(
labelText: '属性名称',
hintText: '例如:身高、年龄等',
),
),
const SizedBox(height: 16),
TextField(
controller: valueController,
decoration: const InputDecoration(
labelText: '属性值',
hintText: '例如180cm、25岁等',
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('取消'),
),
ElevatedButton(
onPressed: () {
final key = keyController.text.trim();
final value = valueController.text.trim();
if (key.isNotEmpty && value.isNotEmpty) {
setState(() {
// 检查是否已存在相同键名
_attributes.removeWhere((entry) => entry.key == key);
_attributes.add(MapEntry(key, value));
});
Navigator.of(context).pop();
}
},
child: const Text('添加'),
),
],
),
);
}
// 添加关系
// void _addRelationship() {}
// 删除关系
// void _deleteRelationship(String targetItemId, String relationshipType) {}
@override
Widget build(BuildContext context) {
final isDark = WebTheme.isDarkMode(context);
if (_isLoading) {
return Container(
decoration: BoxDecoration(
color: isDark ? WebTheme.darkGrey900 : WebTheme.getBackgroundColor(context),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: WebTheme.getShadowColor(context, opacity: 0.1),
blurRadius: 20,
offset: const Offset(0, 4),
),
],
),
child: const Center(
child: CircularProgressIndicator(),
),
);
}
return Container(
decoration: BoxDecoration(
color: isDark ? WebTheme.darkGrey900 : WebTheme.getBackgroundColor(context),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: WebTheme.getShadowColor(context, opacity: 0.1),
blurRadius: 20,
offset: const Offset(0, 4),
),
],
border: Border.all(
color: isDark ? WebTheme.darkGrey800 : WebTheme.grey200,
width: 2,
),
),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 头部区域(类型、标题、图片)
_buildHeaderSection(isDark),
// 进度条/分割线
_buildProgressSection(isDark),
// 标签页
_buildTabSection(isDark),
// 标签页内容 - 固定合理高度
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildDetailsTab(isDark),
_buildResearchTab(isDark),
_buildRelationsTab(isDark),
_buildMentionsTab(isDark),
_buildTrackingTab(isDark),
],
),
),
// 底部操作按钮区域
_buildActionButtons(isDark),
],
),
),
);
}
// 构建头部区域
Widget _buildHeaderSection(bool isDark) {
return Container(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 0), // 进一步缩小padding
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 左侧内容区域
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 类型下拉菜单和设定组选择 - 并排显示
_buildTypeAndGroupRow(isDark),
const SizedBox(height: 6), // 缩小间距
// 标题输入框
_buildTitleInput(isDark),
const SizedBox(height: 8), // 增加间距避免重叠
// 标签/别名输入
_buildTagsInput(isDark),
],
),
),
const SizedBox(width: 12), // 缩小间距
// 右侧图片区域
_buildImageSection(isDark),
],
),
],
),
);
}
// 构建类型和设定组并排显示区域
Widget _buildTypeAndGroupRow(bool isDark) {
return Row(
children: [
// 类型下拉菜单
_buildTypeDropdown(isDark),
const SizedBox(width: 8),
// 设定组选择
_buildGroupDropdownCompact(isDark),
],
);
}
// 构建类型下拉菜单 - 使用简化的自定义实现
Widget _buildTypeDropdown(bool isDark) {
// 确保_selectedType在_typeOptions中
if (_selectedType == null || !_typeOptions.contains(_selectedType)) {
_selectedType = _typeOptions.isNotEmpty ? _typeOptions.first : SettingType.character.displayName;
}
return GestureDetector(
onTap: () => _toggleDropdown(isDark),
child: Container(
key: _dropdownKey,
height: 24,
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: WebTheme.getSurfaceColor(context), // 使用动态表面色
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: isDark ? WebTheme.darkGrey700 : WebTheme.grey300,
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_getTypeIconData(_getTypeEnumFromDisplayName(_selectedType!)),
size: 10,
color: WebTheme.getTextColor(context),
),
const SizedBox(width: 3),
Text(
_selectedType!,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: WebTheme.getTextColor(context),
),
),
const SizedBox(width: 3),
Icon(
_isDropdownOpen ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down,
size: 12,
color: WebTheme.getTextColor(context),
),
],
),
),
);
}
// 切换下拉菜单
void _toggleDropdown(bool isDark) {
if (_isDropdownOpen) {
// 如果菜单已打开,关闭它
_hideDropdown();
} else {
// 打开菜单
_showCustomDropdown(isDark);
}
}
// 隐藏下拉菜单
void _hideDropdown() {
_dropdownOverlayEntry?.remove();
_dropdownOverlayEntry = null;
setState(() {
_isDropdownOpen = false;
});
}
// 计算下拉菜单的水平位置,确保不超出屏幕
double _calculateMenuLeft(double buttonLeft, double screenWidth) {
const menuWidth = 200.0;
// 如果菜单会超出右边界,调整位置
if (buttonLeft + menuWidth > screenWidth) {
return screenWidth - menuWidth - 16; // 留16px边距
}
// 确保不超出左边界
return buttonLeft.clamp(16.0, screenWidth - menuWidth - 16);
}
// 计算下拉菜单的垂直位置,确保不超出屏幕
double _calculateMenuTop(double buttonTop, double buttonHeight, double screenHeight) {
const menuMaxHeight = 250.0; // 与约束中的maxHeight保持一致
const spacing = 2.0;
final preferredTop = buttonTop + buttonHeight + spacing;
// 如果菜单会超出下边界,显示在按钮上方
if (preferredTop + menuMaxHeight > screenHeight - 50) {
return (buttonTop - menuMaxHeight - spacing).clamp(50.0, screenHeight - menuMaxHeight - 50);
}
return preferredTop;
}
// 显示自定义下拉菜单
void _showCustomDropdown(bool isDark) {
// 使用GlobalKey获取按钮的准确位置
final RenderBox? renderBox = _dropdownKey.currentContext?.findRenderObject() as RenderBox?;
if (renderBox == null) return;
// 获取相对于整个屏幕的全局位置
final Offset globalOffset = renderBox.localToGlobal(Offset.zero);
final Size buttonSize = renderBox.size;
// 获取屏幕尺寸
final screenSize = MediaQuery.of(context).size;
// 如果已有下拉菜单,先关闭
if (_dropdownOverlayEntry != null) {
_hideDropdown();
return;
}
setState(() {
_isDropdownOpen = true;
});
// 使用Overlay直接显示下拉菜单确保显示在最顶层
_dropdownOverlayEntry = OverlayEntry(
builder: (context) => Stack(
children: [
// 背景遮罩,点击关闭下拉菜单
Positioned.fill(
child: GestureDetector(
onTap: () {
_hideDropdown();
},
child: Container(
color: Colors.transparent,
),
),
),
// 下拉菜单
Positioned(
left: _calculateMenuLeft(globalOffset.dx, screenSize.width),
top: _calculateMenuTop(globalOffset.dy, buttonSize.height, screenSize.height),
child: Material(
elevation: 8,
borderRadius: BorderRadius.circular(8),
color: WebTheme.getSurfaceColor(context),
shadowColor: WebTheme.getShadowColor(context, opacity: 0.3),
child: Container(
width: 200,
constraints: BoxConstraints(
maxWidth: screenSize.width * 0.8,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isDark ? WebTheme.darkGrey600 : WebTheme.grey300,
width: 1,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ConstrainedBox(
constraints: const BoxConstraints(
maxHeight: 250, // 限制最大高度,避免溢出
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: _typeOptions.map((typeDisplayName) {
final isSelected = typeDisplayName == _selectedType;
return InkWell(
onTap: () {
_hideDropdown();
if (typeDisplayName != _selectedType) {
setState(() {
_selectedType = typeDisplayName;
});
}
},
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: isSelected
? (isDark ? WebTheme.darkGrey700 : WebTheme.grey100)
: Colors.transparent,
),
child: Row(
children: [
Icon(
_getTypeIconData(_getTypeEnumFromDisplayName(typeDisplayName)),
size: 16,
color: isSelected
? (isDark ? WebTheme.grey200 : WebTheme.grey900)
: (isDark ? WebTheme.grey400 : WebTheme.grey700),
),
const SizedBox(width: 8),
Expanded(
child: Text(
typeDisplayName,
style: TextStyle(
fontSize: 13,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
color: isSelected
? (isDark ? WebTheme.grey200 : WebTheme.grey900)
: (isDark ? WebTheme.grey300 : WebTheme.grey700),
),
),
),
],
),
),
);
}).toList(),
),
),
),
),
),
),
),
],
),
);
// 插入到Overlay中
Overlay.of(context).insert(_dropdownOverlayEntry!);
}
// 构建紧凑型设定组下拉菜单 - 与类型下拉菜单样式保持一致
Widget _buildGroupDropdownCompact(bool isDark) {
return BlocBuilder<SettingBloc, SettingState>(
builder: (context, state) {
final groups = state.groups;
// 构建选项列表,包含无分组选项
final groupOptions = <Map<String, dynamic>>[
{'id': null, 'name': '无分组'},
...groups.map((group) => {
'id': group.id,
'name': group.name,
}),
];
// 确保当前选择的组ID在选项列表中
if (_selectedGroupId != null &&
!groupOptions.any((option) => option['id'] == _selectedGroupId)) {
_selectedGroupId = null;
}
// 查找当前选择的组名
final selectedOption = groupOptions.firstWhere(
(option) => option['id'] == _selectedGroupId,
orElse: () => {'id': null, 'name': '无分组'},
);
final selectedGroupName = selectedOption['name'] as String;
return GestureDetector(
onTap: () => _toggleGroupDropdown(isDark, groupOptions),
child: Container(
key: _groupDropdownKey,
height: 28,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: WebTheme.getSurfaceColor(context), // 使用动态表面色
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: isDark ? WebTheme.darkGrey700 : WebTheme.grey300,
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_selectedGroupId == null ? Icons.folder_off : Icons.folder,
size: 12,
color: WebTheme.getTextColor(context),
),
const SizedBox(width: 4),
Text(
selectedGroupName,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: WebTheme.getTextColor(context),
),
),
const SizedBox(width: 4),
Icon(
_isGroupDropdownOpen ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down,
size: 14,
color: WebTheme.getTextColor(context),
),
],
),
),
);
},
);
}
// 构建设定组选择 - 使用与类型下拉框相同的实现方式(保留原版本作为备用)
/* Widget _buildGroupSelection(bool isDark) {
return BlocBuilder<SettingBloc, SettingState>(
builder: (context, state) {
final groups = state.groups;
// 构建选项列表,包含无分组选项
final groupOptions = <Map<String, dynamic>>[
{'id': null, 'name': '无分组'},
...groups.map((group) => {
'id': group.id,
'name': group.name ?? '未命名组', // 防止组名为null
}),
];
// 确保当前选择的组ID在选项列表中
if (_selectedGroupId != null &&
!groupOptions.any((option) => option['id'] == _selectedGroupId)) {
_selectedGroupId = null; // 如果选择的组不存在,重置为无分组
}
// 查找当前选择的组名
final selectedOption = groupOptions.firstWhere(
(option) => option['id'] == _selectedGroupId,
orElse: () => {'id': null, 'name': '无分组'},
);
final selectedGroupName = selectedOption['name'] as String;
return Container(
height: 30,
child: Row(
children: [
Icon(
Icons.folder_outlined,
size: 12,
color: WebTheme.getSecondaryTextColor(context),
),
const SizedBox(width: 4),
Expanded(
child: GestureDetector(
onTap: () => _toggleGroupDropdown(isDark, groupOptions),
child: Container(
key: _groupDropdownKey,
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: Colors.transparent,
width: 1,
),
),
child: Row(
children: [
Expanded(
child: Text(
selectedGroupName,
style: TextStyle(
fontSize: 11,
color: _selectedGroupId == null
? WebTheme.getSecondaryTextColor(context).withOpacity(0.6)
: WebTheme.getSecondaryTextColor(context),
),
),
),
Icon(
_isGroupDropdownOpen ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down,
size: 14,
color: WebTheme.getSecondaryTextColor(context),
),
],
),
),
),
),
],
),
);
},
);
} */
// 切换设定组下拉菜单
void _toggleGroupDropdown(bool isDark, List<Map<String, dynamic>> groupOptions) {
if (_isGroupDropdownOpen) {
// 如果菜单已打开,关闭它
_hideGroupDropdown();
} else {
// 打开菜单
_showGroupCustomDropdown(isDark, groupOptions);
}
}
// 隐藏设定组下拉菜单
void _hideGroupDropdown() {
_groupDropdownOverlayEntry?.remove();
_groupDropdownOverlayEntry = null;
setState(() {
_isGroupDropdownOpen = false;
});
}
// 显示设定组自定义下拉菜单
void _showGroupCustomDropdown(bool isDark, List<Map<String, dynamic>> groupOptions) {
// 使用GlobalKey获取按钮的准确位置
final RenderBox? renderBox = _groupDropdownKey.currentContext?.findRenderObject() as RenderBox?;
if (renderBox == null) return;
// 获取相对于整个屏幕的全局位置
final Offset globalOffset = renderBox.localToGlobal(Offset.zero);
final Size buttonSize = renderBox.size;
// 获取屏幕尺寸
final screenSize = MediaQuery.of(context).size;
// 如果已有下拉菜单,先关闭
if (_groupDropdownOverlayEntry != null) {
_hideGroupDropdown();
return;
}
setState(() {
_isGroupDropdownOpen = true;
});
// 使用Overlay直接显示下拉菜单确保显示在最顶层
_groupDropdownOverlayEntry = OverlayEntry(
builder: (context) => Stack(
children: [
// 背景遮罩,点击关闭下拉菜单
Positioned.fill(
child: GestureDetector(
onTap: () {
_hideGroupDropdown();
},
child: Container(
color: Colors.transparent,
),
),
),
// 下拉菜单
Positioned(
left: _calculateMenuLeft(globalOffset.dx, screenSize.width),
top: _calculateMenuTop(globalOffset.dy, buttonSize.height, screenSize.height),
child: Material(
elevation: 8,
borderRadius: BorderRadius.circular(8),
color: WebTheme.getSurfaceColor(context),
shadowColor: WebTheme.getShadowColor(context, opacity: 0.3),
child: Container(
width: 200,
constraints: BoxConstraints(
maxWidth: screenSize.width * 0.8,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isDark ? WebTheme.darkGrey600 : WebTheme.grey300,
width: 1,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ConstrainedBox(
constraints: const BoxConstraints(
maxHeight: 250, // 限制最大高度,避免溢出
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: groupOptions.map((option) {
final String? groupId = option['id'] as String?;
final String groupName = option['name'] as String;
final bool isSelected = _selectedGroupId == groupId;
return InkWell(
onTap: () {
_hideGroupDropdown();
if (groupId != _selectedGroupId) {
setState(() {
_selectedGroupId = groupId;
});
}
},
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: isSelected
? (isDark ? WebTheme.darkGrey700 : WebTheme.grey100)
: Colors.transparent,
),
child: Row(
children: [
Icon(
groupId == null ? Icons.folder_off : Icons.folder,
size: 16,
color: isSelected
? (isDark ? WebTheme.grey200 : WebTheme.grey900)
: (isDark ? WebTheme.grey400 : WebTheme.grey700),
),
const SizedBox(width: 8),
Expanded(
child: Text(
groupName,
style: TextStyle(
fontSize: 13,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
color: isSelected
? (isDark ? WebTheme.grey200 : WebTheme.grey900)
: (isDark ? WebTheme.grey300 : WebTheme.grey700),
),
),
),
],
),
),
);
}).toList(),
),
),
),
),
),
),
),
],
),
);
// 插入到Overlay中
Overlay.of(context).insert(_groupDropdownOverlayEntry!);
}
// 显示设定组选择菜单(旧版本,保留作为备用)
/* void _showGroupSelectionMenu(bool isDark, List<Map<String, dynamic>> groupOptions) {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => Container(
margin: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: WebTheme.getSurfaceColor(context),
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: isDark ? WebTheme.darkGrey800 : WebTheme.grey200,
width: 1,
),
),
),
child: Row(
children: [
Icon(
Icons.folder_outlined,
size: 18,
color: WebTheme.getTextColor(context),
),
const SizedBox(width: 8),
Text(
'选择设定组',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: WebTheme.getTextColor(context),
),
),
],
),
),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 300),
child: SingleChildScrollView(
child: Column(
children: groupOptions.map((option) {
final String? groupId = option['id'] as String?;
final String groupName = option['name'] as String;
final bool isSelected = _selectedGroupId == groupId;
return ListTile(
leading: Icon(
groupId == null ? Icons.folder_off : Icons.folder,
size: 18,
color: isSelected
? WebTheme.getTextColor(context)
: WebTheme.getSecondaryTextColor(context),
),
title: Text(
groupName,
style: TextStyle(
fontSize: 14,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
color: isSelected
? WebTheme.getTextColor(context)
: WebTheme.getTextColor(context),
),
),
trailing: isSelected
? Icon(
Icons.check,
size: 18,
color: WebTheme.getTextColor(context),
)
: null,
onTap: () {
setState(() {
_selectedGroupId = groupId;
});
Navigator.pop(context);
},
);
}).toList(),
),
),
),
],
),
),
);
} */
// 构建标题输入框
Widget _buildTitleInput(bool isDark) {
return TextFormField(
controller: _nameController,
style: const TextStyle(
fontSize: 18, // 进一步缩小
fontWeight: FontWeight.w800,
height: 1.2,
),
decoration: InputDecoration(
hintText: 'Unnamed Entry',
hintStyle: TextStyle(
fontSize: 18, // 保持一致
fontWeight: FontWeight.w800,
color: WebTheme.getSecondaryTextColor(context),
),
border: InputBorder.none,
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderSide: BorderSide(
color: WebTheme.getTextColor(context).withOpacity(0.3),
width: 2,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
),
maxLines: 1,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '设定条目名称不能为空';
}
return null;
},
);
}
// 构建标签输入
Widget _buildTagsInput(bool isDark) {
return Container(
height: 30, // 从26增加到30与设定组选择保持一致
child: TextFormField(
controller: _tagsController, // 使用正确的标签控制器
style: TextStyle(
fontSize: 11, // 从12缩小到11
color: WebTheme.getSecondaryTextColor(context),
),
decoration: InputDecoration(
hintText: '+ Add Tags/Labels',
hintStyle: TextStyle(
fontSize: 11, // 从12缩小到11
color: WebTheme.getSecondaryTextColor(context).withOpacity(0.6),
),
border: InputBorder.none,
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderSide: BorderSide(
color: WebTheme.getTextColor(context).withOpacity(0.3),
width: 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), // 调整padding
),
maxLines: 1,
),
);
}
// 构建图片区域
Widget _buildImageSection(bool isDark) {
final typeEnum = _selectedType != null
? _getTypeEnumFromDisplayName(_selectedType!)
: SettingType.character;
return MouseRegion(
onEnter: (_) => setState(() => _isImageHovered = true),
onExit: (_) => setState(() => _isImageHovered = false),
child: Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: isDark ? WebTheme.darkGrey800 : WebTheme.grey100,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: isDark ? WebTheme.darkGrey700 : WebTheme.grey300,
width: 1,
),
),
child: Stack(
children: [
// 背景图片或图标
if (_imageUrl != null && _imageUrl!.isNotEmpty)
Positioned.fill(
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.network(
_imageUrl!,
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null,
),
),
);
},
errorBuilder: (context, error, stackTrace) {
return Center(
child: Icon(
_getTypeIconData(typeEnum),
size: 24,
color: WebTheme.getTextColor(context),
),
);
},
),
),
)
else
// 默认图标
Center(
child: Icon(
_getTypeIconData(typeEnum),
size: 24,
color: WebTheme.getTextColor(context),
),
),
// 上传状态遮罩
if (_isImageUploading)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: WebTheme.getShadowColor(context, opacity: 0.7),
),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
isDark ? WebTheme.getTextColor(context) : Colors.white,
),
),
),
),
),
),
// 悬停时显示的操作按钮
if (_isImageHovered && !_isImageUploading)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: WebTheme.getShadowColor(context, opacity: 0.6),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(3),
onTap: _uploadImage,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(
color: WebTheme.getBackgroundColor(context).withOpacity(0.9),
borderRadius: BorderRadius.circular(3),
),
child: Text(
'Upload',
style: TextStyle(
fontSize: 8,
fontWeight: FontWeight.w500,
color: WebTheme.getTextColor(context),
),
),
),
),
),
const SizedBox(height: 2),
Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(3),
onTap: _pasteImage,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(
color: WebTheme.getBackgroundColor(context).withOpacity(0.9),
borderRadius: BorderRadius.circular(3),
),
child: Text(
'Paste',
style: TextStyle(
fontSize: 8,
fontWeight: FontWeight.w500,
color: WebTheme.getTextColor(context),
),
),
),
),
),
],
),
),
),
],
),
),
);
}
// 构建进度条区域
Widget _buildProgressSection(bool isDark) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), // 缩小padding
child: Row(
children: [
// 进度条
Expanded(
child: Container(
height: 16, // 从20缩小到16
child: CustomPaint(
painter: _ProgressPainter(
backgroundColor: isDark ? WebTheme.darkGrey700 : WebTheme.grey200,
progressColor: isDark ? WebTheme.darkGrey800 : WebTheme.getBackgroundColor(context),
strokeColor: isDark ? WebTheme.darkGrey400 : WebTheme.grey700,
progress: 0.35,
),
size: Size.infinite,
),
),
),
const SizedBox(width: 10), // 从12缩小到10
// 提及数量
Text(
'1 mention',
style: TextStyle(
fontSize: 12, // 从14缩小到12
fontWeight: FontWeight.w500,
color: WebTheme.getTextColor(context),
),
),
],
),
);
}
// 构建标签页区域
Widget _buildTabSection(bool isDark) {
return Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: isDark ? WebTheme.darkGrey800 : WebTheme.grey200,
width: 1,
),
),
),
child: TabBar(
controller: _tabController,
labelColor: isDark ? WebTheme.grey300 : WebTheme.grey900,
unselectedLabelColor: isDark ? WebTheme.grey400 : WebTheme.grey500,
labelStyle: const TextStyle(
fontSize: 12, // 从14缩小到12
fontWeight: FontWeight.w500,
),
unselectedLabelStyle: const TextStyle(
fontSize: 12, // 从14缩小到12
fontWeight: FontWeight.w500,
),
indicator: BoxDecoration(
border: Border(
bottom: BorderSide(
color: isDark ? WebTheme.grey400 : WebTheme.grey900,
width: 2,
),
),
),
tabs: const [
Tab(text: 'Details'),
Tab(text: 'Research'),
Tab(text: 'Relations'),
Tab(text: 'Mentions'),
Tab(text: 'Tracking'),
],
),
);
}
// 构建Details标签页 - 重新设计为系统相关字段
Widget _buildDetailsTab(bool isDark) {
return SingleChildScrollView(
padding: const EdgeInsets.all(14), // 进一步缩小
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// 基本信息区域
_buildBasicInfoSection(isDark),
const SizedBox(height: 18), // 从24缩小到18
// 描述区域
_buildDescriptionSection(isDark),
const SizedBox(height: 18),
// 系统属性区域
_buildSystemAttributesSection(isDark),
const SizedBox(height: 18),
// 添加详情按钮
//_buildAddDetailsButton(isDark),
],
),
);
}
// 构建基本信息区域
Widget _buildBasicInfoSection(bool isDark) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标签/别名字段
_buildFieldSection(
'标签/别名',
'所有名称都会在文本中被识别且不会被拼写检查。',
TextFormField(
controller: _tagsController, // 使用正确的标签控制器
decoration: InputDecoration(
hintText: '添加别名, 标签...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6), // 从8缩小到6
borderSide: BorderSide(
color: isDark ? WebTheme.darkGrey600 : WebTheme.grey400,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(
color: WebTheme.getTextColor(context),
width: 1,
),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), // 缩小padding
),
style: const TextStyle(fontSize: 12), // 缩小字体
),
),
// 如果有AI生成的属性显示属性区域
if (_attributes.isNotEmpty) ...[
const SizedBox(height: 18),
_buildAttributesSection(isDark),
],
],
);
}
// 构建AI生成的属性区域
Widget _buildAttributesSection(bool isDark) {
return _buildFieldSection(
'属性',
'设定的详细属性信息。',
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 显示现有属性
if (_attributes.isNotEmpty)
Wrap(
spacing: 6,
runSpacing: 6,
children: _attributes.map((entry) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isDark ? WebTheme.darkGrey800.withOpacity(0.5) : WebTheme.grey100,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: isDark ? WebTheme.darkGrey600 : WebTheme.grey300,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${entry.key}: ',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: WebTheme.getTextColor(context),
),
),
Text(
entry.value,
style: TextStyle(
fontSize: 12,
color: WebTheme.getSecondaryTextColor(context),
),
),
const SizedBox(width: 4),
GestureDetector(
onTap: () => _removeAttribute(entry.key),
child: Icon(
Icons.close,
size: 14,
color: WebTheme.getSecondaryTextColor(context),
),
),
],
),
);
}).toList(),
),
// 添加新属性按钮
const SizedBox(height: 8),
TextButton.icon(
icon: const Icon(Icons.add, size: 16),
label: const Text('添加属性'),
onPressed: () => _showAddAttributeDialog(isDark),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
visualDensity: VisualDensity.compact,
),
),
],
),
);
}
// 构建描述区域
Widget _buildDescriptionSection(bool isDark) {
return _buildFieldSection(
'详细描述',
'记录所有必要的细节信息。保持具体且简洁。有时拆分条目有助于更好的组织。',
Column(
children: [
Container(
decoration: BoxDecoration(
border: Border.all(
color: isDark ? WebTheme.darkGrey600 : WebTheme.grey400,
),
borderRadius: BorderRadius.circular(6),
),
child: TextFormField(
controller: _descriptionController,
maxLines: 3, // 进一步缩小到3行
minLines: 3, // 设置最小行数
decoration: const InputDecoration(
border: InputBorder.none,
contentPadding: EdgeInsets.all(10), // 从12缩小到10
hintText: '输入描述内容...',
),
style: const TextStyle(fontSize: 12), // 缩小字体
),
),
// 底部工具栏
Container(
margin: const EdgeInsets.only(top: 3), // 从4缩小到3
child: Row(
children: [
Text(
'${_descriptionController.text.split(' ').length}',
style: TextStyle(
fontSize: 10, // 从12缩小到10
fontWeight: FontWeight.w500,
color: WebTheme.getSecondaryTextColor(context),
),
),
const Spacer(),
// 工具按钮
_buildToolButton('进展', Icons.layers, isDisabled: true),
const SizedBox(width: 6), // 从8缩小到6
_buildToolButton('历史', Icons.history),
const SizedBox(width: 6),
_buildToolButton('复制', Icons.content_copy),
],
),
),
],
),
);
}
// 构建系统属性区域
Widget _buildSystemAttributesSection(bool isDark) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'系统属性',
style: TextStyle(
fontSize: 13, // 从14缩小到13
fontWeight: FontWeight.w600,
color: WebTheme.getTextColor(context),
),
),
const SizedBox(height: 8),
// 属性标签
Wrap(
spacing: 6,
runSpacing: 6,
children: [
// 生成来源标签
_buildAttributeTag(
'生成方式',
_settingItem?.generatedBy ?? 'manual',
_getGeneratedByColor(_settingItem?.generatedBy),
),
// 优先级标签
_buildAttributeTag(
'优先级',
_settingItem?.priority?.toString() ?? 'normal',
_getPriorityColor(_settingItem?.priority),
),
// 状态标签
_buildAttributeTag(
'状态',
_settingItem?.status ?? 'active',
_getStatusColor(_settingItem?.status),
),
// AI建议标签
if (_settingItem?.isAiSuggestion == true)
_buildAttributeTag(
'AI建议',
'true',
Theme.of(context).colorScheme.tertiary,
),
// 关联场景数量
if (_settingItem?.sceneIds != null && _settingItem!.sceneIds!.isNotEmpty)
_buildAttributeTag(
'关联场景',
'${_settingItem!.sceneIds!.length}',
Theme.of(context).colorScheme.secondary,
),
],
),
],
);
}
// 构建属性标签
Widget _buildAttributeTag(String label, String value, Color color) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: color.withOpacity(0.3),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$label: ',
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w500,
color: color,
),
),
Text(
value,
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w600,
color: color,
),
),
],
),
);
}
// 获取生成方式颜色
Color _getGeneratedByColor(String? generatedBy) {
final scheme = Theme.of(context).colorScheme;
switch (generatedBy?.toLowerCase()) {
case 'ai':
case 'openai':
case 'claude':
return scheme.secondary;
case 'manual':
case 'user':
return scheme.primary;
default:
return WebTheme.getSecondaryTextColor(context);
}
}
// 获取优先级颜色
Color _getPriorityColor(int? priority) {
final scheme = Theme.of(context).colorScheme;
if (priority == null) return WebTheme.getSecondaryTextColor(context);
if (priority >= 8) return scheme.error;
if (priority >= 5) return scheme.tertiary;
if (priority >= 3) return scheme.secondary;
return scheme.primary;
}
// 获取状态颜色
Color _getStatusColor(String? status) {
final scheme = Theme.of(context).colorScheme;
switch (status?.toLowerCase()) {
case 'active':
return scheme.primary;
case 'archived':
return WebTheme.getSecondaryTextColor(context);
case 'draft':
return scheme.tertiary;
default:
return scheme.secondary;
}
}
// 构建字段区域
Widget _buildFieldSection(String title, String description, Widget content) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 字段标题和AI图标
Row(
children: [
Text(
title,
style: TextStyle(
fontSize: 13, // 从14缩小到13
fontWeight: FontWeight.w500,
color: WebTheme.getTextColor(context),
),
),
const SizedBox(width: 3), // 从4缩小到3
Icon(
Icons.auto_awesome,
size: 12, // 从14缩小到12
color: WebTheme.getSecondaryTextColor(context).withOpacity(0.5),
),
],
),
const SizedBox(height: 3), // 从4缩小到3
// 描述文本
Text(
description,
style: TextStyle(
fontSize: 10, // 从12缩小到10
color: WebTheme.getSecondaryTextColor(context),
height: 1.4,
),
),
const SizedBox(height: 6), // 从8缩小到6
// 内容
content,
],
);
}
// 构建工具按钮
Widget _buildToolButton(String label, IconData icon, {bool isDisabled = false}) {
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(3), // 从4缩小到3
onTap: isDisabled ? null : () {
// TODO: 实现工具按钮功能
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 3), // 缩小padding
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 12, // 从14缩小到12
color: isDisabled
? WebTheme.getSecondaryTextColor(context).withOpacity(0.3)
: WebTheme.getSecondaryTextColor(context),
),
const SizedBox(width: 3), // 从4缩小到3
Text(
label,
style: TextStyle(
fontSize: 10, // 从12缩小到10
fontWeight: FontWeight.w500,
color: isDisabled
? WebTheme.getSecondaryTextColor(context).withOpacity(0.3)
: WebTheme.getSecondaryTextColor(context),
),
),
],
),
),
),
);
}
// 构建添加详情按钮
/* Widget _buildAddDetailsButton(bool isDark) {
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(6), // 从8缩小到6
onTap: () {
// TODO: 实现添加详情功能
},
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(10), // 从12缩小到10
child: Row(
children: [
Icon(
Icons.add,
size: 14, // 从16缩小到14
color: WebTheme.getSecondaryTextColor(context),
),
const SizedBox(width: 6), // 从8缩小到6
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'添加详情',
style: TextStyle(
fontSize: 12, // 从14缩小到12
fontWeight: FontWeight.w500,
color: WebTheme.getTextColor(context),
),
),
Text(
'填写自定义详细信息',
style: TextStyle(
fontSize: 10, // 从12缩小到10
color: WebTheme.getSecondaryTextColor(context).withOpacity(0.7),
),
),
],
),
],
),
),
),
);
} */
// 构建其他标签页(暂时为占位符)
Widget _buildResearchTab(bool isDark) {
return const Center(child: Text('Research功能开发中...'));
}
Widget _buildRelationsTab(bool isDark) {
if (_settingItem == null) {
return const Center(child: Text('加载中...'));
}
return SettingRelationsTab(
settingItem: _settingItem!,
novelId: widget.novelId,
availableItems: context.read<SettingBloc>().state.items,
onItemUpdated: (updatedItem) {
setState(() {
_settingItem = updatedItem;
});
},
);
}
Widget _buildMentionsTab(bool isDark) {
return const Center(child: Text('Mentions功能开发中...'));
}
Widget _buildTrackingTab(bool isDark) {
if (_settingItem == null) {
return const Center(child: Text('加载中...'));
}
return SettingTrackingTab(
settingItem: _settingItem!,
novelId: widget.novelId,
onItemUpdated: (updatedItem) {
setState(() {
_settingItem = updatedItem;
});
},
);
}
// 构建底部操作按钮区域
Widget _buildActionButtons(bool isDark) {
return Container(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 12),
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: isDark ? WebTheme.darkGrey800 : WebTheme.grey200,
width: 1,
),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// 取消按钮
TextButton(
onPressed: widget.onCancel,
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
minimumSize: const Size(80, 36),
),
child: Text(
'取消',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: isDark ? WebTheme.grey400 : WebTheme.grey600,
),
),
),
const SizedBox(width: 12),
// 保存按钮 - 参考 common 组件样式
Container(
height: 36,
decoration: BoxDecoration(
color: WebTheme.getTextColor(context),
borderRadius: BorderRadius.circular(6),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: _isSaving ? null : _saveSettingItem,
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (_isSaving) ...[
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
WebTheme.getBackgroundColor(context),
),
),
),
const SizedBox(width: 8),
],
Text(
_isSaving ? '保存中...' : '保存',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: WebTheme.getBackgroundColor(context),
height: 1.0,
),
),
],
),
),
),
),
),
const SizedBox(width: 12),
// 保存并关闭按钮
Container(
height: 36,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(6),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: _isSaving ? null : _saveAndClose,
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.check,
size: 16,
color: Theme.of(context).colorScheme.onPrimary,
),
const SizedBox(width: 6),
Text(
'保存并关闭',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onPrimary,
height: 1.0,
),
),
],
),
),
),
),
),
],
),
);
}
// 获取类型枚举
SettingType _getTypeEnumFromDisplayName(String displayName) {
return SettingType.values.firstWhere(
(type) => type.displayName == displayName,
orElse: () => SettingType.other,
);
}
// 上传图片
Future<void> _uploadImage() async {
try {
FilePickerResult? result = await FilePicker.platform.pickFiles(
type: FileType.image,
allowMultiple: false,
);
if (result != null && result.files.isNotEmpty) {
final file = result.files.first;
// 验证文件类型
final allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
final fileExtension = file.extension?.toLowerCase();
if (fileExtension == null || !allowedExtensions.contains(fileExtension)) {
if (mounted) {
TopToast.error(context, '不支持的文件格式,请选择 JPG、PNG、GIF 或 WEBP 格式的图片');
}
return;
}
setState(() {
_isImageUploading = true;
});
Uint8List fileBytes;
if (file.bytes != null) {
fileBytes = file.bytes!;
} else if (file.path != null) {
final File imageFile = File(file.path!);
fileBytes = await imageFile.readAsBytes();
} else {
throw Exception('无法读取图片文件');
}
// === 统一处理图片(压缩 + 转 JPG===
final img.Image? image = img.decodeImage(fileBytes);
if (image == null) {
throw Exception('无法解码所选图片');
}
// 若图片过大则按最长边 1200px 等比缩放,保持与小说封面上传一致
img.Image processedImage = image;
const int maxSize = 1200;
if (image.width > maxSize || image.height > maxSize) {
processedImage = img.copyResize(
image,
width: image.width > image.height ? maxSize : null,
height: image.height >= image.width ? maxSize : null,
interpolation: img.Interpolation.average,
);
}
// 压缩为 JPG统一格式质量 85
final Uint8List compressedBytes = Uint8List.fromList(
img.encodeJpg(processedImage, quality: 85),
);
// 生成唯一文件名,统一使用 .jpg 扩展名
final timestamp = DateTime.now().millisecondsSinceEpoch;
final uniqueFileName = '${widget.novelId}_setting_${timestamp}_image.jpg';
// === 上传 ===
final storageRepository = context.read<StorageRepository>();
final imageUrl = await storageRepository.uploadCoverImage(
novelId: widget.novelId,
fileBytes: compressedBytes,
fileName: uniqueFileName,
updateNovelCover: false,
);
setState(() {
_imageUrl = imageUrl;
_isImageUploading = false;
});
if (mounted) {
TopToast.success(context, '图片上传成功');
}
}
} catch (e) {
AppLogger.e('NovelSettingDetail', '上传图片失败', e);
setState(() {
_isImageUploading = false;
});
if (mounted) {
TopToast.error(context, '上传失败: ${e.toString()}');
}
}
}
// 粘贴图片
Future<void> _pasteImage() async {
try {
setState(() {
_isImageUploading = true;
});
// 尝试获取剪贴板中的图片数据
bool hasImageData = false;
// 首先尝试检查剪贴板中是否有图片
try {
// 对于Web平台我们主要检查文本内容是否为图片URL
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
if (clipboardData?.text != null && clipboardData!.text!.isNotEmpty) {
final text = clipboardData.text!.trim();
// 简单的URL验证
if (Uri.tryParse(text) != null &&
(text.startsWith('http://') || text.startsWith('https://')) &&
_isImageUrl(text)) {
setState(() {
_imageUrl = text;
_isImageUploading = false;
});
if (mounted) {
TopToast.success(context, '图片链接已粘贴');
}
hasImageData = true;
return;
}
}
} catch (e) {
AppLogger.w('NovelSettingDetail', '无法访问剪贴板文本内容', e);
}
// 如果没有找到有效的图片数据,显示错误对话框
if (!hasImageData) {
setState(() {
_isImageUploading = false;
});
if (mounted) {
_showNoImageFoundDialog();
}
}
} catch (e) {
AppLogger.e('NovelSettingDetail', '粘贴图片失败', e);
setState(() {
_isImageUploading = false;
});
if (mounted) {
_showNoImageFoundDialog();
}
}
}
// 显示"未找到兼容图片"对话框
void _showNoImageFoundDialog() {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
title: Text(
'No compatible image found',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: WebTheme.getTextColor(context),
),
),
content: Text(
'No image was found in the clipboard. Please make sure it\'s in PNG or JPEG format.',
style: TextStyle(
fontSize: 14,
color: WebTheme.getTextColor(context),
height: 1.4,
),
),
actions: [
Container(
decoration: BoxDecoration(
color: WebTheme.getSecondaryTextColor(context),
borderRadius: BorderRadius.circular(8),
),
child: TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(
'OK',
style: TextStyle(
color: WebTheme.getBackgroundColor(context),
fontWeight: FontWeight.w500,
),
),
),
),
],
backgroundColor: WebTheme.getSurfaceColor(context),
actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
);
},
);
}
// 检查是否为图片URL
bool _isImageUrl(String url) {
final imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg'];
final lowerUrl = url.toLowerCase();
return imageExtensions.any((ext) => lowerUrl.contains(ext));
}
// 获取类型图标 - 统一使用纯黑色
IconData _getTypeIconData(SettingType type) {
switch (type) {
case SettingType.character:
return Icons.person;
case SettingType.location:
return Icons.place;
case SettingType.item:
return Icons.inventory_2;
case SettingType.lore:
return Icons.public;
case SettingType.event:
return Icons.event;
case SettingType.concept:
return Icons.auto_awesome;
case SettingType.faction:
return Icons.groups;
case SettingType.creature:
return Icons.pets;
case SettingType.magicSystem:
return Icons.auto_fix_high;
case SettingType.technology:
return Icons.science;
case SettingType.culture:
return Icons.emoji_people;
case SettingType.history:
return Icons.history;
case SettingType.organization:
return Icons.apartment;
case SettingType.worldview:
return Icons.public;
case SettingType.pleasurePoint:
return Icons.whatshot;
case SettingType.anticipationHook:
return Icons.bolt;
case SettingType.theme:
return Icons.category;
case SettingType.tone:
return Icons.tonality;
case SettingType.style:
return Icons.brush;
case SettingType.trope:
return Icons.theater_comedy;
case SettingType.plotDevice:
return Icons.schema;
case SettingType.powerSystem:
return Icons.flash_on;
case SettingType.timeline:
return Icons.timeline;
case SettingType.religion:
return Icons.account_balance;
case SettingType.politics:
return Icons.gavel;
case SettingType.economy:
return Icons.attach_money;
case SettingType.geography:
return Icons.map;
default:
return Icons.article;
}
}
}
// 自定义进度条绘制器
class _ProgressPainter extends CustomPainter {
final Color backgroundColor;
final Color progressColor;
final Color strokeColor;
final double progress;
_ProgressPainter({
required this.backgroundColor,
required this.progressColor,
required this.strokeColor,
required this.progress,
});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..style = PaintingStyle.fill;
// 绘制背景
paint.color = backgroundColor;
final backgroundPath = Path()
..moveTo(0, size.height)
..lineTo(size.width * 0.35, size.height)
..lineTo(size.width * 0.36, 0)
..lineTo(size.width * 0.37, 0)
..lineTo(size.width * 0.38, size.height)
..lineTo(size.width, size.height)
..close();
canvas.drawPath(backgroundPath, paint);
// 绘制描边
paint
..color = strokeColor
..style = PaintingStyle.stroke
..strokeWidth = 1.0;
canvas.drawPath(backgroundPath, paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}