488 lines
17 KiB
TypeScript
488 lines
17 KiB
TypeScript
/**
|
||
* 合同起草页面
|
||
* 路由: /contract-draft/:draftId
|
||
*/
|
||
|
||
import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from '@remix-run/node';
|
||
import { useFetcher, useLoaderData, useNavigate } 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) {
|
||
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);
|
||
|
||
// 延迟清除高亮,给焦点夺回机制充足的时间
|
||
// 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:删除 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>
|
||
);
|
||
}
|