12 KiB
12 KiB
合同起草功能 - 实施总结(V2 更新版)
业务逻辑变更
基于用户需求,功能从"保存草稿"模式调整为"临时编辑"模式:
变更对比
| 功能 | 旧逻辑(V1) | ✅ 新逻辑(V2) |
|---|---|---|
| 保存草稿 | 保存占位符值到数据库 | 导出文档(下载 MinIO 文件) |
| 完成起草 | 标记为已完成,保留记录 | 下载文件 + 删除草稿记录 |
| 离开页面 | 草稿继续保留 | 自动删除草稿记录 |
| 草稿列表 | 可查看历史草稿 | 无草稿列表(临时存在) |
| 数据持久化 | 保存 placeholder_values | 不保存,仅跟踪会话 |
实施的功能
✅ 已完成
-
草稿创建
- 用户点击"起草合同"创建临时草稿记录
- 记录包含:标题、文件路径、模板ID
- 不保存占位符值
-
占位符表单
- 动态渲染表单字段(基于 placeholder_schema)
- 分组显示(甲方信息、乙方信息、合同条款)
- 本地状态管理(不提交到数据库)
-
一键替换
- 遍历占位符值
- 调用 Collabora
unoCommands.replaceAll() - 实时替换文档中的
{{占位符}}
-
导出文档(新)
- 下载 MinIO 上的当前文件
- 使用
downloadFile()统一方法 - 清理文件名,触发浏览器下载
-
完成起草(更新)
- 先下载文件
- 延迟 500ms 后删除草稿记录
- 自动跳转到模板列表
-
自动清理(新)
- 页面关闭:使用
navigator.sendBeacon删除草稿 - 路由跳转:使用
fetch+keepalive删除草稿 - 点击返回:弹窗确认后删除草稿
- 页面关闭:使用
❌ 已移除
- 不再保存占位符值updatePlaceholders()- 不再更新状态completeDraft()- 不需要草稿列表getDraftsByUser()
➕ 新增功能
-
deleteDraft()- 删除草稿记录
- 权限验证(只能删除自己的)
- 使用
postgrestDelete
-
handleExportDocument()- 下载 MinIO 文件
- 创建 Blob URL
- 触发浏览器下载
-
自动清理机制
beforeunload事件监听- 组件卸载清理
keepalive和sendBeacon技术
技术实现
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: 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. 数据库迁移
# 连接数据库
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: 在以下情况下自动删除:
- 用户点击"完成起草"
- 用户点击"返回"按钮并确认
- 用户关闭浏览器标签页
- 用户通过浏览器返回按钮离开
- 用户在地址栏输入其他URL
Q2: 占位符值保存在哪里?
A: 占位符值仅保存在前端组件的本地状态中,不会提交到数据库。数据库中的 placeholder_values 字段始终为空对象 {}。
Q3: 如果用户关闭页面前网络断开,草稿会被删除吗?
A: 使用了两种机制确保删除:
navigator.sendBeacon- 浏览器会排队等待网络恢复后发送fetch+keepalive- 即使页面关闭也会尝试发送
但如果网络长时间不可用,草稿可能保留。可以通过定时任务清理超过一定时间(如 1 小时)的草稿记录。
Q4: "导出文档"和"完成起草"有什么区别?
A:
- 导出文档:只下载文件,草稿记录继续保留,用户可以继续编辑
- 完成起草:下载文件 + 删除草稿记录 + 返回模板列表
Q5: 如何启用文件复制功能?
A: 参考 docs/minio-file-copy-implementation.md,需要:
- 安装
npm install minio - 配置环境变量(MINIO_ENDPOINT, MINIO_ACCESS_KEY 等)
- 实现
copyMinioFile()函数 - 在创建草稿时先复制文件,然后传递
draftFilePath
总结
✅ 功能已完成:
- 草稿临时管理
- 导出文档功能
- 自动清理机制
- 完整的 Remix 集成
✅ 代码质量:
- TypeScript 类型检查通过
- 符合 Remix 最佳实践
- 代码结构清晰
- 注释完整
✅ 用户体验:
- 操作流程简单
- 自动清理无需手动管理
- 随时导出当前编辑
- 错误提示友好
🎉 可以开始测试和部署了!