451 lines
11 KiB
Markdown
451 lines
11 KiB
Markdown
# 合同起草功能 - Remix 模式迁移完成
|
||
|
||
## 概述
|
||
|
||
已完成从独立 API 路由到 Remix loader/action 模式的迁移,使代码更符合 Remix 框架规范。
|
||
|
||
---
|
||
|
||
## 主要变更
|
||
|
||
### 1. 移除独立 API 路由
|
||
|
||
**删除的文件**:
|
||
- ~~`app/routes/api.contracts.draft.tsx`~~ → 功能已集成到 `contract-template.detail.$id.tsx`
|
||
- ~~`app/routes/api.contracts.draft.$id.placeholders.tsx`~~ → 功能已集成到 `contract-draft.$draftId.tsx`
|
||
- ~~`app/routes/api.contracts.draft.$id.complete.tsx`~~ → 功能已集成到 `contract-draft.$draftId.tsx`
|
||
|
||
### 2. 文件复制接口整合
|
||
|
||
**文件**:`app/api/contracts/draft-service.server.ts`
|
||
|
||
已将 MinIO 文件复制功能添加到 draft-service.server.ts:
|
||
|
||
```typescript
|
||
/**
|
||
* 复制 MinIO 文件(预留实现)
|
||
*/
|
||
export async function copyMinioFile(
|
||
sourceFilePath: string,
|
||
targetFilePath: string,
|
||
bucket: string = 'docauditai'
|
||
): Promise<boolean> {
|
||
// TODO: 实现 MinIO 文件复制
|
||
// 1. 安装 minio SDK: npm install minio
|
||
// 2. 创建 MinIO 客户端实例
|
||
// 3. 调用 copyObject 方法复制文件
|
||
|
||
console.warn('[Draft Service] ⚠️ 文件复制功能尚未实现,请实施 MinIO 集成');
|
||
return true;
|
||
}
|
||
```
|
||
|
||
### 3. 模板详情页改造
|
||
|
||
**文件**:`app/routes/contract-template.detail.$id.tsx`
|
||
|
||
**变更内容**:
|
||
|
||
#### 新增导入
|
||
```typescript
|
||
import type { ActionFunctionArgs } from '@remix-run/node';
|
||
import { json, redirect } from '@remix-run/node';
|
||
import { useSubmit } from '@remix-run/react';
|
||
import { createDraftContract } from '~/api/contracts/draft-service.server';
|
||
```
|
||
|
||
#### 新增 action 函数
|
||
```typescript
|
||
export async function action({ request, params }: ActionFunctionArgs) {
|
||
const templateId = parseInt(params.id || '0');
|
||
|
||
// 获取用户信息
|
||
const { userInfo } = await getUserSession(request);
|
||
if (!userInfo?.sub) {
|
||
return json({ error: '未登录' }, { status: 401 });
|
||
}
|
||
|
||
try {
|
||
// 解析表单数据
|
||
const formData = await request.formData();
|
||
const title = formData.get('title') as string;
|
||
const draftFilePath = formData.get('draftFilePath') as string | null;
|
||
|
||
// 创建草稿记录
|
||
const draft = await createDraftContract(
|
||
{
|
||
templateId,
|
||
title,
|
||
draftFilePath: draftFilePath || undefined
|
||
},
|
||
parseInt(userInfo.sub),
|
||
draftFilePath || undefined
|
||
);
|
||
|
||
// 重定向到草稿编辑页面
|
||
return redirect(`/contract-draft/${draft.id}`);
|
||
} catch (error) {
|
||
console.error('[Template Detail] 创建草稿失败:', error);
|
||
return json(
|
||
{ error: error instanceof Error ? error.message : '创建草稿失败' },
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 修改 handleStartDraft 函数
|
||
```typescript
|
||
// 旧实现:使用 fetch API
|
||
const handleStartDraft = async () => {
|
||
const response = await fetch('/api/contracts/draft', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ templateId, title })
|
||
});
|
||
// ...
|
||
};
|
||
|
||
// 新实现:使用 Remix submit
|
||
const submit = useSubmit();
|
||
|
||
const handleStartDraft = () => {
|
||
if (isCreatingDraft) return;
|
||
|
||
const defaultTitle = `${template.title}-${new Date().toLocaleDateString('zh-CN').replace(/\//g, '')}`;
|
||
const title = prompt('请输入合同标题:', defaultTitle);
|
||
if (!title) return;
|
||
|
||
setIsCreatingDraft(true);
|
||
|
||
const formData = new FormData();
|
||
formData.append('title', title.trim());
|
||
// 可选:如果需要复制文件,添加 draftFilePath
|
||
// formData.append('draftFilePath', draftFilePath);
|
||
|
||
submit(formData, { method: 'post' });
|
||
};
|
||
```
|
||
|
||
### 4. 草稿编辑页改造
|
||
|
||
**文件**:`app/routes/contract-draft.$draftId.tsx`
|
||
|
||
**变更内容**:
|
||
|
||
#### 新增导入
|
||
```typescript
|
||
import type { ActionFunctionArgs } from '@remix-run/node';
|
||
import { redirect } from '@remix-run/node';
|
||
import { useFetcher } from '@remix-run/react';
|
||
import { useEffect } from 'react';
|
||
import { updatePlaceholders, completeDraft } from '~/api/contracts/draft-service.server';
|
||
```
|
||
|
||
#### 新增 action 函数
|
||
```typescript
|
||
/**
|
||
* Action 函数:处理保存占位符和完成起草
|
||
*/
|
||
export async function action({ request, params }: ActionFunctionArgs) {
|
||
const draftId = parseInt(params.draftId || '0');
|
||
|
||
if (!draftId) {
|
||
return json({ error: '草稿ID无效' }, { status: 400 });
|
||
}
|
||
|
||
// 获取用户信息
|
||
const { userInfo } = await getUserSession(request);
|
||
if (!userInfo?.sub) {
|
||
return json({ error: '未登录' }, { status: 401 });
|
||
}
|
||
|
||
const userId = parseInt(userInfo.sub);
|
||
|
||
try {
|
||
// 解析表单数据
|
||
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);
|
||
|
||
return json({ success: true, message: '草稿已保存' });
|
||
} else if (actionType === 'complete') {
|
||
// 完成起草
|
||
await completeDraft(draftId, userId);
|
||
|
||
return json({ success: true, message: '起草完成' });
|
||
}
|
||
|
||
return json({ error: '无效的操作类型' }, { status: 400 });
|
||
} catch (error) {
|
||
console.error('[Action] 操作失败:', error);
|
||
return json(
|
||
{ error: error instanceof Error ? error.message : '操作失败' },
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 修改组件逻辑
|
||
|
||
**使用 useFetcher**:
|
||
```typescript
|
||
const fetcher = useFetcher();
|
||
const isSaving = fetcher.state !== 'idle';
|
||
```
|
||
|
||
**添加响应处理**:
|
||
```typescript
|
||
// 处理 fetcher 响应
|
||
useEffect(() => {
|
||
if (fetcher.data) {
|
||
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, navigate]);
|
||
```
|
||
|
||
**修改 handleSaveDraft**:
|
||
```typescript
|
||
// 旧实现:使用 fetch API
|
||
const handleSaveDraft = async () => {
|
||
setIsSaving(true);
|
||
try {
|
||
const response = await fetch(`/api/contracts/draft/${draft.id}/placeholders`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ placeholders: placeholderValues })
|
||
});
|
||
// ...
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
};
|
||
|
||
// 新实现:使用 fetcher.submit
|
||
const handleSaveDraft = async () => {
|
||
const formData = new FormData();
|
||
formData.append('_action', 'save');
|
||
formData.append('placeholders', JSON.stringify(placeholderValues));
|
||
|
||
fetcher.submit(formData, { method: 'post' });
|
||
};
|
||
```
|
||
|
||
**修改 handleComplete**:
|
||
```typescript
|
||
// 旧实现:使用 fetch API
|
||
const handleComplete = async () => {
|
||
try {
|
||
const response = await fetch(`/api/contracts/draft/${draft.id}/complete`, {
|
||
method: 'POST'
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('完成起草失败');
|
||
}
|
||
|
||
toastService.success('起草完成!');
|
||
|
||
setTimeout(() => {
|
||
navigate('/contract-template');
|
||
}, 1500);
|
||
} catch (error) {
|
||
console.error('[Draft] 完成起草失败:', error);
|
||
toastService.error('完成起草失败');
|
||
}
|
||
};
|
||
|
||
// 新实现:使用 fetcher.submit
|
||
const handleComplete = async () => {
|
||
const formData = new FormData();
|
||
formData.append('_action', 'complete');
|
||
|
||
fetcher.submit(formData, { method: 'post' });
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## Remix 模式优势
|
||
|
||
### 1. 更符合框架规范
|
||
- 使用 loader/action 而不是单独的 API 路由
|
||
- 遵循 Remix 的数据流模式
|
||
|
||
### 2. 更好的类型安全
|
||
- action 函数与组件在同一文件
|
||
- TypeScript 类型推导更准确
|
||
- 使用 `typeof loader` 和 `typeof action` 进行类型推导
|
||
|
||
### 3. 更简洁的代码
|
||
- 减少文件数量(从 6 个路由文件减少到 2 个)
|
||
- 减少重复的认证逻辑
|
||
- 统一的错误处理
|
||
|
||
### 4. 更好的用户体验
|
||
- `useFetcher` 自动处理加载状态
|
||
- 不需要手动管理 `isSaving` 状态
|
||
- 自动重新验证数据
|
||
|
||
### 5. 更好的性能
|
||
- Remix 自动优化数据预取
|
||
- 减少不必要的网络请求
|
||
- 利用 Remix 的缓存机制
|
||
|
||
---
|
||
|
||
## 数据流对比
|
||
|
||
### 旧模式(独立 API 路由)
|
||
|
||
```
|
||
用户点击"起草合同"
|
||
↓
|
||
前端:发送 fetch 请求到 /api/contracts/draft
|
||
↓
|
||
后端:api.contracts.draft.tsx 处理请求
|
||
↓
|
||
调用 createDraftContract
|
||
↓
|
||
返回 JSON 响应
|
||
↓
|
||
前端:解析响应,手动跳转
|
||
```
|
||
|
||
### 新模式(Remix action)
|
||
|
||
```
|
||
用户点击"起草合同"
|
||
↓
|
||
前端:submit(formData, { method: 'post' })
|
||
↓
|
||
Remix:调用 action 函数(同一文件)
|
||
↓
|
||
调用 createDraftContract
|
||
↓
|
||
返回 redirect() 或 json()
|
||
↓
|
||
Remix:自动处理跳转/重新验证
|
||
```
|
||
|
||
---
|
||
|
||
## 使用示例
|
||
|
||
### 创建草稿
|
||
|
||
```typescript
|
||
// 在模板详情页点击"起草合同"
|
||
const handleStartDraft = () => {
|
||
const title = prompt('请输入合同标题:', defaultTitle);
|
||
if (!title) return;
|
||
|
||
const formData = new FormData();
|
||
formData.append('title', title.trim());
|
||
|
||
submit(formData, { method: 'post' });
|
||
};
|
||
```
|
||
|
||
### 保存草稿
|
||
|
||
```typescript
|
||
// 在草稿编辑页点击"保存草稿"
|
||
const handleSaveDraft = () => {
|
||
const formData = new FormData();
|
||
formData.append('_action', 'save');
|
||
formData.append('placeholders', JSON.stringify(placeholderValues));
|
||
|
||
fetcher.submit(formData, { method: 'post' });
|
||
};
|
||
```
|
||
|
||
### 完成起草
|
||
|
||
```typescript
|
||
// 在草稿编辑页点击"完成起草"
|
||
const handleComplete = () => {
|
||
const formData = new FormData();
|
||
formData.append('_action', 'complete');
|
||
|
||
fetcher.submit(formData, { method: 'post' });
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## 待实施任务
|
||
|
||
### 1. 数据库迁移
|
||
|
||
执行 SQL 脚本:
|
||
|
||
```bash
|
||
psql -U postgres -d docreview
|
||
\i database/migrations/001_create_drafted_contracts.sql
|
||
```
|
||
|
||
### 2. 配置测试模板
|
||
|
||
在 `contract_templates` 表中添加 `placeholder_schema`:
|
||
|
||
```json
|
||
{
|
||
"fields": [
|
||
{
|
||
"key": "甲方名称",
|
||
"label": "甲方名称",
|
||
"type": "text",
|
||
"required": true,
|
||
"group": "甲方信息"
|
||
},
|
||
{
|
||
"key": "合同金额",
|
||
"label": "合同金额(元)",
|
||
"type": "number",
|
||
"required": true,
|
||
"group": "合同条款"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
### 3. (可选)实现 MinIO 文件复制
|
||
|
||
参考文档:`docs/minio-file-copy-implementation.md`
|
||
|
||
---
|
||
|
||
## 总结
|
||
|
||
✅ **完成项**:
|
||
1. 移除独立 API 路由
|
||
2. 集成 action 函数到页面组件
|
||
3. 使用 useFetcher 和 useSubmit
|
||
4. 添加响应处理逻辑
|
||
5. 文件复制接口预留
|
||
|
||
✅ **代码更清晰**:从 6 个文件减少到 2 个核心文件
|
||
✅ **更符合 Remix 规范**:使用 loader/action 模式
|
||
✅ **保持功能完整**:所有原有功能均已迁移
|
||
|
||
🎯 **下一步**:执行数据库迁移,配置测试模板,开始功能测试
|