Files
leaudit-platform-frontend/app/routes/contract-draft.$draftId.tsx
T
LiangShiyong 6fa65ff156 fix: 1. 优化collabora的高亮效果,不固定主要页面。
2. 优化评查结果中的下载按钮,如果加载docx文件的话需要先保存再下载。
3. 交叉评查结果中添加返回按钮,并实现打开对应的任务的文档列表。
4. 文档类型的添加,添加绑定合同管理为入口的时候文档类型名称必须是要附带‘合同’字符。
2025-12-17 01:09:23 +08:00

534 lines
19 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 { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from '@remix-run/node';
import { redirect } from '@remix-run/node';
import { isRouteErrorResponse, useFetcher, useLoaderData, useNavigate, useParams, useRouteError } from '@remix-run/react';
import { useEffect, useRef, useState } from 'react';
import { downloadFile } from '~/api/axios-client';
import type { ContractTemplate } from '~/api/contract-template/templates';
import { extractPlaceholdersFromDocx, generateDefaultSchema } from '~/api/contracts/docx-parser.server';
import { getUserSession } from '~/api/login/auth.server';
import { deleteFile } from '~/api/storage/minio-client';
import { PlaceholderForm } from '~/components/contracts/PlaceholderForm';
import { FilePreview, type FilePreviewHandle } from '~/components/reviews/FilePreview';
import { messageService } from '~/components/ui/MessageModal';
import { toastService } from '~/components/ui/Toast';
import type { DraftedContract, PlaceholderSchema } from '~/types/contract-draft';
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 || !templateId) {
console.log('[Loader] URL参数缺失,可能是刷新导致,重定向到模板列表');
console.log('[Loader] filePath:', filePath, 'templateId:', templateId);
// 如果有 templateId,重定向到模板详情页;否则重定向到模板列表
if (templateId) {
return redirect(`/contract-template/detail/${templateId}`);
}
return redirect('/contract-template');
}
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);
console.error('[Loader] 错误类型:', error instanceof Error ? 'Error' : typeof error);
console.error('[Loader] 错误消息:', error instanceof Error ? error.message : String(error));
// 无论什么错误,都重定向回模板详情页(因为文件可能已被刷新删除)
console.log('[Loader] 发生错误,重定向到模板详情页');
return redirect(`/contract-template/detail/${templateId}`);
}
// 创建模板对象
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);
}
};
}, [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, // 静默替换,不显示搜索替换面板
timestamp: Date.now() // 添加时间戳,确保对象始终是新的
} as any);
// 短暂延迟后清除参数,以便下次可以重新触发
setTimeout(() => {
setAiSuggestionReplace(undefined);
// toastService.success(`已替换 ${key}`);
}, 2000);
};
// 字段聚焦时高亮对应占位符
const handleFieldFocus = (key: string) => {
const placeholder = `{{${key}}}`;
// console.log(`[Draft] 高亮占位符: ${placeholder}`);
// 设置高亮值,触发 FilePreview 中的高亮
setHighlightValue(placeholder);
// 延迟清除高亮,给焦点夺回机制充足的时间
// PlaceholderForm 会在 150ms 后夺回焦点,所以这里延迟 250ms 清除
setTimeout(() => {
setHighlightValue(undefined);
}, 250);
};
// 导出文档(下载当前编辑的文件)- 不再需要手动保存
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:清除会话标记
const sessionKey = `contract-draft-${draft.id}-loaded`;
sessionStorage.removeItem(sessionKey);
// 步骤5:删除 MinIO 文件
console.log('[Complete] 步骤5:删除 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: () => {
// 清除会话标记
const sessionKey = `contract-draft-${draft.id}-loaded`;
sessionStorage.removeItem(sessionKey);
// 删除 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">
{/* 刷新提醒 */}
<div className="flex items-center gap-2 px-3 py-2 text-sm text-yellow-700 bg-yellow-50 border border-yellow-200 rounded-lg">
<i className="ri-alert-line text-base"></i>
<span></span>
</div>
{/* 草稿状态标签 */}
<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>
);
}
/**
* 错误边界组件 - 处理页面加载错误时自动重定向
*/
export function ErrorBoundary() {
const error = useRouteError();
const params = useParams();
const navigate = useNavigate();
useEffect(() => {
console.error('[ErrorBoundary] 捕获到错误:', error);
// 自动重定向到模板详情页
const templateId = new URLSearchParams(window.location.search).get('templateId');
if (templateId) {
console.log('[ErrorBoundary] 自动重定向到模板详情页:', templateId);
// 使用 replace 避免返回到错误页面
navigate(`/contract-template/detail/${templateId}`, { replace: true });
} else {
console.log('[ErrorBoundary] 无法获取 templateId,重定向到模板列表');
navigate('/contract-template', { replace: true });
}
}, [error, navigate]);
// 显示一个简单的加载提示(用户几乎看不到,因为会立即重定向)
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="text-gray-600">...</div>
</div>
</div>
);
}