all in
This commit is contained in:
@@ -0,0 +1,450 @@
|
||||
# 合同起草功能 - 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 模式
|
||||
✅ **保持功能完整**:所有原有功能均已迁移
|
||||
|
||||
🎯 **下一步**:执行数据库迁移,配置测试模板,开始功能测试
|
||||
Reference in New Issue
Block a user