483 lines
12 KiB
Markdown
483 lines
12 KiB
Markdown
# 合同起草功能 - 实施总结(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; // 改名:是否正在删除
|
||
}
|
||
|
||
// 按钮
|
||
<button onClick={onBatchReplace}>一键替换占位符</button>
|
||
<button onClick={onExportDocument}>
|
||
<i className="ri-download-line"></i>
|
||
导出文档
|
||
</button>
|
||
<button onClick={onComplete}>
|
||
{isDeleting ? '处理中...' : '完成起草'}
|
||
</button>
|
||
```
|
||
|
||
---
|
||
|
||
## 关键技术点
|
||
|
||
### 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()`<br>❌ 删除 `updatePlaceholders()`<br>❌ 删除 `completeDraft()`<br>❌ 删除 `getDraftsByUser()` |
|
||
| `app/routes/contract-draft.$draftId.tsx` | 🔄 action 只处理删除<br>➕ 添加 `handleExportDocument()`<br>🔄 修改 `handleComplete()`<br>🔄 修改 `handleBack()`<br>➕ 添加自动清理机制 |
|
||
| `app/components/contracts/PlaceholderForm.tsx` | 🔄 props: `onSaveDraft` → `onExportDocument`<br>🔄 props: `isSaving` → `isDeleting`<br>🔄 按钮文案:保存草稿 → 导出文档 |
|
||
|
||
### 删除的文件
|
||
|
||
| 文件 | 原因 |
|
||
|------|------|
|
||
| ❌ `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 最佳实践
|
||
- 代码结构清晰
|
||
- 注释完整
|
||
|
||
✅ **用户体验**:
|
||
- 操作流程简单
|
||
- 自动清理无需手动管理
|
||
- 随时导出当前编辑
|
||
- 错误提示友好
|
||
|
||
🎉 **可以开始测试和部署了!**
|