/** * 合同起草页面 * 路由: /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 = ({ 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(); const { draft, template, returnUrl } = loaderData; const navigate = useNavigate(); const fetcher = useFetcher(); const [placeholderValues, setPlaceholderValues] = useState>( draft.placeholder_values || {} ); const [highlightValue, setHighlightValue] = useState(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(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 (
{/* 顶部工具栏 */}
{/*

{draft.title}

*/}

基于模板:{template.title.replace(/-[\d-]+$/, '')}

{/* 刷新提醒 */}
请勿刷新页面,否则临时文件将被删除
{/* 草稿状态标签 */} {draft.status === 'draft' ? '草稿' : draft.status === 'completed' ? '已完成' : '已归档'}
{/* 主内容区域:左右分栏 */}
{/* 左侧:文档预览(60%) */}
{showFilePreview ? ( ) : (

正在保存文档...

请稍候,即将完成

)}
{/* 右侧:占位符表单(40%) */}
); } /** * 错误边界组件 - 处理页面加载错误时自动重定向 */ 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 (
正在返回...
); }