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

685 lines
16 KiB
Markdown
Raw Permalink 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.
# 合同起草功能 - 最终架构说明
## 架构概览
基于 Remix 框架的最佳实践,所有功能通过页面路由的 **loader/action** 函数实现,业务逻辑封装在 **service 层**
---
## 核心原则
### ✅ Remix 最佳实践
1. **不使用独立 API 路由**
-~~`app/routes/api.contracts.draft.tsx`~~
-~~`app/routes/api.contracts.draft.$id.placeholders.tsx`~~
-~~`app/routes/api.contracts.draft.$id.complete.tsx`~~
-~~`app/routes/api.files.copy.tsx`~~
2. **使用 loader/action 模式**
-`contract-template.detail.$id.tsx` - action 函数处理草稿创建
-`contract-draft.$draftId.tsx` - loader 加载数据,action 处理保存/完成
3. **业务逻辑集中在 Service 层**
-`app/api/contracts/draft-service.server.ts` - 所有业务逻辑
---
## 文件结构
```
app/
├── routes/
│ ├── contract-template.detail.$id.tsx # 模板详情页(包含 action 创建草稿)
│ └── contract-draft.$draftId.tsx # 草稿编辑页(loader + action
├── api/
│ └── contracts/
│ └── draft-service.server.ts # 业务逻辑层
├── components/
│ └── contracts/
│ └── PlaceholderForm.tsx # 占位符表单组件
└── types/
└── contract-draft.ts # 类型定义
```
---
## 数据流架构
### 1. 创建草稿流程
```
用户在模板详情页点击"起草合同"
contract-template.detail.$id.tsx
├─ 组件:handleStartDraft()
│ ├─ 用户输入标题
│ ├─ 创建 FormData
│ └─ submit(formData, { method: 'post' })
↓ Remix 自动调用
├─ action({ request, params })
│ ├─ 获取用户信息和 JWT
│ ├─ 解析表单数据(title, draftFilePath?
│ └─ 调用 createDraftContract()
draft-service.server.ts
├─ createDraftContract()
│ ├─ 查询模板信息(postgrestGet
│ ├─ 确定文件路径(模板路径 or 复制路径)
│ └─ 创建草稿记录(postgrestPost
返回 redirect(`/contract-draft/${draft.id}`)
自动跳转到草稿编辑页
```
### 2. 加载草稿页面流程
```
访问 /contract-draft/:draftId
contract-draft.$draftId.tsx
├─ loader({ params, request })
│ ├─ 获取用户信息和 JWT
│ ├─ 调用 getDraftById(draftId, userId, jwt)
│ ├─ 调用 postgrestGet 获取模板信息
│ └─ 返回 { draft, template }
渲染页面
├─ 左侧:FilePreviewCollabora 编辑器)
└─ 右侧:PlaceholderForm(占位符表单)
```
### 3. 保存草稿流程
```
用户点击"保存草稿"
handleSaveDraft()
├─ 创建 FormData
├─ 添加 _action: 'save'
├─ 添加 placeholders: JSON.stringify(values)
└─ fetcher.submit(formData, { method: 'post' })
action({ request, params })
├─ 获取 JWT
├─ 解析 _action 类型
├─ 调用 updatePlaceholders(draftId, placeholders, userId, jwt)
└─ 返回 { success: true, message: '草稿已保存' }
useEffect 监听 fetcher.data
└─ toastService.success('草稿已保存')
```
### 4. 完成起草流程
```
用户点击"完成起草"
handleComplete()
├─ 创建 FormData
├─ 添加 _action: 'complete'
└─ fetcher.submit(formData, { method: 'post' })
action({ request, params })
├─ 获取 JWT
├─ 解析 _action 类型
├─ 调用 completeDraft(draftId, userId, jwt)
└─ 返回 { success: true, message: '起草完成' }
useEffect 监听 fetcher.data
├─ toastService.success('起草完成!')
└─ 延迟跳转到 /contract-template
```
### 5. 一键替换占位符流程
```
用户点击"一键替换"
handleBatchReplace()
├─ 获取 CollaboraViewer 引用
├─ 遍历 placeholderValues
│ ├─ 对每个占位符调用 unoCommands.replaceAll()
│ └─ 添加 100ms 延迟避免冲突
└─ 自动调用 handleSaveDraft()
```
---
## Service 层实现
### draft-service.server.ts
```typescript
import { postgrestGet, postgrestPost, postgrestPut } from '~/api/postgrest-client';
import type { DraftedContract, CreateDraftRequest } from '~/types/contract-draft';
/**
* 生成草稿文件路径
*/
export function generateDraftFilePath(
templateFilePath: string,
userId: number,
templateId: number
): string {
const timestamp = Date.now();
const fileExtension = templateFilePath.split('.').pop() || 'docx';
const newFileName = `contract_${templateId}_${userId}_${timestamp}.${fileExtension}`;
return `drafts/${newFileName}`;
}
/**
* 复制 MinIO 文件(预留实现)
*/
export async function copyMinioFile(
sourceFilePath: string,
targetFilePath: string,
bucket: string = 'docauditai'
): Promise<boolean> {
// TODO: 实现 MinIO 文件复制
console.warn('[Draft Service] ⚠️ 文件复制功能尚未实现,请实施 MinIO 集成');
return true;
}
/**
* 创建草稿记录
*/
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. 创建草稿记录
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 updatePlaceholders(
draftId: number,
placeholders: Record<string, string>,
userId: number,
jwt?: string
): Promise<void> {
await postgrestPut('drafted_contracts', {
body: {
placeholder_values: placeholders,
updated_at: new Date().toISOString()
},
filter: {
id: `eq.${draftId}`,
created_by: `eq.${userId}`
},
token: jwt
});
}
/**
* 完成起草
*/
export async function completeDraft(
draftId: number,
userId: number,
jwt?: string
): Promise<void> {
await postgrestPut('drafted_contracts', {
body: {
status: 'completed',
updated_at: new Date().toISOString()
},
filter: {
id: `eq.${draftId}`,
created_by: `eq.${userId}`
},
token: jwt
});
}
/**
* 获取草稿详情
*/
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;
}
/**
* 获取用户的草稿列表
*/
export async function getDraftsByUser(
userId: number,
status?: string,
jwt?: string
): Promise<DraftedContract[]> {
const filter: Record<string, string> = {
created_by: `eq.${userId}`
};
if (status) {
filter.status = `eq.${status}`;
}
const response = await postgrestGet('drafted_contracts', {
select: '*',
filter,
order: 'created_at.desc',
token: jwt
});
return drafts;
}
```
---
## 路由实现
### contract-template.detail.$id.tsx
```typescript
import { json, redirect } from '@remix-run/node';
import { useSubmit } from '@remix-run/react';
import { createDraftContract } from '~/api/contracts/draft-service.server';
import { getUserSession } from '~/api/login/auth.server';
/**
* Action 函数:处理起草合同请求
*/
export async function action({ request, params }: ActionFunctionArgs) {
const templateId = parseInt(params.id || '0');
// 获取用户信息和 JWT
const { userInfo, frontendJWT } = await getUserSession(request);
if (!userInfo?.sub) {
return json({ error: '未登录' }, { status: 401 });
}
// 解析表单数据
const formData = await request.formData();
const title = formData.get('title') as string;
const draftFilePath = formData.get('draftFilePath') as string | null;
// 调用 service 层创建草稿
const draft = await createDraftContract(
{ templateId, title, draftFilePath: draftFilePath || undefined },
parseInt(userInfo.sub),
draftFilePath || undefined,
frontendJWT || undefined
);
// 重定向到草稿编辑页面
return redirect(`/contract-draft/${draft.id}`);
}
export default function ContractTemplateDetail() {
const submit = useSubmit();
const handleStartDraft = () => {
const title = prompt('请输入合同标题:', defaultTitle);
if (!title) return;
const formData = new FormData();
formData.append('title', title.trim());
// 可选:如果需要复制文件
// formData.append('draftFilePath', draftFilePath);
submit(formData, { method: 'post' });
};
return (
<div>
{/* ... 其他内容 ... */}
<button onClick={handleStartDraft}>
</button>
</div>
);
}
```
### contract-draft.$draftId.tsx
```typescript
import { json } from '@remix-run/node';
import { useLoaderData, useFetcher } from '@remix-run/react';
import { useEffect } from 'react';
import {
getDraftById,
updatePlaceholders,
completeDraft
} from '~/api/contracts/draft-service.server';
import { getUserSession } from '~/api/login/auth.server';
/**
* Loader 函数:加载草稿数据
*/
export async function loader({ params, request }: LoaderFunctionArgs) {
const draftId = parseInt(params.draftId || '0');
// 获取用户信息和 JWT
const { userInfo, frontendJWT } = await getUserSession(request);
if (!userInfo?.sub) {
throw new Response('未登录', { status: 401 });
}
const jwt = frontendJWT || undefined;
// 获取草稿信息
const draft = await getDraftById(draftId, parseInt(userInfo.sub), jwt);
if (!draft) {
throw new Response('草稿不存在', { status: 404 });
}
// 获取模板信息
const { postgrestGet } = await import('~/api/postgrest-client');
const templateResult = await postgrestGet('contract_templates', {
select: '*',
filter: { id: `eq.${draft.template_id}` },
token: jwt
});
const template = Array.isArray(templateResult.data)
? templateResult.data[0]
: templateResult.data;
return json({ draft, template });
}
/**
* Action 函数:处理保存和完成
*/
export async function action({ request, params }: ActionFunctionArgs) {
const draftId = parseInt(params.draftId || '0');
// 获取用户信息和 JWT
const { userInfo, frontendJWT } = await getUserSession(request);
if (!userInfo?.sub) {
return json({ error: '未登录' }, { status: 401 });
}
const userId = parseInt(userInfo.sub);
const jwt = frontendJWT || undefined;
// 解析表单数据
const formData = await request.formData();
const actionType = formData.get('_action') as string;
if (actionType === 'save') {
// 保存占位符值
const placeholdersJson = formData.get('placeholders') as string;
const placeholders = JSON.parse(placeholdersJson);
await updatePlaceholders(draftId, placeholders, userId, jwt);
return json({ success: true, message: '草稿已保存' });
} else if (actionType === 'complete') {
// 完成起草
await completeDraft(draftId, userId, jwt);
return json({ success: true, message: '起草完成' });
}
return json({ error: '无效的操作类型' }, { status: 400 });
}
export default function ContractDraftPage() {
const { draft, template } = useLoaderData<typeof loader>();
const fetcher = useFetcher<ActionData>();
const [placeholderValues, setPlaceholderValues] = useState(
draft.placeholder_values || {}
);
// 处理 fetcher 响应
useEffect(() => {
if (fetcher.data?.success) {
toastService.success(fetcher.data.message);
if (fetcher.data.message === '起草完成') {
setTimeout(() => navigate('/contract-template'), 1500);
}
} else if (fetcher.data?.error) {
toastService.error(fetcher.data.error);
}
}, [fetcher.data]);
// 保存草稿
const handleSaveDraft = () => {
const formData = new FormData();
formData.append('_action', 'save');
formData.append('placeholders', JSON.stringify(placeholderValues));
fetcher.submit(formData, { method: 'post' });
};
// 完成起草
const handleComplete = () => {
const formData = new FormData();
formData.append('_action', 'complete');
fetcher.submit(formData, { method: 'post' });
};
return (
<div className="flex h-screen">
{/* 左侧:文档预览(60% */}
<div className="w-[60%]">
<FilePreview
fileContent={{ path: draft.file_path }}
isTemplate={false}
/>
</div>
{/* 右侧:占位符表单(40% */}
<div className="w-[40%]">
<PlaceholderForm
schema={template.placeholder_schema}
values={placeholderValues}
onChange={setPlaceholderValues}
onBatchReplace={handleBatchReplace}
onSaveDraft={handleSaveDraft}
onComplete={handleComplete}
isSaving={fetcher.state !== 'idle'}
/>
</div>
</div>
);
}
```
---
## 文件复制策略
### 策略一:直接使用模板文件(默认,已实现)
```typescript
// 不传 draftFilePath
const draft = await createDraftContract(
{ templateId, title },
userId,
undefined, // 不传 draftFilePath
jwt
);
// draft.file_path = template.file_path
```
### 策略二:复制模板文件(可选,预留实现)
```typescript
// 1. 生成目标路径
const targetFilePath = generateDraftFilePath(
template.file_path,
userId,
templateId
);
// 2. 调用文件复制(预留实现)
const success = await copyMinioFile(
template.file_path,
targetFilePath,
'docauditai'
);
// 3. 创建草稿记录时传递复制后的路径
const draft = await createDraftContract(
{ templateId, title, draftFilePath: targetFilePath },
userId,
targetFilePath,
jwt
);
// draft.file_path = targetFilePath
```
---
## 优势总结
### 1. 符合 Remix 规范
- ✅ 使用 loader/action 而不是独立 API 路由
- ✅ 利用 Remix 的自动重新验证
- ✅ 更好的 TypeScript 类型推导
- ✅ 更简洁的代码结构
### 2. 业务逻辑清晰
- ✅ Service 层专注业务逻辑
- ✅ Route 层专注数据流转
- ✅ Component 层专注 UI 渲染
- ✅ 职责分离,易于维护
### 3. 性能优化
- ✅ Remix 自动优化数据预取
- ✅ 减少网络请求数量
- ✅ 利用 Remix 的缓存机制
- ✅ 自动处理加载状态
### 4. 用户体验
- ✅ useFetcher 自动管理加载状态
- ✅ 无需刷新页面即可更新数据
- ✅ 自动错误处理和重试
- ✅ 乐观更新支持
---
## 待实施任务
### 1. 数据库迁移
```bash
psql -U postgres -d docreview
\i database/migrations/001_create_drafted_contracts.sql
```
### 2. 配置测试模板
`contract_templates` 表中添加 `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; -- 替换为实际的模板ID
```
### 3. 功能测试
- [ ] 创建草稿流程
- [ ] 占位符表单渲染
- [ ] 一键替换功能
- [ ] 保存草稿功能
- [ ] 完成起草功能
- [ ] 跳转和导航
### 4. (可选)实现 MinIO 文件复制
参考 `docs/minio-file-copy-implementation.md`
---
## 总结
当前架构完全符合 Remix 框架最佳实践:
1.**无独立 API 路由** - 所有功能通过 loader/action 实现
2.**业务逻辑在 Service 层** - draft-service.server.ts 统一管理
3.**文件复制预留实现** - copyMinioFile 函数已准备好
4.**类型安全** - 完整的 TypeScript 类型定义
5.**JWT 认证集成** - 所有接口都支持 JWT 认证
代码结构清晰、易于维护、符合框架规范!