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

15 KiB
Raw Blame History

合同起草功能 - 更新后的架构说明

业务逻辑调整

基于新的业务需求,草稿功能调整为临时编辑模式

新的业务逻辑

  1. "保存草稿""导出文档"

    • 功能:下载 MinIO 上的当前文件
    • 不保存占位符值到数据库
  2. "完成起草"

    • 功能:下载文件 + 删除草稿记录
    • 操作完成后返回模板列表
  3. 关闭页面/返回

    • 自动删除草稿记录
    • 草稿是临时的,不保留
  4. 草稿记录策略

    • 临时存在,仅用于跟踪编辑会话
    • 离开页面即删除
    • 不持久化占位符值

架构变更

删除的功能

- updatePlaceholders()     # 不再保存占位符值
- completeDraft()           # 不再更新状态为 completed
- getDraftsByUser()         # 不需要查询草稿列表

新增的功能

+ deleteDraft()             # 删除草稿记录
+ handleExportDocument()    # 下载 MinIO 文件
+ beforeunload 监听         # 页面关闭时删除草稿
+ 组件卸载清理              # 路由跳转时删除草稿

文件结构

app/
├── routes/
│   ├── contract-template.detail.$id.tsx     # 模板详情页(创建草稿)
│   └── contract-draft.$draftId.tsx          # 草稿编辑页(loader + action: delete
│
├── api/
│   └── contracts/
│       └── draft-service.server.ts          # 业务逻辑
│           ├── createDraftContract()        # 创建草稿记录
│           ├── deleteDraft()                # 删除草稿记录(新增)
│           ├── getDraftById()               # 获取草稿详情
│           ├── copyMinioFile()              # 文件复制(预留)
│           └── generateDraftFilePath()      # 生成文件路径
│
└── components/
    └── contracts/
        └── PlaceholderForm.tsx              # 占位符表单
            ├── "导出文档" 按钮(新)
            └── "完成起草" 按钮

数据流

1. 创建草稿

用户点击"起草合同"
  ↓
contract-template.detail.$id.tsx
  ├─ action() 创建草稿记录
  └─ redirect 到 /contract-draft/:draftId

2. 编辑草稿

草稿编辑页
  ├─ loader() 加载草稿和模板数据
  ├─ 左侧:Collabora 编辑器(实时编辑 MinIO 文件)
  └─ 右侧:占位符表单(本地状态,不保存到数据库)

3. 导出文档

用户点击"导出文档"
  ↓
handleExportDocument()
  ├─ downloadFile(draft.file_path)
  │   └─ 从 MinIO 下载文件
  ├─ 创建 Blob URL
  └─ 触发浏览器下载

4. 完成起草

用户点击"完成起草"
  ↓
handleComplete()
  ├─ 1. 调用 handleExportDocument()
  │      └─ 下载文件
  ├─ 2. 延迟 500ms
  └─ 3. fetcher.submit({ _action: 'delete' })
         ↓
       action() 删除草稿记录
         ↓
       redirect 到 /contract-template

5. 关闭页面/返回

