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}
+66 -29
View File
@@ -1,18 +1,17 @@
import type { MetaFunction, LoaderFunctionArgs, ActionFunctionArgs } from '@remix-run/node';
import { redirect } from '@remix-run/node';
import { useLoaderData, useNavigate, useSubmit } from '@remix-run/react';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { getContractTemplate } from '~/api/contract-template/templates';
import type { ContractTemplate } from '~/api/contract-template/templates';
import styles from '~/styles/pages/contract-template.css?url';
import filePreviewStyles from '~/styles/components/file-preview-isolation.css?url';
import { getUserSession } from '~/api/login/auth.server';
import { createDraftContract } from '~/api/contracts/draft-service.server';
import { apiRequest, downloadFile } from '~/api/axios-client';
// 导入FilePreview组件
import { FilePreview } from '~/components/reviews';
// 导入统一的下载方法和提示服务
import { downloadFile } from '~/api/axios-client';
import { toastService } from '~/components/ui/Toast';
export const links = () => [
@@ -70,7 +69,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
* Action 函数:处理起草合同请求
*/
export async function action({ request, params }: ActionFunctionArgs) {
const templateId = parseInt(params.id || '0');
const templateId = params.id!;
if (!templateId) {
return Response.json({ error: '模板ID无效' }, { status: 400 });
@@ -86,27 +85,60 @@ export async function action({ request, params }: ActionFunctionArgs) {
// 解析表单数据
const formData = await request.formData();
const title = formData.get('title') as string;
const draftFilePath = formData.get('draftFilePath') as string | null;
const originalFilePath = formData.get('originalFilePath') as string;
if (!title) {
return Response.json({ error: '标题不能为空' }, { status: 400 });
}
// 创建草稿记录(到时候可以换成接口,使用接口来在minio中生成备份文件:备份文件可以用时间戳+uuid来保证唯一性。)
// const draft = await createDraftContract(
// {
// templateId,
// title,
// draftFilePath: draftFilePath || undefined
// },
// parseInt(userInfo.sub),
// draftFilePath || undefined,
// frontendJWT || undefined
// );
if (!originalFilePath) {
return Response.json({ error: '文件路径不存在' }, { status: 400 });
}
// 重定向到草稿编辑页面
// return redirect(`/contract-draft/${draft.id}`);
return redirect(`/contract-draft/1`);
// 生成新文件路径
const area = userInfo.area || 'unknown';
const timestamp = Date.now();
const uuid = crypto.randomUUID();
// 提取文件目录和文件名
const lastSlashIndex = originalFilePath.lastIndexOf('/');
const directory = lastSlashIndex >= 0 ? originalFilePath.substring(0, lastSlashIndex) : '';
const fileName = lastSlashIndex >= 0 ? originalFilePath.substring(lastSlashIndex + 1) : originalFilePath;
// 提取文件扩展名
const lastDotIndex = fileName.lastIndexOf('.');
const baseName = lastDotIndex >= 0 ? fileName.substring(0, lastDotIndex) : fileName;
const extension = lastDotIndex >= 0 ? fileName.substring(lastDotIndex) : '';
// 构建新文件名
const newFileName = `${baseName}_${area}_${timestamp}_${uuid}${extension}`;
const newFilePath = directory ? `${directory}/${newFileName}` : newFileName;
console.log('[Draft] 复制文件:', { originalFilePath, newFilePath });
// 调用 MinIO 复制文件 API(需要传递 JWT)
const jwt = frontendJWT || undefined;
const copyResponse = await apiRequest('/api/v2/storage/files/copy', {
method: 'POST',
data: {
source_path: originalFilePath,
destination_path: newFilePath
},
headers: {
'Authorization': jwt ? `Bearer ${jwt}` : ''
}
});
if (copyResponse.error) {
console.error('[Draft] 文件复制失败:', copyResponse.error);
return Response.json({ error: `文件复制失败: ${copyResponse.error}` }, { status: 500 });
}
console.log('[Draft] 文件复制成功:', copyResponse.data);
// 重定向到草稿编辑页面,通过 URL 参数传递文件路径和模板 ID
const draftUrl = `/contract-draft/1?filePath=${encodeURIComponent(newFilePath)}&templateId=${templateId}&title=${encodeURIComponent(title)}`;
return redirect(draftUrl);
} catch (error) {
console.error('[Template Detail] 创建草稿失败:', error);
return Response.json(
@@ -124,6 +156,12 @@ export default function ContractTemplateDetail() {
// 注释掉收藏功能
// const [isFavorited, setIsFavorited] = useState(false);
// 防止页面加载时自动滚动到预览区域(由 Collabora iframe 的 tabIndex 导致)
useEffect(() => {
// 页面加载后立即滚动回顶部
window.scrollTo({ top: 0, behavior: 'instant' });
}, []);
const handleBack = () => {
navigate(-1);
};
@@ -181,21 +219,20 @@ export default function ContractTemplateDetail() {
const handleStartDraft = () => {
if (isCreatingDraft) return;
// 生成默认标题
// const defaultTitle = `${template.title}-${new Date().toLocaleDateString('zh-CN').replace(/\//g, '')}`;
if (!template.file_path) {
toastService.error('模板文件路径不存在,无法起草');
return;
}
// // 提示用户输入标题
// const title = prompt('请输入合同标题:', defaultTitle);
// if (!title) return;
// 生成默认标题
const defaultTitle = `${template.title}-${new Date().toLocaleDateString('zh-CN').replace(/\//g, '')}`;
setIsCreatingDraft(true);
// 使用 Remix 的 submit 提交表单
const formData = new FormData();
// formData.append('title', title.trim());
formData.append('title', '买卖合同-拟起草合同');
// 可选:如果需要复制文件,可以先调用文件复制服务,然后传递 draftFilePath
// formData.append('draftFilePath', draftFilePath);
formData.append('title', defaultTitle);
formData.append('originalFilePath', template.file_path);
submit(formData, { method: 'post' });
};
@@ -430,7 +467,7 @@ export default function ContractTemplateDetail() {
{/* 合同预览 - 只有当存在pdf_file_path时才显示 */}
{fileContent && (
<div className="content-section mb-8" id="template-preview">
<div className="content-section mb-8">
<h3 className="section-title text-xl font-semibold mb-4"></h3>
<div className="border border-gray-200 rounded-lg overflow-hidden">
<div
+1 -1
View File
@@ -188,7 +188,7 @@ export async function action({ request }: ActionFunctionArgs) {
}
toastService.success('更新文档成功');
return redirect("/documents");
return redirect("/documents/list");
} catch (error) {
console.error("更新文档失败2:", error);
return Response.json({
+1 -1
View File
@@ -382,7 +382,7 @@ export default function RuleNew() {
const fetchEvaluationPointGroups = useCallback(async () => {
try {
// console.log("🔍 [fetchEvaluationPointGroups] 开始获取评查点组数据");
const response = await postgrestGet('evaluation_point_groups', { token: frontendJWT });
const response = await postgrestGet('/api/postgrest/proxy/evaluation_point_groups', { token: frontendJWT });
// console.log("🔍 [fetchEvaluationPointGroups] API响应:", response);