# 合同起草功能 - 更新后的架构说明 ## 业务逻辑调整 基于新的业务需求,草稿功能调整为**临时编辑模式**: ### ✅ 新的业务逻辑 1. **"保存草稿"** → **"导出文档"** - 功能:下载 MinIO 上的当前文件 - 不保存占位符值到数据库 2. **"完成起草"** - 功能:下载文件 + 删除草稿记录 - 操作完成后返回模板列表 3. **关闭页面/返回** - 自动删除草稿记录 - 草稿是临时的,不保留 4. **草稿记录策略** - 临时存在,仅用于跟踪编辑会话 - 离开页面即删除 - 不持久化占位符值 --- ## 架构变更 ### 删除的功能 ```diff - updatePlaceholders() # 不再保存占位符值 - completeDraft() # 不再更新状态为 completed - getDraftsByUser() # 不需要查询草稿列表 ``` ### 新增的功能 ```diff + deleteDraft() # 删除草稿记录 + handleExportDocument() # 下载 MinIO 文件 + beforeunload 监听 # 页面关闭时删除草稿 + 组件卸载清理 # 路由跳转时删除草稿 ``` --- ## 文件结构 ``` app/ ├── routes/ │ ├── contract-template.detail.$id.tsx # 模板详情页(创建草稿) │ └── contract-draft.$draftId.tsx # 草稿编辑页(loader + action: delete) │ ├── api/ │ └── contracts/ │ └── draft-service.server.ts # 业务逻辑 │ ├── createDraftContract() # 创建草稿记录 │ ├── deleteDraft() # 删除草稿记录(新增) │ ├── getDraftById() # 获取草稿详情 │ ├── copyMinioFile() # 文件复制(预留) │ └── generateDraftFilePath() # 生成文件路径 │ └── components/ └── contracts/ └── PlaceholderForm.tsx # 占位符表单 ├── "导出文档" 按钮(新) └── "完成起草" 按钮 ``` --- ## 数据流 ### 1. 创建草稿 ``` 用户点击"起草合同" ↓ contract-template.detail.$id.tsx ├─ action() 创建草稿记录 └─ redirect 到 /contract-draft/:draftId ``` ### 2. 编辑草稿 ``` 草稿编辑页 ├─ loader() 加载草稿和模板数据 ├─ 左侧:Collabora 编辑器(实时编辑 MinIO 文件) └─ 右侧:占位符表单(本地状态,不保存到数据库) ``` ### 3. 导出文档 ``` 用户点击"导出文档" ↓ handleExportDocument() ├─ downloadFile(draft.file_path) │ └─ 从 MinIO 下载文件 ├─ 创建 Blob URL └─ 触发浏览器下载 ``` ### 4. 完成起草 ``` 用户点击"完成起草" ↓ handleComplete() ├─ 1. 调用 handleExportDocument() │ └─ 下载文件 ├─ 2. 延迟 500ms └─ 3. fetcher.submit({ _action: 'delete' }) ↓ action() 删除草稿记录 ↓ redirect 到 /contract-template ``` ### 5. 关闭页面/返回 ``` 方式一:页面关闭(beforeunload) ↓ handleBeforeUnload() └─ navigator.sendBeacon('/contract-draft/:id', { _action: 'delete' }) 方式二:路由跳转(组件卸载) ↓ useEffect cleanup └─ fetch('/contract-draft/:id', { method: 'POST', keepalive: true }) 方式三:点击返回按钮 ↓ handleBack() ├─ confirm('确定要返回吗?草稿将被删除。') └─ fetcher.submit({ _action: 'delete' }) ``` --- ## 核心代码实现 ### draft-service.server.ts ```typescript import { postgrestGet, postgrestPost, postgrestPut, postgrestDelete } from '~/api/postgrest-client'; /** * 创建草稿记录(临时记录,用于跟踪编辑会话) */ export async function createDraftContract( request: CreateDraftRequest, userId: number, draftFilePath?: string, jwt?: string ): Promise { // 1. 查询模板信息 const templateResponse = await postgrestGet('contract_templates', { select: 'id,file_path', filter: { id: `eq.${request.templateId}` }, token: jwt }); // 2. 确定文件路径(模板路径 or 复制路径) const finalFilePath = draftFilePath || template.file_path; // 3. 创建草稿记录(不保存 placeholder_values) const insertResponse = await postgrestPost('drafted_contracts', { body: { template_id: request.templateId, file_path: finalFilePath, title: request.title, placeholder_values: {}, // 始终为空 status: 'draft', created_by: userId }, select: '*', token: jwt }); return draft as DraftedContract; } /** * 删除草稿记录 */ export async function deleteDraft( draftId: number, userId: number, jwt?: string ): Promise { await postgrestDelete('drafted_contracts', { filter: { id: `eq.${draftId}`, created_by: `eq.${userId}` }, token: jwt }); console.log('[Draft Service] 草稿已删除:', draftId); } /** * 获取草稿详情 */ export async function getDraftById( draftId: number, userId: number, jwt?: string ): Promise { const response = await postgrestGet('drafted_contracts', { select: '*', filter: { id: `eq.${draftId}`, created_by: `eq.${userId}` }, token: jwt }); return draft as DraftedContract; } ``` ### contract-draft.$draftId.tsx ```typescript /** * Action 函数:只处理删除操作 */ export async function action({ request, params }: ActionFunctionArgs) { const draftId = parseInt(params.draftId || '0'); const { userInfo, frontendJWT } = await getUserSession(request); const userId = parseInt(userInfo.sub); const jwt = frontendJWT || undefined; const formData = await request.formData(); const actionType = formData.get('_action') as string; if (actionType === 'delete') { await deleteDraft(draftId, userId, jwt); return json({ success: true, message: '草稿已删除' }); } return json({ error: '无效的操作类型' }, { status: 400 }); } export default function ContractDraftPage() { const { draft, template } = useLoaderData(); const navigate = useNavigate(); const fetcher = useFetcher(); const [placeholderValues, setPlaceholderValues] = useState( draft.placeholder_values || {} ); // 监听删除成功 useEffect(() => { if (fetcher.data?.success && fetcher.data.message === '草稿已删除') { navigate('/contract-template'); } }, [fetcher.data, navigate]); // 监听页面关闭 - 自动删除草稿 useEffect(() => { const handleBeforeUnload = () => { const formData = new FormData(); formData.append('_action', 'delete'); navigator.sendBeacon(`/contract-draft/${draft.id}`, formData); }; window.addEventListener('beforeunload', handleBeforeUnload); return () => window.removeEventListener('beforeunload', handleBeforeUnload); }, [draft.id]); // 组件卸载时删除草稿 useEffect(() => { return () => { const formData = new FormData(); formData.append('_action', 'delete'); fetch(`/contract-draft/${draft.id}`, { method: 'POST', body: formData, keepalive: true }); }; }, [draft.id]); // 导出文档(下载 MinIO 文件) const handleExportDocument = async () => { try { const blob = await downloadFile(draft.file_path); const blobUrl = URL.createObjectURL(blob); const fileName = `${draft.title}.${draft.file_path.split('.').pop()}`; const a = document.createElement('a'); a.href = blobUrl; a.download = fileName; document.body.appendChild(a); a.click(); setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(blobUrl); }, 100); toastService.success('文件下载成功'); } catch (error) { toastService.error('下载失败'); } }; // 完成起草(下载 + 删除) const handleComplete = async () => { await handleExportDocument(); setTimeout(() => { const formData = new FormData(); formData.append('_action', 'delete'); fetcher.submit(formData, { method: 'post' }); }, 500); }; // 返回(删除草稿) const handleBack = () => { if (confirm('确定要返回吗?草稿将被删除。')) { const formData = new FormData(); formData.append('_action', 'delete'); fetcher.submit(formData, { method: 'post' }); } }; return (
); } ``` ### PlaceholderForm.tsx ```typescript interface PlaceholderFormProps { schema: PlaceholderSchema | null; values: Record; onChange: (values: Record) => void; onBatchReplace: () => void; onExportDocument: () => void; // 导出文档 onComplete: () => void; isReplacing: boolean; isDeleting: boolean; // 是否正在删除 } export function PlaceholderForm({ schema, values, onChange, onBatchReplace, onExportDocument, onComplete, isReplacing, isDeleting }: PlaceholderFormProps) { // ... return (
{/* 表单字段 */} {/* ... */} {/* 操作按钮 */}
{/* 一键替换 */} {/* 导出文档 */} {/* 完成起草 */}
); } ``` --- ## 关键技术点 ### 1. 页面关闭时发送请求 使用 `navigator.sendBeacon` 确保请求发送: ```typescript navigator.sendBeacon( `/contract-draft/${draft.id}`, formData ); ``` **优势**: - 异步发送,不阻塞页面关闭 - 浏览器保证发送(即使页面已关闭) - 不受页面卸载影响 ### 2. 组件卸载时清理 使用 `fetch` 的 `keepalive` 选项: ```typescript fetch(`/contract-draft/${draft.id}`, { method: 'POST', body: formData, keepalive: true // 关键:确保请求在页面关闭后仍然发送 }); ``` **优势**: - 即使组件已卸载,请求仍会发送 - 适用于 SPA 路由跳转场景 ### 3. 文件下载实现 使用 `axios-client` 的 `downloadFile` 方法: ```typescript const blob = await downloadFile(draft.file_path); const blobUrl = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = blobUrl; a.download = fileName; a.click(); URL.revokeObjectURL(blobUrl); // 清理内存 ``` --- ## 数据库设计 ### drafted_contracts 表 ```sql CREATE TABLE drafted_contracts ( id SERIAL PRIMARY KEY, template_id INTEGER NOT NULL, -- 关联的模板ID file_path TEXT NOT NULL, -- 文件路径(模板路径或复制路径) title TEXT NOT NULL, -- 合同标题 placeholder_values JSONB DEFAULT '{}', -- 占位符值(始终为空,不使用) status TEXT DEFAULT 'draft', -- 状态(始终为 draft) created_by INTEGER, -- 创建人ID created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); ``` **字段说明**: - `placeholder_values`:保留字段,始终为空对象 `{}` - `status`:保留字段,始终为 `'draft'` - 草稿记录仅用于跟踪当前编辑会话,离开即删除 --- ## 用户体验流程 ### 正常流程 ``` 1. 用户在模板详情页点击"起草合同" ↓ 2. 输入合同标题 ↓ 3. 进入草稿编辑页 ├─ 左侧:Collabora 实时编辑 └─ 右侧:填写占位符表单 ↓ 4. 点击"一键替换占位符" └─ 在 Collabora 中替换所有 {{占位符}} ↓ 5. 点击"导出文档" └─ 下载当前编辑的文件 ↓ 6. 继续编辑或点击"完成起草" ├─ 下载文件 └─ 删除草稿记录,返回模板列表 ``` ### 中断流程 ``` 用户编辑过程中离开页面: 方式一:关闭浏览器标签页 ↓ beforeunload 事件 └─ 自动删除草稿记录 方式二:点击浏览器返回按钮 ↓ 组件卸载 └─ 自动删除草稿记录 方式三:点击页面"返回"按钮 ↓ 弹窗确认 ├─ 确定:删除草稿,返回模板列表 └─ 取消:留在编辑页 ``` --- ## 优势总结 ### 1. 简化了数据管理 - ✅ 不保存占位符值到数据库 - ✅ 不需要更新草稿状态 - ✅ 不需要查询草稿列表 - ✅ 草稿记录仅用于会话跟踪 ### 2. 更好的用户体验 - ✅ 随时导出当前编辑的文件 - ✅ 离开页面自动清理 - ✅ 无需手动删除草稿 - ✅ 操作流程更清晰 ### 3. 系统维护简单 - ✅ 无需担心草稿堆积 - ✅ 无需定期清理过期草稿 - ✅ 数据库存储压力小 - ✅ 业务逻辑简单 --- ## 待实施任务 ### 1. 数据库迁移 ```bash psql -U postgres -d docreview \i database/migrations/001_create_drafted_contracts.sql ``` ### 2. 配置测试模板 在模板文档中添加占位符: - `{{甲方名称}}` - `{{乙方名称}}` - `{{合同金额}}` 配置 `placeholder_schema`: ```sql 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": "合同条款" } ] }'::jsonb WHERE id = 1; ``` ### 3. 功能测试 - [ ] 创建草稿 - [ ] 填写占位符表单 - [ ] 一键替换功能 - [ ] 导出文档功能 - [ ] 完成起草功能(下载 + 删除) - [ ] 返回按钮(删除) - [ ] 页面关闭自动删除 - [ ] 路由跳转自动删除 --- ## 总结 新的架构更加简洁高效: 1. ✅ **草稿是临时的** - 离开即删除 2. ✅ **不保存占位符** - 减少数据库操作 3. ✅ **自动清理** - 无需手动管理 4. ✅ **用户体验好** - 随时导出,自动清理 5. ✅ **维护成本低** - 逻辑简单,代码清晰 功能已完成,代码已通过类型检查!🎉