# 合同起草功能 - 实施总结(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 最佳实践
- 代码结构清晰
- 注释完整
✅ **用户体验**:
- 操作流程简单
- 自动清理无需手动管理
- 随时导出当前编辑
- 错误提示友好
🎉 **可以开始测试和部署了!**