Files
leaudit-platform-frontend/app/routes/contract-draft.$draftId.tsx
T
2025-12-05 00:09:32 +08:00

438 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 { toastService } from '~/components/ui/Toast';
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;
// 【临时测试】使用测试文档和模拟数据
// const testDocPath = path.join(process.cwd(), 'public', 'testWork', '买卖合同 (1).docx');
const testDocPath = 'contract-template/买卖/买卖合同范本.docx';
// 创建临时的草稿对象(用于测试)
const draft: DraftedContract = {
id: draftId,
template_id: 1,
file_path: testDocPath,
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] 使用测试文档:', testDocPath);
// 提取占位符
const placeholders = await extractPlaceholdersFromDocx(testDocPath);
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: 1,
title: '买卖合同模板',
template_code: 'TEST-001',
category_id: 1,
file_path: testDocPath,
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
});
}
/**
* 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 userId = parseInt(userInfo.sub);
const jwt = frontendJWT || undefined;
try {
// 解析表单数据
const formData = await request.formData();
const actionType = formData.get('_action') as string;
if (actionType === 'delete') {
// 删除草稿记录
await deleteDraft(draftId, userId, 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 { draft, template } = useLoaderData<typeof loader>();
const navigate = useNavigate();
const fetcher = useFetcher<ActionData>();
const [placeholderValues, setPlaceholderValues] = useState<Record<string, string>>(
draft.placeholder_values || {}
);
const [isReplacing, setIsReplacing] = useState(false);
const [highlightValue, setHighlightValue] = useState<string | undefined>(undefined);
const [aiSuggestionReplace, setAiSuggestionReplace] = useState<{
searchText: string;
replaceText: string;
pageNumber: number;
} | undefined>(undefined);
const filePreviewRef = useRef<FilePreviewHandle>(null);
// 从 fetcher.state 判断是否正在操作
const isDeleting = fetcher.state !== 'idle';
// 处理 fetcher 响应(删除草稿)
useEffect(() => {
if (fetcher.data?.success && fetcher.data.message === '草稿已删除') {
// 删除成功,跳转到模板列表
navigate('/contract-template');
} else if (fetcher.data?.error) {
toastService.error(fetcher.data.error);
}
}, [fetcher.data, navigate]);
// 监听页面关闭事件 - 自动删除草稿
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
// 发送删除请求(使用 sendBeacon 确保请求发送)
const formData = new FormData();
formData.append('_action', 'delete');
navigator.sendBeacon(
`/contract-draft/${draft.id}`,
formData
);
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [draft.id]);
// 组件卸载时删除草稿(处理路由跳转的情况)
useEffect(() => {
return () => {
// 组件卸载时删除草稿记录
const formData = new FormData();
formData.append('_action', 'delete');
fetch(`/contract-draft/${draft.id}`, {
method: 'POST',
body: formData,
keepalive: true // 确保请求在页面关闭后仍然发送
}).catch(err => {
console.error('[Draft] 删除草稿失败:', err);
});
};
}, [draft.id]);
// 单个替换占位符
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 handleBatchReplace = async () => {
setIsReplacing(true);
try {
// 获取 CollaboraViewer 引用
const collaboraRef = filePreviewRef.current?.collaboraViewerRef.current;
if (!collaboraRef?.isReady) {
toastService.warning('文档尚未加载完成,请稍候...');
setIsReplacing(false);
return;
}
console.log('[Draft] 开始批量替换占位符:', placeholderValues);
// 批量替换所有占位符
let replaceCount = 0;
for (const [key, value] of Object.entries(placeholderValues)) {
if (value) { // 只替换有值的字段
const placeholder = `{{${key}}}`;
console.log(`[Draft] 替换: ${placeholder} -> ${value}`);
// 调用 unoCommands.replaceAll 方法
if (collaboraRef.unoCommands?.replaceAll) {
await collaboraRef.unoCommands.replaceAll(placeholder, value);
replaceCount++;
// 添加延迟避免 Collabora 响应不过来
await new Promise(resolve => setTimeout(resolve, 100));
} else {
console.warn('[Draft] unoCommands.replaceAll 方法不可用');
}
}
}
console.log(`[Draft] 替换完成,共替换 ${replaceCount} 个占位符`);
toastService.success(`占位符替换完成(${replaceCount}个)`);
} catch (error) {
console.error('[Draft] 替换失败:', error);
toastService.error(`替换失败: ${error instanceof Error ? error.message : '未知错误'}`);
} finally {
setIsReplacing(false);
}
};
// 导出文档(下载文件)
const handleExportDocument = async () => {
if (!draft.file_path) {
toastService.error('文件路径不存在,无法下载');
return;
}
try {
toastService.info('正在下载文件...');
// 使用统一的下载方法
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 : '未知错误'}`);
}
};
// 完成起草(下载文件 + 删除草稿记录)
const handleComplete = async () => {
// 1. 先下载文件
await handleExportDocument();
// 2. 延迟后删除草稿记录并跳转
setTimeout(() => {
const formData = new FormData();
formData.append('_action', 'delete');
fetcher.submit(formData, { method: 'post' });
}, 500);
};
// 返回模板详情页(删除草稿)
const handleBack = () => {
if (confirm('确定要返回吗?草稿将被删除。')) {
// 删除草稿记录
const formData = new FormData();
formData.append('_action', 'delete');
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>
<p className="text-sm text-gray-500 flex items-center gap-2">
<i className="ri-file-text-line text-base"></i>
<span>{template.title}</span>
</p>
</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}
onBatchReplace={handleBatchReplace}
onExportDocument={handleExportDocument}
onComplete={handleComplete}
isReplacing={isReplacing}
isDeleting={isDeleting}
onSingleReplace={handleSingleReplace}
onFieldFocus={handleFieldFocus}
/>
</div>
</div>
</div>
);
}