Files
leaudit-platform-frontend/app/routes/contract-draft.$draftId.tsx
T

457 lines
15 KiB
TypeScript
Raw 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.
/**
* 合同起草页面
* 路由: /contract-draft/:draftId
*/
import type { LoaderFunctionArgs, MetaFunction, ActionFunctionArgs } from '@remix-run/node';
import { redirect } from '@remix-run/node';
import { useLoaderData, useNavigate, useFetcher } from '@remix-run/react';
import { useState, useRef, useEffect } from 'react';
import { FilePreview, type FilePreviewHandle } from '~/components/reviews/FilePreview';
import { PlaceholderForm } from '~/components/contracts/PlaceholderForm';
import { getDraftById, deleteDraft } from '~/api/contracts/draft-service.server';
import { getUserSession } from '~/api/login/auth.server';
import { deleteFile } from '~/api/storage/minio-client';
import { toastService } from '~/components/ui/Toast';
import { messageService } from '~/components/ui/MessageModal';
import { downloadFile } from '~/api/axios-client';
import { extractPlaceholdersFromDocx, generateDefaultSchema } from '~/api/contracts/docx-parser.server';
import path from 'path';
import type { DraftedContract, PlaceholderSchema } from '~/types/contract-draft';
import type { ContractTemplate } from '~/api/contract-template/templates';
export const meta: MetaFunction<typeof loader> = ({ data }) => {
return [
{ title: `${data?.draft.title || '合同起草'} - 智慧法务` },
{ name: 'description', content: '起草合同文档' }
];
};
interface LoaderData {
draft: DraftedContract;
template: ContractTemplate;
}
interface ActionData {
success?: boolean;
message?: string;
error?: string;
}
export async function loader({ params, request }: LoaderFunctionArgs) {
const draftId = parseInt(params.draftId || '0');
if (!draftId) {
throw new Response('草稿ID无效', { status: 400 });
}
// 获取用户信息和JWT
const { userInfo, frontendJWT } = await getUserSession(request);
if (!userInfo?.sub) {
throw new Response('未登录', { status: 401 });
}
const jwt = frontendJWT || undefined;
// 从 URL 参数获取文件路径、模板 ID 和标题
const url = new URL(request.url);
const filePath = url.searchParams.get('filePath');
const templateId = url.searchParams.get('templateId');
const title = url.searchParams.get('title');
if (!filePath) {
throw new Response('文件路径参数缺失', { status: 400 });
}
if (!templateId) {
throw new Response('模板ID参数缺失', { status: 400 });
}
console.log('[Loader] 起草合同:', { filePath, templateId, title });
// 创建草稿对象
const draft: DraftedContract = {
id: draftId,
template_id: parseInt(templateId),
file_path: filePath,
title: title || '未命名合同',
placeholder_values: {},
status: 'draft',
created_by: parseInt(userInfo.sub),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
// 从 docx 文件中提取占位符
let placeholderSchema: PlaceholderSchema | null = null;
try {
console.log('[Loader] 使用文件:', filePath);
// 提取占位符
const placeholders = await extractPlaceholdersFromDocx(filePath);
console.log('[Loader] 提取到的占位符:', placeholders);
// 生成默认 schema
placeholderSchema = generateDefaultSchema(placeholders);
// console.log('[Loader] 生成的 schema:', JSON.stringify(placeholderSchema, null, 2));
} catch (error) {
console.error('[Loader] 提取占位符失败:', error);
placeholderSchema = null;
}
// 创建模板对象
const template: ContractTemplate = {
id: parseInt(templateId),
title: title || '合同模板',
template_code: 'DRAFT-' + templateId,
category_id: 1,
file_path: filePath,
file_format: 'docx',
description: '起草中的合同',
is_featured: false,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
placeholder_schema: placeholderSchema as any
};
return Response.json({
draft,
template,
returnUrl: `/contract-template/detail/${templateId}`
});
}
/**
* Action 函数:处理文件删除
*/
export async function action({ request, params }: ActionFunctionArgs) {
const draftId = parseInt(params.draftId || '0');
if (!draftId) {
return Response.json({ error: '草稿ID无效' }, { status: 400 });
}
// 获取用户信息和JWT
const { userInfo, frontendJWT } = await getUserSession(request);
if (!userInfo?.sub) {
return Response.json({ error: '未登录' }, { status: 401 });
}
const jwt = frontendJWT || undefined;
try {
const formData = await request.formData();
const actionType = formData.get('_action') as string;
if (actionType === 'deleteFile') {
const filePath = formData.get('filePath') as string;
if (!filePath) {
return Response.json({ error: '文件路径缺失' }, { status: 400 });
}
// 删除 MinIO 文件,传递 JWT
await deleteFile({ file_path: filePath }, jwt);
return Response.json({
success: true,
message: '文件删除成功'
});
}
return Response.json({ error: '无效的操作类型' }, { status: 400 });
} catch (error) {
console.error('[Action] 操作失败:', error);
return Response.json(
{ error: error instanceof Error ? error.message : '操作失败' },
{ status: 500 }
);
}
}
export default function ContractDraftPage() {
const loaderData = useLoaderData<typeof loader>();
const { draft, template, returnUrl } = loaderData;
const navigate = useNavigate();
const fetcher = useFetcher<ActionData>();
const [placeholderValues, setPlaceholderValues] = useState<Record<string, string>>(
draft.placeholder_values || {}
);
const [highlightValue, setHighlightValue] = useState<string | undefined>(undefined);
const [aiSuggestionReplace, setAiSuggestionReplace] = useState<{
searchText: string;
replaceText: string;
pageNumber: number;
} | undefined>(undefined);
const filePreviewRef = useRef<FilePreviewHandle>(null);
const hasDeletedNormally = useRef(false); // 标记是否已通过正常流程删除
const currentPathRef = useRef(window.location.pathname); // 保存当前路由路径
// 从 fetcher.state 判断是否正在操作
const isDeleting = fetcher.state !== 'idle';
// 处理 fetcher 响应(文件删除)
useEffect(() => {
if (fetcher.data?.success) {
// 标记已通过正常流程删除
hasDeletedNormally.current = true;
// 文件删除成功,显示提示并跳转
// toastService.success('合同已完成');
// 延迟跳转,让用户看到成功提示
setTimeout(() => {
if (returnUrl) {
navigate(returnUrl);
} else {
navigate('/contract-template');
}
}, 500);
} else if (fetcher.data?.error) {
toastService.error(fetcher.data.error);
}
}, [fetcher.data, navigate, returnUrl]);
// 页面卸载或路由切换时清理文件
useEffect(() => {
const deleteFileSync = () => {
// 如果已经通过正常流程删除,不再重复删除
if (hasDeletedNormally.current || !draft.file_path) {
return;
}
// console.log('[Cleanup] 尝试删除文件:', draft.file_path);
// console.log('[Cleanup] 使用路径:', currentPathRef.current);
try {
// 直接使用同步 XMLHttpRequest 确保删除请求真正执行
// 使用保存的原始路径,而不是当前的 window.location.pathname(可能已切换)
const xhr = new XMLHttpRequest();
xhr.open('POST', currentPathRef.current, false); // false = 同步请求
const formData = new FormData();
formData.append('_action', 'deleteFile');
formData.append('filePath', draft.file_path);
xhr.send(formData);
// console.log('[Cleanup] 同步删除完成,状态:', xhr.status);
if (xhr.status === 200) {
try {
const response = JSON.parse(xhr.responseText);
// console.log('[Cleanup] ✅ 文件删除成功:', response);
} catch (e) {
// console.log('[Cleanup] ✅ 文件删除成功(状态码200');
}
} else {
console.error('[Cleanup] ❌ 文件删除失败,状态码:', xhr.status);
}
} catch (error) {
console.error('[Cleanup] 删除文件失败:', error);
}
};
const handleBeforeUnload = () => {
deleteFileSync();
};
// 监听页面卸载事件
window.addEventListener('beforeunload', handleBeforeUnload);
// 清理事件监听器
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
// 组件卸载时(路由切换),执行删除
deleteFileSync();
};
}, [draft.file_path]);
// 单个替换占位符
const handleSingleReplace = async (key: string, value: string) => {
const placeholder = `{{${key}}}`;
console.log(`[Draft] 单个替换: ${placeholder} -> ${value}`);
// 设置 AI 建议替换参数,触发 FilePreview 中的替换
setAiSuggestionReplace({
searchText: placeholder,
replaceText: value,
pageNumber: 1 // 从第一页开始搜索
});
// 短暂延迟后清除参数,以便下次可以重新触发
setTimeout(() => {
setAiSuggestionReplace(undefined);
// toastService.success(`已替换 ${key}`);
}, 1000);
};
// 字段聚焦时高亮对应占位符
const handleFieldFocus = (key: string) => {
const placeholder = `{{${key}}}`;
console.log(`[Draft] 高亮占位符: ${placeholder}`);
// 设置高亮值,触发 FilePreview 中的高亮
setHighlightValue(placeholder);
// 短暂延迟后清除高亮,以便下次点击可以重新触发
setTimeout(() => {
setHighlightValue(undefined);
}, 100);
};
// 导出文档(下载当前编辑的文件)
const handleExportDocument = async () => {
if (!draft.file_path) {
toastService.error('文件路径不存在,无法下载');
return;
}
try {
toastService.info('正在下载文件...');
// 使用 axios-client 的 downloadFile 方法下载文件
const blob = await downloadFile(draft.file_path);
// 创建Blob URL
const blobUrl = URL.createObjectURL(blob);
// 清理文件名
const fileExtension = draft.file_path.split('.').pop() || 'docx';
const fileName = `${draft.title}.${fileExtension}`;
const cleanFileName = fileName.replace(/[<>:"/\\|?*]/g, '_');
// 创建隐藏的a标签并点击下载
const a = document.createElement('a');
a.style.display = 'none';
a.href = blobUrl;
a.download = cleanFileName;
document.body.appendChild(a);
a.click();
// 清理
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
}, 100);
toastService.success('文件下载成功');
} catch (error) {
console.error('[Draft] 下载文件失败:', error);
toastService.error(`下载文件失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
};
// 完成起草(下载文件 + 删除 MinIO 文件)
const handleComplete = async () => {
try {
// 1. 先下载文件
await handleExportDocument();
// 2. 删除 MinIO 文件
const formData = new FormData();
formData.append('_action', 'deleteFile');
formData.append('filePath', draft.file_path);
fetcher.submit(formData, { method: 'post' });
} catch (error) {
console.error('[Complete] 操作失败:', error);
toastService.error('操作失败,请重试');
}
};
// 返回模板详情页
const handleBack = () => {
messageService.show({
type: 'warning',
title: '确认返回',
message: '确定要返回吗?未保存的更改将丢失。',
confirmText: '确定返回',
cancelText: '取消',
onConfirm: () => {
// 删除 MinIO 文件
const formData = new FormData();
formData.append('_action', 'deleteFile');
formData.append('filePath', draft.file_path);
fetcher.submit(formData, { method: 'post' });
// 注意:文件删除后会在 useEffect 中跳转
}
});
};
return (
<div className="h-screen flex flex-col bg-gradient-to-br from-gray-50 to-gray-100">
{/* 顶部工具栏 */}
<div className="flex items-center justify-between px-8 py-5 bg-white shadow-sm border-b border-gray-100">
<div className="flex items-center gap-6">
<button
onClick={handleBack}
className="flex items-center gap-2 px-4 py-2.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 hover:border-gray-400 active:bg-gray-100 transition-all duration-150 shadow-sm hover:shadow"
>
<i className="ri-arrow-left-line text-lg"></i>
<span></span>
</button>
<div className="border-l border-gray-300 h-10"></div>
<div className="flex flex-col gap-1">
{/* <h1 className="text-xl font-bold text-gray-900 tracking-tight">{draft.title}</h1> */}
<h1 className="flex items-center gap-2">
<i className="ri-file-text-line"></i>
<span>{template.title.replace(/-[\d-]+$/, '')}</span>
</h1>
</div>
</div>
<div className="flex items-center gap-3">
<span className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg bg-gradient-to-r from-blue-50 to-blue-100 text-blue-700 border border-blue-200 shadow-sm">
<i className="ri-draft-line text-base"></i>
<span>{draft.status === 'draft' ? '草稿' : draft.status === 'completed' ? '已完成' : '已归档'}</span>
</span>
</div>
</div>
{/* 主内容区域:左右分栏 */}
<div className="flex-1 flex overflow-hidden gap-4 p-4">
{/* 左侧:文档预览(60% */}
<div className="w-[60%] bg-white h-full overflow-hidden rounded-xl shadow-lg border border-gray-200">
<FilePreview
ref={filePreviewRef}
fileContent={{
path: draft.file_path,
title: draft.title,
contractNumber: '',
parties: {
partyA: { name: '', address: '', representative: '', phone: '' },
partyB: { name: '', address: '', representative: '', phone: '' }
},
sections: []
}}
activeReviewPointResultId={null}
targetPage={undefined}
isStructuredView={false}
isTemplate={false} // 编辑模式
highlightValue={highlightValue}
aiSuggestionReplace={aiSuggestionReplace}
/>
</div>
{/* 右侧:占位符表单(40% */}
<div className="w-[40%] bg-white h-full overflow-hidden rounded-xl shadow-lg border border-gray-200">
<PlaceholderForm
schema={template.placeholder_schema as any}
values={placeholderValues}
onChange={setPlaceholderValues}
onComplete={handleComplete}
isDeleting={isDeleting}
onSingleReplace={handleSingleReplace}
onFieldFocus={handleFieldFocus}
/>
</div>
</div>
</div>
);
}