方式一:页面关闭(beforeunload
  ↓
handleBeforeUnload()
  └─ navigator.sendBeacon('/contract-draft/:id', { _action: 'delete' })

方式二:路由跳转(组件卸载)
  ↓
useEffect cleanup
  └─ fetch('/contract-draft/:id', { method: 'POST', keepalive: true })

方式三:点击返回按钮
  ↓
handleBack()
  ├─ confirm('确定要返回吗?草稿将被删除。')
  └─ fetcher.submit({ _action: 'delete' })

核心代码实现

draft-service.server.ts

import { postgrestGet, postgrestPost, postgrestPut, postgrestDelete } from '~/api/postgrest-client';

/**
 * 创建草稿记录(临时记录,用于跟踪编辑会话)
 */
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. 创建草稿记录(不保存 placeholder_values
  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 deleteDraft(
  draftId: number,
  userId: number,
  jwt?: string
): Promise<void> {
  await postgrestDelete('drafted_contracts', {
    filter: {
      id: `eq.${draftId}`,
      created_by: `eq.${userId}`
    },
    token: jwt
  });

  console.log('[Draft Service] 草稿已删除:', draftId);
}

/**
 * 获取草稿详情
 */
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;
}

contract-draft.$draftId.tsx

/**
 * Action 函数:只处理删除操作
 */
export async function action({ request, params }: ActionFunctionArgs) {
  const draftId = parseInt(params.draftId || '0');
  const { userInfo, frontendJWT } = await getUserSession(request);
  const userId = parseInt(userInfo.sub);
  const jwt = frontendJWT || undefined;

  const formData = await request.formData();
  const actionType = formData.get('_action') as string;

  if (actionType === 'delete') {
    await deleteDraft(draftId, userId, jwt);
    return json({ success: true, message: '草稿已删除' });
  }

  return json({ error: '无效的操作类型' }, { status: 400 });
}

export default function ContractDraftPage() {
  const { draft, template } = useLoaderData<typeof loader>();
  const navigate = useNavigate();
  const fetcher = useFetcher<ActionData>();

  const [placeholderValues, setPlaceholderValues] = useState(
    draft.placeholder_values || {}
  );

  // 监听删除成功
  useEffect(() => {
    if (fetcher.data?.success && fetcher.data.message === '草稿已删除') {
      navigate('/contract-template');
    }
  }, [fetcher.data, navigate]);

  // 监听页面关闭 - 自动删除草稿
  useEffect(() => {
    const handleBeforeUnload = () => {
      const formData = new FormData();
      formData.append('_action', 'delete');
      navigator.sendBeacon(`/contract-draft/${draft.id}`, formData);
    };

    window.addEventListener('beforeunload', handleBeforeUnload);
    return () => window.removeEventListener('beforeunload', handleBeforeUnload);
  }, [draft.id]);

  // 组件卸载时删除草稿
  useEffect(() => {
    return () => {
      const formData = new FormData();
      formData.append('_action', 'delete');
      fetch(`/contract-draft/${draft.id}`, {
        method: 'POST',
        body: formData,
        keepalive: true
      });
    };
  }, [draft.id]);

  // 导出文档(下载 MinIO 文件)
  const handleExportDocument = async () => {
    try {
      const blob = await downloadFile(draft.file_path);
      const blobUrl = URL.createObjectURL(blob);
      const fileName = `${draft.title}.${draft.file_path.split('.').pop()}`;

      const a = document.createElement('a');
      a.href = blobUrl;
      a.download = fileName;
      document.body.appendChild(a);
      a.click();

      setTimeout(() => {
        document.body.removeChild(a);
        URL.revokeObjectURL(blobUrl);
      }, 100);

      toastService.success('文件下载成功');
    } catch (error) {
      toastService.error('下载失败');
    }
  };

  // 完成起草(下载 + 删除)
  const handleComplete = async () => {
    await handleExportDocument();

    setTimeout(() => {
      const formData = new FormData();
      formData.append('_action', 'delete');
      fetcher.submit(formData, { method: 'post' });
    }, 500);
  };

  // 返回(删除草稿)
  const handleBack = () => {
    if (confirm('确定要返回吗?草稿将被删除。')) {
      const formData = new FormData();
      formData.append('_action', 'delete');
      fetcher.submit(formData, { method: 'post' });
    }
  };

  return (
    <div className="flex h-screen">
      <div className="w-[60%]">
        <FilePreview fileContent={{ path: draft.file_path }} />
      </div>

      <div className="w-[40%]">
        <PlaceholderForm
          schema={template.placeholder_schema}
          values={placeholderValues}
          onChange={setPlaceholderValues}
          onBatchReplace={handleBatchReplace}
          onExportDocument={handleExportDocument}
          onComplete={handleComplete}
          isReplacing={isReplacing}
          isDeleting={fetcher.state !== 'idle'}
        />
      </div>
    </div>
  );
}

PlaceholderForm.tsx

interface PlaceholderFormProps {
  schema: PlaceholderSchema | null;
  values: Record<string, string>;
  onChange: (values: Record<string, string>) => void;
  onBatchReplace: () => void;
  onExportDocument: () => void;  // 导出文档
  onComplete: () => void;
  isReplacing: boolean;
  isDeleting: boolean;           // 是否正在删除
}

export function PlaceholderForm({
  schema,
  values,
  onChange,
  onBatchReplace,
  onExportDocument,
  onComplete,
  isReplacing,
  isDeleting
}: PlaceholderFormProps) {
  // ...

  return (
    <div>
      {/* 表单字段 */}
      {/* ... */}

      {/* 操作按钮 */}
      <div className="space-y-3">
        {/* 一键替换 */}
        <button
          onClick={onBatchReplace}
          disabled={isReplacing || isDeleting}
        >
          一键替换占位符
        </button>

        {/* 导出文档 */}
        <button
          onClick={onExportDocument}
          disabled={isReplacing || isDeleting}
        >
          <i className="ri-download-line mr-2"></i>
          导出文档
        </button>

        {/* 完成起草 */}
        <button
          onClick={handleCompleteClick}
          disabled={isReplacing || isDeleting}
        >
          {isDeleting ? '处理中...' : '完成起草'}
        </button>
      </div>
    </div>
  );
}

关键技术点

1. 页面关闭时发送请求

使用 navigator.sendBeacon 确保请求发送:

navigator.sendBeacon(
  `/contract-draft/${draft.id}`,
  formData
);

优势

  • 异步发送,不阻塞页面关闭
  • 浏览器保证发送(即使页面已关闭)
  • 不受页面卸载影响

2. 组件卸载时清理

使用 fetchkeepalive 选项:

fetch(`/contract-draft/${draft.id}`, {
  method: 'POST',
  body: formData,
  keepalive: true  // 关键:确保请求在页面关闭后仍然发送
});

优势

  • 即使组件已卸载,请求仍会发送
  • 适用于 SPA 路由跳转场景

3. 文件下载实现

使用 axios-clientdownloadFile 方法:

const blob = await downloadFile(draft.file_path);
const blobUrl = URL.createObjectURL(blob);

const a = document.createElement('a');
a.href = blobUrl;
a.download = fileName;
a.click();

URL.revokeObjectURL(blobUrl);  // 清理内存

数据库设计

drafted_contracts 表

CREATE TABLE drafted_contracts (
  id SERIAL PRIMARY KEY,
  template_id INTEGER NOT NULL,           -- 关联的模板ID
  file_path TEXT NOT NULL,                 -- 文件路径(模板路径或复制路径)
  title TEXT NOT NULL,                     -- 合同标题
  placeholder_values JSONB DEFAULT '{}',   -- 占位符值(始终为空,不使用)
  status TEXT DEFAULT 'draft',             -- 状态(始终为 draft
  created_by INTEGER,                      -- 创建人ID
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

字段说明

  • placeholder_values:保留字段,始终为空对象 {}
  • status:保留字段,始终为 'draft'
  • 草稿记录仅用于跟踪当前编辑会话,离开即删除

用户体验流程

正常流程

1. 用户在模板详情页点击"起草合同"
   ↓
2. 输入合同标题
   ↓
3. 进入草稿编辑页
   ├─ 左侧:Collabora 实时编辑
   └─ 右侧:填写占位符表单
   ↓
4. 点击"一键替换占位符"
   └─ 在 Collabora 中替换所有 {{占位符}}
   ↓
5. 点击"导出文档"
   └─ 下载当前编辑的文件
   ↓
6. 继续编辑或点击"完成起草"
   ├─ 下载文件
   └─ 删除草稿记录,返回模板列表

中断流程

用户编辑过程中离开页面:

方式一:关闭浏览器标签页
  ↓ beforeunload 事件
  └─ 自动删除草稿记录

方式二:点击浏览器返回按钮
  ↓ 组件卸载
  └─ 自动删除草稿记录

方式三:点击页面"返回"按钮
  ↓ 弹窗确认
  ├─ 确定:删除草稿,返回模板列表
  └─ 取消:留在编辑页

优势总结

1. 简化了数据管理

  • 不保存占位符值到数据库
  • 不需要更新草稿状态
  • 不需要查询草稿列表
  • 草稿记录仅用于会话跟踪

2. 更好的用户体验

  • 随时导出当前编辑的文件
  • 离开页面自动清理
  • 无需手动删除草稿
  • 操作流程更清晰

3. 系统维护简单

  • 无需担心草稿堆积
  • 无需定期清理过期草稿
  • 数据库存储压力小
  • 业务逻辑简单

待实施任务

1. 数据库迁移

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

2. 配置测试模板

在模板文档中添加占位符:

  • {{甲方名称}}
  • {{乙方名称}}
  • {{合同金额}}

配置 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;

3. 功能测试

  • 创建草稿
  • 填写占位符表单
  • 一键替换功能
  • 导出文档功能
  • 完成起草功能(下载 + 删除)
  • 返回按钮(删除)
  • 页面关闭自动删除
  • 路由跳转自动删除

总结

新的架构更加简洁高效:

  1. 草稿是临时的 - 离开即删除
  2. 不保存占位符 - 减少数据库操作
  3. 自动清理 - 无需手动管理
  4. 用户体验好 - 随时导出,自动清理
  5. 维护成本低 - 逻辑简单,代码清晰

功能已完成,代码已通过类型检查!🎉