feat: 1. 完善起草合同页面的逻辑交互,对接minio的接口操作

This commit is contained in:
2025-12-05 20:17:37 +08:00
parent 3d1dbb3f97
commit 91b7518c99
21 changed files with 1249 additions and 1057 deletions
+146 -127
View File
@@ -11,7 +11,9 @@ import { FilePreview, type FilePreviewHandle } from '~/components/reviews/FilePr
import { PlaceholderForm } from '~/components/contracts/PlaceholderForm';
import { getDraftById, deleteDraft } from '~/api/contracts/draft-service.server';
import { getUserSession } from '~/api/login/auth.server';
import { deleteFile } from '~/api/storage/minio-client';
import { toastService } from '~/components/ui/Toast';
import { messageService } from '~/components/ui/MessageModal';
import { downloadFile } from '~/api/axios-client';
import { extractPlaceholdersFromDocx, generateDefaultSchema } from '~/api/contracts/docx-parser.server';
import path from 'path';
@@ -51,16 +53,28 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
const jwt = frontendJWT || undefined;
// 【临时测试】使用测试文档和模拟数据
// const testDocPath = path.join(process.cwd(), 'public', 'testWork', '买卖合同 (1).docx');
const testDocPath = 'contract-template/买卖/买卖合同范本.docx';
// 从 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: 1,
file_path: testDocPath,
title: '买卖合同-测试草稿',
template_id: parseInt(templateId),
file_path: filePath,
title: title || '未命名合同',
placeholder_values: {},
status: 'draft',
created_by: parseInt(userInfo.sub),
@@ -72,10 +86,10 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
let placeholderSchema: PlaceholderSchema | null = null;
try {
console.log('[Loader] 使用测试文档:', testDocPath);
console.log('[Loader] 使用文件:', filePath);
// 提取占位符
const placeholders = await extractPlaceholdersFromDocx(testDocPath);
const placeholders = await extractPlaceholdersFromDocx(filePath);
console.log('[Loader] 提取到的占位符:', placeholders);
// 生成默认 schema
@@ -86,15 +100,15 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
placeholderSchema = null;
}
// 创建临时的模板对象(用于测试)
// 创建模板对象
const template: ContractTemplate = {
id: 1,
title: '买卖合同模板',
template_code: 'TEST-001',
id: parseInt(templateId),
title: title || '合同模板',
template_code: 'DRAFT-' + templateId,
category_id: 1,
file_path: testDocPath,
file_path: filePath,
file_format: 'docx',
description: '测试用买卖合同模板',
description: '起草中的合同',
is_featured: false,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
@@ -103,12 +117,13 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
return Response.json({
draft,
template
template,
returnUrl: `/contract-template/detail/${templateId}`
});
}
/**
* Action 函数:处理删除草稿
* Action 函数:处理文件删除
*/
export async function action({ request, params }: ActionFunctionArgs) {
const draftId = parseInt(params.draftId || '0');
@@ -123,19 +138,26 @@ export async function action({ request, params }: ActionFunctionArgs) {
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);
if (actionType === 'deleteFile') {
const filePath = formData.get('filePath') as string;
return Response.json({ success: true, message: '草稿已删除' });
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 });
@@ -149,14 +171,14 @@ export async function action({ request, params }: ActionFunctionArgs) {
}
export default function ContractDraftPage() {
const { draft, template } = useLoaderData<typeof loader>();
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 [isReplacing, setIsReplacing] = useState(false);
const [highlightValue, setHighlightValue] = useState<string | undefined>(undefined);
const [aiSuggestionReplace, setAiSuggestionReplace] = useState<{
searchText: string;
@@ -165,56 +187,88 @@ export default function ContractDraftPage() {
} | undefined>(undefined);
const filePreviewRef = useRef<FilePreviewHandle>(null);
const hasDeletedNormally = useRef(false); // 标记是否已通过正常流程删除
const currentPathRef = useRef(window.location.pathname); // 保存当前路由路径
// 从 fetcher.state 判断是否正在操作
const isDeleting = fetcher.state !== 'idle';
// 处理 fetcher 响应(删除草稿
// 处理 fetcher 响应(文件删除)
useEffect(() => {
if (fetcher.data?.success && fetcher.data.message === '草稿已删除') {
// 删除成功,跳转到模板列表
navigate('/contract-template');
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]);
}, [fetcher.data, navigate, returnUrl]);
// 监听页面关闭事件 - 自动删除草稿
// 页面卸载或路由切换时清理文件
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
// 发送删除请求(使用 sendBeacon 确保请求发送)
const formData = new FormData();
formData.append('_action', 'delete');
const deleteFileSync = () => {
// 如果已经通过正常流程删除,不再重复删除
if (hasDeletedNormally.current || !draft.file_path) {
return;
}
navigator.sendBeacon(
`/contract-draft/${draft.id}`,
formData
);
// 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);
};
}, [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);
});
// 组件卸载时(路由切换),执行删除
deleteFileSync();
};
}, [draft.id]);
}, [draft.file_path]);
// 单个替换占位符
const handleSingleReplace = async (key: string, value: string) => {
@@ -231,7 +285,7 @@ export default function ContractDraftPage() {
// 短暂延迟后清除参数,以便下次可以重新触发
setTimeout(() => {
setAiSuggestionReplace(undefined);
toastService.success(`已替换 ${key}`);
// toastService.success(`已替换 ${key}`);
}, 1000);
};
@@ -249,52 +303,7 @@ export default function ContractDraftPage() {
}, 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('文件路径不存在,无法下载');
@@ -304,7 +313,7 @@ export default function ContractDraftPage() {
try {
toastService.info('正在下载文件...');
// 使用统一的下载方法
// 使用 axios-client 的 downloadFile 方法下载文件
const blob = await downloadFile(draft.file_path);
// 创建Blob URL
@@ -336,30 +345,43 @@ export default function ContractDraftPage() {
}
};
// 完成起草(下载文件 + 删除草稿记录
// 完成起草(下载文件 + 删除 MinIO 文件
const handleComplete = async () => {
// 1. 先下载文件
await handleExportDocument();
try {
// 1. 先下载文件
await handleExportDocument();
// 2. 延迟后删除草稿记录并跳转
setTimeout(() => {
// 2. 删除 MinIO 文件
const formData = new FormData();
formData.append('_action', 'delete');
formData.append('_action', 'deleteFile');
formData.append('filePath', draft.file_path);
fetcher.submit(formData, { method: 'post' });
}, 500);
} catch (error) {
console.error('[Complete] 操作失败:', error);
toastService.error('操作失败,请重试');
}
};
// 返回模板详情页(删除草稿)
// 返回模板详情页
const handleBack = () => {
if (confirm('确定要返回吗?草稿将被删除。')) {
// 删除草稿记录
const formData = new FormData();
formData.append('_action', 'delete');
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)
}
fetcher.submit(formData, { method: 'post' });
// 注意:文件删除后会在 useEffect 中跳转
}
});
};
return (
@@ -376,11 +398,11 @@ export default function ContractDraftPage() {
</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>
{/* <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">
@@ -422,10 +444,7 @@ export default function ContractDraftPage() {
schema={template.placeholder_schema as any}
values={placeholderValues}
onChange={setPlaceholderValues}
onBatchReplace={handleBatchReplace}
onExportDocument={handleExportDocument}
onComplete={handleComplete}
isReplacing={isReplacing}
isDeleting={isDeleting}
onSingleReplace={handleSingleReplace}
onFieldFocus={handleFieldFocus}