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

490 lines
17 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;
silentReplace?: boolean;
} | undefined>(undefined);
const [showFilePreview, setShowFilePreview] = useState(true); // 控制 FilePreview 显示
const [isCompleting, setIsCompleting] = useState(false); // 标记是否正在执行完成流程
const filePreviewRef = useRef<FilePreviewHandle>(null);
const hasDeletedNormally = useRef(false); // 标记是否已通过正常流程删除
const currentPathRef = useRef(window.location.pathname); // 保存当前路由路径
// 从 fetcher.state 判断是否正在操作
const isDeleting = fetcher.state !== 'idle' || isCompleting;
// 处理 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, // 从第一页开始搜索
silentReplace: true // 静默替换,不显示搜索替换面板
});
// 短暂延迟后清除参数,以便下次可以重新触发
setTimeout(() => {
setAiSuggestionReplace(undefined);
// toastService.success(`已替换 ${key}`);
}, 2000);
};
// 字段聚焦时高亮对应占位符
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 {
const fileExtension = draft.file_path.split('.').pop()?.toLowerCase();
console.log('[Draft] 正在下载文件:', draft.file_path);
toastService.info('正在下载文件...');
// 使用 axios-client 的 downloadFile 方法下载文件
const blob = await downloadFile(draft.file_path);
// 创建Blob URL
const blobUrl = URL.createObjectURL(blob);
// 清理文件名
const fileName = `${draft.title}.${fileExtension || 'docx'}`;
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 : '未知错误'}`);
throw error; // 重新抛出错误,让 handleComplete 捕获
}
};
// 完成起草(触发保存 → 下载文件 → 删除 MinIO 文件)
const handleComplete = async () => {
try {
setIsCompleting(true);
// 步骤1:隐藏 FilePreview,触发 CollaboraViewer 组件销毁和保存
console.log('[Complete] 步骤1:隐藏 FilePreview,触发 Collabora 保存');
toastService.info('正在保存文档...');
setShowFilePreview(false);
// 步骤2:等待 Collabora 保存完成(给予充足时间让 WOPI PutFile 完成)
console.log('[Complete] 步骤2:等待 4 秒让 WOPI PutFile 完成');
await new Promise(resolve => setTimeout(resolve, 4000));
// 步骤3:下载文件
console.log('[Complete] 步骤3:下载文件');
await handleExportDocument();
// 步骤4:删除 MinIO 文件
console.log('[Complete] 步骤4:删除 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('操作失败,请重试');
setIsCompleting(false);
setShowFilePreview(true); // 恢复显示
}
};
// 返回模板详情页
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">
{showFilePreview ? (
<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 className="flex items-center justify-center h-full">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary mb-4"></div>
<p className="text-gray-600 font-medium">...</p>
<p className="text-sm text-gray-500 mt-2"></p>
</div>
</div>
)}
</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>
);
}