# 合同起草功能 - 实施总结(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` 事件监听 - 组件卸载清理 - `keepalive` 和 `sendBeacon` 技术 --- ## 技术实现 ### 1. Service 层(draft-service.server.ts) ```typescript // 保留的函数 ✅ createDraftContract() // 创建草稿记录 ✅ getDraftById() // 获取草稿详情 ✅ copyMinioFile() // 文件复制(预留) ✅ generateDraftFilePath() // 生成文件路径 // 新增的函数 ✅ deleteDraft() // 删除草稿记录 // 删除的函数 ❌ updatePlaceholders() // 不再需要 ❌ completeDraft() // 不再需要 ❌ getDraftsByUser() // 不再需要 ``` ### 2. 路由层(contract-draft.$draftId.tsx) ```typescript // 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) ```typescript interface PlaceholderFormProps { onBatchReplace: () => void; onExportDocument: () => void; // 改名:导出文档 onComplete: () => void; isReplacing: boolean; isDeleting: boolean; // 改名:是否正在删除 } // 按钮 ``` --- ## 关键技术点 ### 1. 页面关闭时发送请求 ```typescript // 使用 sendBeacon 确保请求发送 const handleBeforeUnload = () => { const formData = new FormData(); formData.append('_action', 'delete'); navigator.sendBeacon(`/contract-draft/${draft.id}`, formData); }; window.addEventListener('beforeunload', handleBeforeUnload); ``` **特点**: - 异步发送,不阻塞页面 - 浏览器保证发送(即使页面已关闭) - 适用于页面关闭场景 ### 2. 组件卸载时清理 ```typescript 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. 文件下载 ```typescript 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: `onSaveDraft` → `onExportDocument`
🔄 props: `isSaving` → `isDeleting`
🔄 按钮文案:保存草稿 → 导出文档 | ### 删除的文件 | 文件 | 原因 | |------|------| | ❌ `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. 数据库迁移 ```bash # 连接数据库 psql -U postgres -d docreview # 执行迁移脚本 \i database/migrations/001_create_drafted_contracts.sql # 验证表结构 \d drafted_contracts ``` ### 2. 配置测试模板 ```sql -- 更新模板的 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. 构建和部署 ```bash # 类型检查 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 最佳实践 - 代码结构清晰 - 注释完整 ✅ **用户体验**: - 操作流程简单 - 自动清理无需手动管理 - 随时导出当前编辑 - 错误提示友好 🎉 **可以开始测试和部署了!**