685 lines
16 KiB
Markdown
685 lines
16 KiB
Markdown
# 合同起草功能 - 最终架构说明
|
||
|
||
## 架构概览
|
||
|
||
基于 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 }
|
||
│
|
||
↓
|
||
渲染页面
|
||
├─ 左侧:FilePreview(Collabora 编辑器)
|
||
└─ 右侧: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 认证
|
||
|
||
代码结构清晰、易于维护、符合框架规范!
|