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

451 lines
11 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 模式迁移完成
## 概述
已完成从独立 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 模式
**保持功能完整**:所有原有功能均已迁移
🎯 **下一步**:执行数据库迁移,配置测试模板,开始功能测试