438 lines
15 KiB
TypeScript
438 lines
15 KiB
TypeScript
/**
|
||
* 合同起草页面
|
||
* 路由: /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>
|
||
);
|
||
}
|