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

16 KiB
Raw Permalink Blame History

合同起草功能 - 最终架构说明

架构概览

基于 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 }
  │
  ↓
渲染页面
  ├─ 左侧:FilePreviewCollabora 编辑器)
  └─ 右侧: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

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

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

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>
  );
}

文件复制策略

策略一:直接使用模板文件(默认,已实现)

// 不传 draftFilePath
const draft = await createDraftContract(
  { templateId, title },
  userId,
  undefined,  // 不传 draftFilePath
  jwt
);

// draft.file_path = template.file_path

策略二:复制模板文件(可选,预留实现)

// 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. 数据库迁移

psql -U postgres -d docreview
\i database/migrations/001_create_drafted_contracts.sql

2. 配置测试模板

contract_templates 表中添加 placeholder_schema

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 认证

代码结构清晰、易于维护、符合框架规范!