This commit is contained in:
2025-12-05 00:09:32 +08:00
parent bb3d22eabf
commit 3d1dbb3f97
214 changed files with 113060 additions and 1232 deletions
@@ -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 模式
**保持功能完整**:所有原有功能均已迁移
🎯 **下一步**:执行数据库迁移,配置测试模板,开始功能测试