This commit is contained in:
2025-12-05 00:09:32 +08:00
parent bb3d22eabf
commit 3d1dbb3f97
214 changed files with 113060 additions and 1232 deletions
+437
View File
@@ -0,0 +1,437 @@
/**
* 合同起草页面
* 路由: /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>
);
}