聊天文件预览与保存到网盘功能开发笔记

December 28, 2025
7 min read
By devshan

Table of Contents

This is a list of all the sections in this post. Click on any of them to jump to that section.

涉及功能: 聊天文件预览来源区分、附件保存到网盘、文本消息保存为文件


一、需求背景

1.1 问题描述

聊天中发送的图片、视频等附件,在预览时使用的是与网盘文件相同的预览页面(ImagePreviewPage、VideoPlayerPage),但菜单操作应该不同:

  • 从网盘进入: 显示重命名、移动、删除等文件管理操作
  • 从聊天进入: 显示下载、保存到网盘等操作

1.2 功能需求

  1. 预览页面能区分进入来源
  2. 聊天附件可保存到网盘指定位置
  3. 文本消息可保存为 .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 关键区别

字段SendMessageFileMetadata
文件IDfileIdid
文件名fileNamename
路径无(聊天附件无路径概念)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,只有本地缓存
  • 使用 adoptOrphanFile API 添加元数据

已上传文件(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添加新建文件夹按钮和对话框

十、设计原则总结

  1. 参数区分来源: 通过可选参数而非页面类型区分,复用预览页面代码

  2. API 复用优先: 优先使用现有 API(adoptOrphanFile、uploadByPath),避免后端膨胀

  3. 渐进式交互: 文件夹选择器内可新建文件夹,减少操作步骤

  4. 异步安全: 所有异步操作后检查 mounted 状态再使用 context

  5. 临时文件清理: 生成临时文件后在 finally 块中清理,避免残留