Files
leaudit-platform-frontend/docs/contract-drafting-updated-architecture.md
T
2025-12-05 00:09:32 +08:00

644 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 合同起草功能 - 更新后的架构说明
## 业务逻辑调整
基于新的业务需求,草稿功能调整为**临时编辑模式**:
### ✅ 新的业务逻辑
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<DraftedContract> {
// 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<void> {
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<DraftedContract | null> {
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<typeof loader>();
const navigate = useNavigate();
const fetcher = useFetcher<ActionData>();
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 (
<div className="flex h-screen">
<div className="w-[60%]">
<FilePreview fileContent={{ path: draft.file_path }} />
</div>
<div className="w-[40%]">
<PlaceholderForm
schema={template.placeholder_schema}
values={placeholderValues}
onChange={setPlaceholderValues}
onBatchReplace={handleBatchReplace}
onExportDocument={handleExportDocument}
onComplete={handleComplete}
isReplacing={isReplacing}
isDeleting={fetcher.state !== 'idle'}
/>
</div>
</div>
);
}
```
### PlaceholderForm.tsx
```typescript
interface PlaceholderFormProps {
schema: PlaceholderSchema | null;
values: Record<string, string>;
onChange: (values: Record<string, string>) => void;
onBatchReplace: () => void;
onExportDocument: () => void; // 导出文档
onComplete: () => void;
isReplacing: boolean;
isDeleting: boolean; // 是否正在删除
}
export function PlaceholderForm({
schema,
values,
onChange,
onBatchReplace,
onExportDocument,
onComplete,
isReplacing,
isDeleting
}: PlaceholderFormProps) {
// ...
return (
<div>
{/* 表单字段 */}
{/* ... */}
{/* 操作按钮 */}
<div className="space-y-3">
{/* 一键替换 */}
<button
onClick={onBatchReplace}
disabled={isReplacing || isDeleting}
>
</button>
{/* 导出文档 */}
<button
onClick={onExportDocument}
disabled={isReplacing || isDeleting}
>
<i className="ri-download-line mr-2"></i>
</button>
{/* 完成起草 */}
<button
onClick={handleCompleteClick}
disabled={isReplacing || isDeleting}
>
{isDeleting ? '处理中...' : '完成起草'}
</button>
</div>
</div>
);
}
```
---
## 关键技术点
### 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.**维护成本低** - 逻辑简单,代码清晰
功能已完成,代码已通过类型检查!🎉