涉及功能: 聊天文件预览来源区分、附件保存到网盘、文本消息保存为文件
一、需求背景
1.1 问题描述
聊天中发送的图片、视频等附件,在预览时使用的是与网盘文件相同的预览页面(ImagePreviewPage、VideoPlayerPage),但菜单操作应该不同:
- 从网盘进入: 显示重命名、移动、删除等文件管理操作
- 从聊天进入: 显示下载、保存到网盘等操作
1.2 功能需求
- 预览页面能区分进入来源
- 聊天附件可保存到网盘指定位置
- 文本消息可保存为 .txt 文件到网盘
二、数据模型分析
2.1 SendMessage 模型
class SendMessage {
final String id;
final String sessionId;
final String type; // 'text', 'image', 'video', 'audio', 'file'
final String? content; // 文本内容
final String? fileId; // 关联的文件ID
final String? fileName; // 文件名
final String? mimeType; // MIME类型
final int? fileSize; // 文件大小
final int? width; // 图片/视频宽度
final int? height; // 图片/视频高度
final int? duration; // 音视频时长(ms)
final bool isLocalFile; // 是否为本地文件(尚未上传到S3)
final DateTime createdAt;
// ...
}2.2 FileMetadata 模型
class FileMetadata {
final String id;
final String name;
final String path;
final bool isFolder;
final String? mimeType;
// ... 其他元数据
}2.3 关键区别
| 字段 | SendMessage | FileMetadata |
|---|---|---|
| 文件ID | fileId | id |
| 文件名 | fileName | name |
| 路径 | 无(聊天附件无路径概念) | path |
| 本地状态 | isLocalFile | 无(都是已上传) |
三、方案设计
3.1 预览页面来源区分
设计思路
通过可选参数 SendMessage? sendMessage 区分来源:
- 有值 → 从聊天进入
- 为空 → 从网盘进入
修改文件
ImagePreviewPage:
class ImagePreviewPage extends StatefulWidget {
final FileMetadata file;
final SendMessage? sendMessage; // 新增:从聊天进入时传入
const ImagePreviewPage({
super.key,
required this.file,
this.sendMessage,
});
// ...
}VideoPlayerPage:
class VideoPlayerPage extends StatefulWidget {
final FileMetadata file;
final SendMessage? sendMessage; // 新增:从聊天进入时传入
const VideoPlayerPage({
super.key,
required this.file,
this.sendMessage,
});
// ...
}3.2 菜单逻辑区分
void _showFileActions(BuildContext context) {
if (widget.sendMessage != null) {
_showChatFileActions(context); // 聊天来源菜单
} else {
_showNetdiskFileActions(context); // 网盘来源菜单
}
}聊天来源菜单项
void _showChatFileActions(BuildContext context) {
showModalBottomSheet(
context: context,
builder: (context) => SendFileActionSheet(
message: widget.sendMessage!,
onSaveToNetdisk: () => _saveToNetdisk(context),
onDownload: () => _downloadFile(context),
),
);
}网盘来源菜单项
void _showNetdiskFileActions(BuildContext context) {
showModalBottomSheet(
context: context,
builder: (context) => FileActionSheet(
file: widget.file,
onRename: () => _renameFile(context),
onMove: () => _moveFile(context),
onDelete: () => _deleteFile(context),
),
);
}四、保存到网盘实现
4.1 附件保存流程
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐│ 点击"保存到 │────▶│ 文件夹选择器 │────▶│ 调用API保存 ││ 网盘"按钮 │ │ 选择目标路径 │ │ 到指定位置 │└─────────────────┘ └─────────────────┘ └─────────────────┘ │ ┌─────────────────┐ │ │ 刷新文件列表 │◀────────────┘ │ 显示成功提示 │ └─────────────────┘4.2 API 选择与取舍
方案 A:新增专用 API
// 后端新增
POST /api/send/save-to-netdisk
{
"messageId": "xxx",
"targetPath": "/目标文件夹"
}问题: 增加后端复杂度,违反 API 复用原则
方案 B:复用现有 API(采用)
根据文件状态选择不同 API:
本地文件(isLocalFile=true):
- 文件尚未上传到 S3,只有本地缓存
- 使用
adoptOrphanFileAPI 添加元数据
已上传文件(isLocalFile=false):
- 文件已在 S3,有完整元数据
- 跳转到网盘中该文件的位置查看
4.3 实现代码
_saveToNetdisk 方法:
Future<void> _saveToNetdisk(BuildContext context) async {
final message = widget.sendMessage!;
// 如果不是本地文件,跳转到网盘位置
if (!message.isLocalFile && message.fileId != null) {
_navigateToFileLocation(message.fileId!);
return;
}
// 选择目标文件夹
final targetPath = await showDialog<String>(
context: context,
builder: (context) => FilePickerDialog(
mode: FilePickerMode.folderOnly,
title: '保存到',
),
);
if (targetPath == null) return;
// 调用 adoptOrphanFile 保存
final appState = Provider.of<AppState>(context, listen: false);
try {
await appState.adoptOrphanFile(
fileId: message.fileId!,
name: message.fileName ?? 'file',
targetPath: targetPath,
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('已保存到 $targetPath')),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('保存失败: $e')),
);
}
}
}五、文本消息保存为文件
5.1 流程设计
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐│ 长按文本消息 │────▶│ 菜单选择 │────▶│ 输入文件名 ││ 弹出菜单 │ │ "保存为文件" │ │ (可选) │└─────────────────┘ └─────────────────┘ └─────────────────┘ │ ▼┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐│ 生成临时 │────▶│ 调用 │────▶│ 删除临时文件 ││ .txt 文件 │ │ uploadByPath │ │ 显示成功提示 │└─────────────────┘ └─────────────────┘ └─────────────────┘5.2 实现代码
Future<void> _saveTextAsFile(SendMessage message) async {
if (message.content == null || message.content!.isEmpty) return;
// 1. 选择目标文件夹
final targetPath = await showDialog<String>(
context: context,
builder: (context) => FilePickerDialog(
mode: FilePickerMode.folderOnly,
title: '保存到',
),
);
if (targetPath == null) return;
// 2. 生成文件名(取前20字符 + 时间戳)
final preview = message.content!.length > 20
? message.content!.substring(0, 20)
: message.content!;
final safeName = preview.replaceAll(RegExp(r'[\\/:*?"<>|\n\r]'), '_');
final timestamp = DateTime.now().millisecondsSinceEpoch;
final fileName = '${safeName}_$timestamp.txt';
// 3. 创建临时文件
final tempDir = await getTemporaryDirectory();
final tempFile = File('${tempDir.path}/$fileName');
await tempFile.writeAsString(message.content!);
try {
// 4. 上传到网盘
final appState = Provider.of<AppState>(context, listen: false);
await appState.uploadByPath(
localPath: tempFile.path,
remotePath: '$targetPath/$fileName',
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('已保存到 $targetPath/$fileName')),
);
}
} finally {
// 5. 清理临时文件
if (await tempFile.exists()) {
await tempFile.delete();
}
}
}六、chat_page.dart 修改
6.1 打开预览时传入 sendMessage
图片预览:
void _openImagePreview(SendMessage message, FileMetadata file) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ImagePreviewPage(
file: file,
sendMessage: message, // 传入消息对象
),
),
);
}视频预览:
void _openVideoPlayer(SendMessage message, FileMetadata file) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => VideoPlayerPage(
file: file,
sendMessage: message, // 传入消息对象
),
),
);
}6.2 消息上下文菜单添加保存选项
List<ContextMenuItem> _buildMessageMenuItems(SendMessage message) {
final items = <ContextMenuItem>[];
// 复制(文本消息)
if (message.type == 'text' && message.content != null) {
items.add(ContextMenuItem(
icon: TablerIcons.copy,
label: '复制',
onTap: () => _copyMessage(message),
));
// 保存为文件
items.add(ContextMenuItem(
icon: TablerIcons.file_download,
label: '保存为文件',
onTap: () => _saveTextAsFile(message),
));
}
// 附件消息
if (message.fileId != null) {
items.add(ContextMenuItem(
icon: TablerIcons.device_floppy,
label: '保存到网盘',
onTap: () => _saveToNetdisk(message),
));
}
// 删除
items.add(ContextMenuItem(
icon: TablerIcons.trash,
label: '删除',
onTap: () => _deleteMessage(message),
));
return items;
}七、文件夹选择器新建文件夹功能
7.1 需求
在保存文件时,使用时可能需要新建一个文件夹来存放,不应该退出选择器再去网盘创建。
7.2 UI 设计
在面包屑导航右侧添加圆形 folder-plus 按钮
7.3 实现代码
file_picker_dialog.dart:
Row(
children: [
// 面包屑导航
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: _buildBreadcrumb(),
),
),
// 新建文件夹按钮(仅 folderOnly 模式)
if (widget.mode == FilePickerMode.folderOnly)
Container(
width: 32,
height: 32,
margin: const EdgeInsets.only(left: 8),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: theme.colorScheme.primaryContainer,
),
child: IconButton(
icon: Icon(TablerIcons.folder_plus, size: 18),
onPressed: _showCreateFolderDialog,
tooltip: '新建文件夹',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
color: theme.colorScheme.onPrimaryContainer,
),
),
],
),新建文件夹对话框:
Future<void> _showCreateFolderDialog() async {
final controller = TextEditingController();
final folderName = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('新建文件夹'),
content: TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(
hintText: '文件夹名称',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(context, controller.text.trim()),
child: const Text('创建'),
),
],
),
);
if (folderName == null || folderName.isEmpty) return;
final appState = Provider.of<AppState>(context, listen: false);
try {
await appState.createFolderAt(folderName, _currentPath);
_refreshFiles(); // 刷新文件列表
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('创建失败: $e')),
);
}
}
}八、遇到的问题与解决
8.1 createFolder API 参数错误
错误: Too many positional arguments: 1 expected, but 2 found
原因: createFolder 方法签名只接受一个参数(完整路径)
解决: 使用 createFolderAt(name, parentPath) 方法
// 错误
await appState.createFolder(folderName, _currentPath);
// 正确
await appState.createFolderAt(folderName, _currentPath);8.2 异步后使用 BuildContext
问题: 异步操作后使用 context 可能导致错误
解决: 检查 context.mounted 或使用 if (mounted)
try {
await someAsyncOperation();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(...);
}
} catch (e) {
// ...
}九、文件修改清单
| 文件 | 修改内容 |
|---|---|
| image_preview_page.dart | 添加 sendMessage 参数,区分菜单 |
| video_player_page.dart | 添加 sendMessage 参数,区分菜单 |
| chat_page.dart | 打开预览时传入 sendMessage |
| chat_page.dart | 添加保存到网盘、保存为文件功能 |
| file_picker_dialog.dart | 添加新建文件夹按钮和对话框 |
十、设计原则总结
-
参数区分来源: 通过可选参数而非页面类型区分,复用预览页面代码
-
API 复用优先: 优先使用现有 API(adoptOrphanFile、uploadByPath),避免后端膨胀
-
渐进式交互: 文件夹选择器内可新建文件夹,减少操作步骤
-
异步安全: 所有异步操作后检查 mounted 状态再使用 context
-
临时文件清理: 生成临时文件后在 finally 块中清理,避免残留