Files
leaudit-platform-frontend/docs/contract-drafting-implementation-summary-v2.md
2025-12-05 00:09:32 +08:00

12 KiB
Raw Permalink Blame History

合同起草功能 - 实施总结(V2 更新版)

业务逻辑变更

基于用户需求,功能从"保存草稿"模式调整为"临时编辑"模式:

变更对比

功能 旧逻辑(V1 新逻辑(V2
保存草稿 保存占位符值到数据库 导出文档(下载 MinIO 文件)
完成起草 标记为已完成,保留记录 下载文件 + 删除草稿记录
离开页面 草稿继续保留 自动删除草稿记录
草稿列表 可查看历史草稿 无草稿列表(临时存在)
数据持久化 保存 placeholder_values 不保存,仅跟踪会话

实施的功能

已完成

  1. 草稿创建

    • 用户点击"起草合同"创建临时草稿记录
    • 记录包含:标题、文件路径、模板ID
    • 不保存占位符值
  2. 占位符表单

    • 动态渲染表单字段(基于 placeholder_schema
    • 分组显示(甲方信息、乙方信息、合同条款)
    • 本地状态管理(不提交到数据库)
  3. 一键替换

    • 遍历占位符值
    • 调用 Collabora unoCommands.replaceAll()
    • 实时替换文档中的 {{占位符}}
  4. 导出文档(新)

    • 下载 MinIO 上的当前文件
    • 使用 downloadFile() 统一方法
    • 清理文件名,触发浏览器下载
  5. 完成起草(更新)

    • 先下载文件
    • 延迟 500ms 后删除草稿记录
    • 自动跳转到模板列表
  6. 自动清理(新)

    • 页面关闭:使用 navigator.sendBeacon 删除草稿
    • 路由跳转:使用 fetch + keepalive 删除草稿
    • 点击返回:弹窗确认后删除草稿

已移除

  1. updatePlaceholders() - 不再保存占位符值
  2. completeDraft() - 不再更新状态
  3. getDraftsByUser() - 不需要草稿列表

新增功能

  1. deleteDraft()

    • 删除草稿记录
    • 权限验证(只能删除自己的)
    • 使用 postgrestDelete
  2. handleExportDocument()

    • 下载 MinIO 文件
    • 创建 Blob URL
    • 触发浏览器下载
  3. 自动清理机制

    • beforeunload 事件监听
    • 组件卸载清理
    • keepalivesendBeacon 技术

技术实现

1. Service 层(draft-service.server.ts

// 保留的函数
 createDraftContract()      // 创建草稿记录
 getDraftById()              // 获取草稿详情
 copyMinioFile()             // 文件复制(预留)
 generateDraftFilePath()    // 生成文件路径

// 新增的函数
 deleteDraft()               // 删除草稿记录

// 删除的函数
 updatePlaceholders()        // 不再需要
 completeDraft()             // 不再需要
 getDraftsByUser()           // 不再需要

2. 路由层(contract-draft.$draftId.tsx

// Loader
export async function loader({ params, request }) {
  // 获取草稿和模板信息
  const draft = await getDraftById(draftId, userId, jwt);
  const template = await postgrestGet('contract_templates', ...);
  return { draft, template };
}

// Action(只处理删除)
export async function action({ request, params }) {
  const actionType = formData.get('_action');

  if (actionType === 'delete') {
    await deleteDraft(draftId, userId, jwt);
    return json({ success: true, message: '草稿已删除' });
  }
}

// 组件
export default function ContractDraftPage() {
  // 自动删除机制
  useEffect(() => {
    window.addEventListener('beforeunload', handleBeforeUnload);
    return () => {
      // 组件卸载时删除草稿
      fetch(`/contract-draft/${draft.id}`, {
        method: 'POST',
        body: formData,
        keepalive: true
      });
    };
  }, [draft.id]);

  // 导出文档
  const handleExportDocument = async () => {
    const blob = await downloadFile(draft.file_path);
    // 创建下载链接
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = fileName;
    a.click();
  };

  // 完成起草
  const handleComplete = async () => {
    await handleExportDocument();  // 先下载
    setTimeout(() => {
      fetcher.submit({ _action: 'delete' }, { method: 'post' });  // 后删除
    }, 500);
  };

  // 返回
  const handleBack = () => {
    if (confirm('确定要返回吗?草稿将被删除。')) {
      fetcher.submit({ _action: 'delete' }, { method: 'post' });
    }
  };
}

3. 组件层(PlaceholderForm.tsx

interface PlaceholderFormProps {
  onBatchReplace: () => void;
  onExportDocument: () => void;   // 改名:导出文档
  onComplete: () => void;
  isReplacing: boolean;
  isDeleting: boolean;            // 改名:是否正在删除
}

// 按钮
<button onClick={onBatchReplace}>一键替换占位符</button>
<button onClick={onExportDocument}>
  <i className="ri-download-line"></i>
  导出文档
</button>
<button onClick={onComplete}>
  {isDeleting ? '处理中...' : '完成起草'}
</button>

关键技术点

1. 页面关闭时发送请求

// 使用 sendBeacon 确保请求发送
const handleBeforeUnload = () => {
  const formData = new FormData();
  formData.append('_action', 'delete');
  navigator.sendBeacon(`/contract-draft/${draft.id}`, formData);
};

window.addEventListener('beforeunload', handleBeforeUnload);

特点

  • 异步发送,不阻塞页面
  • 浏览器保证发送(即使页面已关闭)
  • 适用于页面关闭场景

2. 组件卸载时清理

useEffect(() => {
  return () => {
    const formData = new FormData();
    formData.append('_action', 'delete');

    fetch(`/contract-draft/${draft.id}`, {
      method: 'POST',
      body: formData,
      keepalive: true  // 关键:确保请求发送
    });
  };
}, [draft.id]);

特点

  • keepalive: true 确保即使组件卸载也能发送
  • 适用于 SPA 路由跳转场景
  • 不阻塞路由跳转

3. 文件下载

const handleExportDocument = async () => {
  // 1. 从 MinIO 下载文件
  const blob = await downloadFile(draft.file_path);

  // 2. 创建 Blob URL
  const blobUrl = URL.createObjectURL(blob);

  // 3. 触发下载
  const a = document.createElement('a');
  a.href = blobUrl;
  a.download = fileName;
  document.body.appendChild(a);
  a.click();

  // 4. 清理
  setTimeout(() => {
    document.body.removeChild(a);
    URL.revokeObjectURL(blobUrl);
  }, 100);
};

特点

  • 使用 Blob URL 避免跨域问题
  • 自动清理内存
  • 兼容所有现代浏览器

文件变更清单

修改的文件

文件 变更内容
app/api/contracts/draft-service.server.ts 添加 deleteDraft()
删除 updatePlaceholders()
删除 completeDraft()
删除 getDraftsByUser()
app/routes/contract-draft.$draftId.tsx 🔄 action 只处理删除
添加 handleExportDocument()
🔄 修改 handleComplete()
🔄 修改 handleBack()
添加自动清理机制
app/components/contracts/PlaceholderForm.tsx 🔄 props: onSaveDraftonExportDocument
🔄 props: isSavingisDeleting
🔄 按钮文案:保存草稿 → 导出文档

删除的文件

文件 原因
app/routes/api.contracts.draft.tsx 已集成到页面路由 action
app/routes/api.contracts.draft.$id.placeholders.tsx 不再保存占位符
app/routes/api.contracts.draft.$id.complete.tsx 改为删除操作
app/routes/api.files.copy.tsx 已集成到 service 层

新增的文档

文件 内容
docs/contract-drafting-updated-architecture.md 更新后的架构说明
docs/contract-drafting-implementation-summary-v2.md 本文档

测试清单

功能测试

  • 创建草稿

    • 点击"起草合同"按钮
    • 输入标题
    • 成功跳转到编辑页
  • 占位符表单

    • 表单字段正确渲染
    • 分组显示正常
    • 输入值能实时更新
  • 一键替换

    • 点击"一键替换占位符"
    • Collabora 中占位符被替换
    • 显示替换数量提示
  • 导出文档

    • 点击"导出文档"按钮
    • 文件开始下载
    • 文件名正确
    • 文件内容包含已替换的内容
  • 完成起草

    • 点击"完成起草"按钮
    • 文件自动下载
    • 草稿记录被删除
    • 跳转到模板列表
  • 返回按钮

    • 点击"返回"按钮
    • 显示确认弹窗
    • 确认后草稿被删除
    • 跳转到模板列表
  • 自动清理

    • 关闭浏览器标签页 → 草稿被删除
    • 点击浏览器返回按钮 → 草稿被删除
    • 在地址栏输入其他URL → 草稿被删除

边界测试

  • 网络异常

    • 下载失败时显示错误提示
    • 删除失败时显示错误提示
  • 并发操作

    • 快速点击"完成起草"不会重复操作
    • 正在删除时禁用所有按钮
  • 权限验证

    • 只能查看自己的草稿
    • 只能删除自己的草稿

部署步骤

1. 数据库迁移

# 连接数据库
psql -U postgres -d docreview

# 执行迁移脚本
\i database/migrations/001_create_drafted_contracts.sql

# 验证表结构
\d drafted_contracts

2. 配置测试模板

-- 更新模板的 placeholder_schema
UPDATE contract_templates
SET placeholder_schema = '{
  "fields": [
    {
      "key": "甲方名称",
      "label": "甲方名称",
      "type": "text",
      "required": true,
      "group": "甲方信息"
    },
    {
      "key": "乙方名称",
      "label": "乙方名称",
      "type": "text",
      "required": true,
      "group": "乙方信息"
    },
    {
      "key": "合同金额",
      "label": "合同金额(元)",
      "type": "number",
      "required": true,
      "group": "合同条款"
    },
    {
      "key": "签订日期",
      "label": "签订日期",
      "type": "date",
      "required": true,
      "group": "合同条款"
    }
  ]
}'::jsonb
WHERE id = 1;  -- 替换为实际的模板ID

3. 准备测试模板文档

在模板 Word 文档中添加占位符:

  • {{甲方名称}}
  • {{乙方名称}}
  • {{合同金额}}
  • {{签订日期}}

4. 构建和部署

# 类型检查
npm run typecheck

# 构建
npm run build

# 启动
npm start

常见问题

Q1: 草稿记录什么时候被删除?

A: 在以下情况下自动删除:

  1. 用户点击"完成起草"
  2. 用户点击"返回"按钮并确认
  3. 用户关闭浏览器标签页
  4. 用户通过浏览器返回按钮离开
  5. 用户在地址栏输入其他URL

Q2: 占位符值保存在哪里?

A: 占位符值仅保存在前端组件的本地状态中,不会提交到数据库。数据库中的 placeholder_values 字段始终为空对象 {}

Q3: 如果用户关闭页面前网络断开,草稿会被删除吗?

A: 使用了两种机制确保删除:

  1. navigator.sendBeacon - 浏览器会排队等待网络恢复后发送
  2. fetch + keepalive - 即使页面关闭也会尝试发送

但如果网络长时间不可用,草稿可能保留。可以通过定时任务清理超过一定时间(如 1 小时)的草稿记录。

Q4: "导出文档"和"完成起草"有什么区别?

A:

  • 导出文档:只下载文件,草稿记录继续保留,用户可以继续编辑
  • 完成起草:下载文件 + 删除草稿记录 + 返回模板列表

Q5: 如何启用文件复制功能?

A: 参考 docs/minio-file-copy-implementation.md,需要:

  1. 安装 npm install minio
  2. 配置环境变量(MINIO_ENDPOINT, MINIO_ACCESS_KEY 等)
  3. 实现 copyMinioFile() 函数
  4. 在创建草稿时先复制文件,然后传递 draftFilePath

总结

功能已完成

  • 草稿临时管理
  • 导出文档功能
  • 自动清理机制
  • 完整的 Remix 集成

代码质量

  • TypeScript 类型检查通过
  • 符合 Remix 最佳实践
  • 代码结构清晰
  • 注释完整

用户体验

  • 操作流程简单
  • 自动清理无需手动管理
  • 随时导出当前编辑
  • 错误提示友好

🎉 可以开始测试和部署了!