Files
leaudit-platform-frontend/app/routes/contract-template.detail.$id.tsx
T

541 lines
20 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.
import type { MetaFunction, LoaderFunctionArgs, ActionFunctionArgs } from '@remix-run/node';
import { redirect } from '@remix-run/node';
import { useLoaderData, useNavigate, useSubmit, useActionData } from '@remix-run/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';
import { checkRoutePermission } from '~/api/auth/check-route-permission.server';
// 导入FilePreview组件
import { FilePreview } from '~/components/reviews';
import { toastService } from '~/components/ui/Toast';
export const links = () => [
{ rel: 'stylesheet', href: styles },
{ rel: 'stylesheet', href: filePreviewStyles },
];
export const meta: MetaFunction<typeof loader> = ({ data }) => {
return [
{ title: `${data?.template.title || '模板详情'} - 合同管理 - 智慧法务` },
{
name: 'description',
content: data?.template.description || '查看合同管理模块中的模板详细信息'
}
];
};
// 面包屑导航配置
export const handle = {
breadcrumb: (data: { template: { title: string } }) => {
return data?.template?.title || "模板详情";
}
};
export async function loader({ params, request }: LoaderFunctionArgs) {
const templateId = params.id!;
// 获取 JWT
const { frontendJWT } = await getUserSession(request);
const jwt = frontendJWT || undefined;
try {
const response = await getContractTemplate(templateId, jwt);
if (response.error) {
throw new Response(response.error, { status: response.status || 404 });
}
if (!response.data) {
throw new Response('模板未找到', { status: 404 });
}
// 添加调试信息
// console.log('模板详情数据:', response.data);
// console.log('分类信息:', response.data.category);
return { template: response.data };
} catch (error) {
console.error('加载模板详情失败:', error);
throw new Response('模板未找到', { status: 404 });
}
}
/**
* Action 函数:处理起草合同请求
*/
export async function action({ request, params }: ActionFunctionArgs) {
const templateId = params.id!;
if (!templateId) {
return Response.json({ error: '模板ID无效' }, { status: 400 });
}
// 获取用户信息和JWT
const { userInfo, frontendJWT, userRole } = await getUserSession(request);
if (!userInfo?.sub) {
return Response.json({ error: '未登录' }, { status: 401 });
}
// 🔒 在执行任何操作之前,先检查用户是否有权限访问目标路由
const targetPath = '/contract-draft';
const permissionCheck = await checkRoutePermission(targetPath, userRole, frontendJWT || undefined);
if (!permissionCheck.allowed) {
console.warn(`[Action] 用户无权访问 ${targetPath}:`, permissionCheck.error);
return Response.json({ error: permissionCheck.error || '您没有权限使用起草合同功能' }, { status: 403 });
}
try {
// 解析表单数据
const formData = await request.formData();
const title = formData.get('title') as string;
const originalFilePath = formData.get('originalFilePath') as string;
if (!title) {
return Response.json({ error: '标题不能为空' }, { status: 400 });
}
if (!originalFilePath) {
return Response.json({ error: '文件路径不存在' }, { status: 400 });
}
// 生成新文件路径
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] 文件复制成功:');
// 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(
{ error: error instanceof Error ? error.message : '创建草稿失败' },
{ status: 500 }
);
}
}
// Action 返回的数据类型
interface ActionData {
error?: string;
}
export default function ContractTemplateDetail() {
const { template }: { template: ContractTemplate } = useLoaderData<typeof loader>();
const actionData = useActionData<ActionData>();
const navigate = useNavigate();
const submit = useSubmit();
const [isCreatingDraft, setIsCreatingDraft] = useState(false);
// 注释掉收藏功能
// const [isFavorited, setIsFavorited] = useState(false);
// 防止页面加载时自动滚动到预览区域(由 Collabora iframe 的 tabIndex 导致)
useEffect(() => {
// 页面加载后立即滚动回顶部
window.scrollTo({ top: 0, behavior: 'instant' });
}, []);
// 处理 action 返回的错误
useEffect(() => {
if (actionData?.error) {
toastService.error(actionData.error);
setIsCreatingDraft(false);
}
}, [actionData]);
const handleBack = () => {
navigate('/contract-template/list');
};
// 使用统一的下载方法(与 rules-files.tsx 相同)
const handleDownload = async () => {
if (!template.file_path) {
toastService.error('文件路径不存在,无法下载');
return;
}
try {
// 使用axios封装的下载方法
const blob = await downloadFile(template.file_path);
// 创建Blob URL
const blobUrl = URL.createObjectURL(blob);
// 清理文件名,移除可能导致问题的字符
const fileExtension = template.file_format || 'docx';
const fileName = `${template.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);
} catch (error) {
console.error('下载文件失败:', error);
toastService.error(`下载文件失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
};
const handlePreview = () => {
// console.log('预览模板:', template.id);
// 页面内预览,滚动到预览区域
const previewElement = document.getElementById('template-preview');
if (previewElement) {
previewElement.scrollIntoView({ behavior: 'smooth' });
}
};
// 起草合同
const handleStartDraft = () => {
if (isCreatingDraft) return;
if (!template.file_path) {
toastService.error('模板文件路径不存在,无法起草');
return;
}
// 生成默认标题
const defaultTitle = `${template.title}-${new Date().toLocaleDateString('zh-CN').replace(/\//g, '')}`;
setIsCreatingDraft(true);
// 使用 Remix 的 submit 提交表单
const formData = new FormData();
formData.append('title', defaultTitle);
formData.append('originalFilePath', template.file_path);
submit(formData, { method: 'post' });
};
/* const handleFavorite = () => {
setIsFavorited(!isFavorited);
console.log('收藏状态:', !isFavorited);
}; */
/* const handleShare = () => {
// 复制当前页面URL到剪贴板
navigator.clipboard.writeText(window.location.href).then(() => {
alert('链接已复制到剪贴板');
}).catch(() => {
alert('复制失败,请手动复制链接');
});
}; */
// 注释掉评分相关功能
/* const renderStars = (rating: number) => {
const stars = [];
const fullStars = Math.floor(rating);
for (let i = 0; i < 5; i++) {
if (i < fullStars) {
stars.push(<i key={i} className="ri-star-fill"></i>);
} else {
stars.push(<i key={i} className="ri-star-line"></i>);
}
}
return stars;
}; */
// 创建文件内容对象用于FilePreview组件
// 优先使用原始文件路径(支持docx),如果没有则使用pdf_file_path
const previewPath = template.file_path || template.pdf_file_path;
const fileContent = previewPath ? {
title: template.title,
contractNumber: template.template_code,
// 使用file_path以支持多种格式(docx/pdf
path: previewPath,
parties: {
partyA: {
name: '',
address: '',
representative: '',
phone: ''
},
partyB: {
name: '',
address: '',
representative: '',
phone: ''
}
},
sections: []
} : null;
return (
<div className="contract-search-results">
{/* 返回按钮 */}
<div className="mb-6">
<button
onClick={handleBack}
className="flex items-center px-3 py-2 text-sm border border-gray-200 rounded-lg hover:border-primary-color transition-colors"
>
<i className="ri-arrow-left-line mr-2"></i>
</button>
</div>
{/* 模板详情 */}
<div className="template-detail max-w-4xl mx-auto">
{/* 详情头部 */}
<div className="detail-header bg-white rounded-xl p-8 mb-6 border border-gray-100">
{/* 注释掉类型和评分显示 */}
{/* <div className="flex justify-between items-start mb-4">
<div className="template-type">{template.type}</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 text-yellow-500">
{renderStars(template.rating)}
<span className="text-sm ml-1">{template.rating} (156评价)</span>
</div>
</div>
</div> */}
<h1 className="detail-title text-3xl font-semibold mb-6">{template.title}</h1>
<div className="detail-meta grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
<div className="meta-item">
<span className="meta-label text-gray-500"></span>
<span>{template.template_code}</span>
</div>
<div className="meta-item">
<span className="meta-label text-gray-500"></span>
<span>{new Date(template.updated_at).toLocaleDateString('zh-CN')}</span>
</div>
<div className="meta-item">
<span className="meta-label text-gray-500"></span>
<span>{template.category?.name || '其他'}</span>
</div>
<div className="meta-item">
<span className="meta-label text-gray-500"></span>
<span>{template.file_format?.toUpperCase()}</span>
</div>
{template.is_featured && (
<div className="meta-item">
<span className="meta-label text-gray-500"></span>
<span className="text-primary"></span>
</div>
)}
{/* 注释掉使用次数、文件大小、适用范围、法律依据等字段 */}
{/* <div className="meta-item">
<span className="meta-label text-gray-500">使用次数:</span>
<span>{template.useCount.toLocaleString()}次</span>
</div>
<div className="meta-item">
<span className="meta-label text-gray-500">文件大小:</span>
<span>{template.fileSize}</span>
</div>
<div className="meta-item">
<span className="meta-label text-gray-500">适用范围:</span>
<span>{template.scope}</span>
</div>
<div className="meta-item">
<span className="meta-label text-gray-500">法律依据:</span>
<span>{template.legalBasis}</span>
</div> */}
</div>
<div className="detail-actions flex gap-3">
<button
className="detail-btn primary bg-primary text-white px-6 py-3 rounded-lg flex items-center gap-2 hover:bg-primary-hover"
onClick={handleStartDraft}
disabled={isCreatingDraft}
>
{isCreatingDraft ? (
<>
<i className="ri-loader-4-line animate-spin"></i>
...
</>
) : (
<>
<i className="ri-edit-line"></i>
</>
)}
</button>
<button
className="detail-btn secondary bg-white border border-gray-200 px-6 py-3 rounded-lg flex items-center gap-2 hover:border-primary"
onClick={handleDownload}
>
<i className="ri-download-line"></i>
</button>
{template.pdf_file_path && (
<button
className="detail-btn secondary bg-white border border-gray-200 px-6 py-3 rounded-lg flex items-center gap-2 hover:border-primary"
onClick={handlePreview}
>
<i className="ri-eye-line"></i>
线
</button>
)}
{/* 注释掉收藏功能 */}
{/* <button
className={`detail-btn secondary bg-white border border-gray-200 px-6 py-3 rounded-lg flex items-center gap-2 hover:border-primary ${isFavorited ? 'text-yellow-500' : ''}`}
onClick={handleFavorite}
>
<i className={isFavorited ? 'ri-star-fill' : 'ri-star-line'}></i>
收藏模板
</button> */}
{/* <button
className="detail-btn secondary bg-white border border-gray-200 px-6 py-3 rounded-lg flex items-center gap-2 hover:border-primary"
onClick={handleShare}
>
<i className="ri-share-line"></i>
分享
</button> */}
</div>
</div>
{/* 详情内容 */}
<div className="detail-content bg-white rounded-xl p-8 border border-gray-100">
{/* 模板简介 */}
<div className="content-section mb-8">
<h3 className="section-title text-xl font-semibold mb-4"></h3>
<p className="text-gray-600 leading-relaxed">
{template.description || '该合同模板为标准格式,包含完整的合同条款结构,适用于相关业务场景的合同签署。'}
{template.category?.description && (
<>
<br /><br />
<strong></strong>{template.category.description}
</>
)}
</p>
</div>
{/* 注释掉主要特点模块 */}
{/* <div className="content-section mb-8">
<h3 className="section-title text-xl font-semibold mb-4">主要特点</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{template.features.map((feature, index) => (
<div key={index} className={`bg-${feature.color}-50 p-4 rounded-lg border border-${feature.color}-200`}>
<div className="flex items-center mb-2">
<i className={`${feature.icon} text-${feature.color}-600 mr-2`}></i>
<span className="font-medium">{feature.title}</span>
</div>
<p className="text-sm text-gray-600">{feature.description}</p>
</div>
))}
</div>
</div> */}
{/* 注释掉合同条款结构模块 */}
{/* <div className="content-section mb-8">
<h3 className="section-title text-xl font-semibold mb-4">合同条款结构</h3>
<div className="space-y-3">
{template.structure.map((item) => (
<div key={item.step} className="flex items-center p-3 bg-gray-50 rounded-lg">
<div className="w-8 h-8 bg-primary text-white rounded-full flex items-center justify-center text-sm font-medium mr-3">
{item.step}
</div>
<div>
<div className="font-medium">{item.title}</div>
<div className="text-sm text-gray-600">{item.description}</div>
</div>
</div>
))}
</div>
</div> */}
{/* 合同预览 - 只有当存在pdf_file_path时才显示 */}
{fileContent && (
<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
className="file-preview-isolation w-full"
>
<FilePreview
fileContent={fileContent}
activeReviewPointResultId={null}
targetPage={undefined}
isStructuredView={false}
isTemplate={true}
/>
</div>
</div>
</div>
)}
{/* 注释掉用户评价模块 */}
{/* <div className="content-section">
<h3 className="section-title text-xl font-semibold mb-4">用户评价</h3>
<div className="space-y-4">
{template.reviews.map((review, index) => (
<div key={index} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-blue-500 text-white rounded-full flex items-center justify-center text-sm font-medium">
{review.user[0]}
</div>
<span className="font-medium">{review.user}</span>
<div className="flex items-center gap-1 text-yellow-500">
{renderStars(review.rating)}
</div>
</div>
<span className="text-sm text-gray-500">{review.date}</span>
</div>
<p className="text-gray-600">{review.comment}</p>
</div>
))}
</div>
</div> */}
</div>
</div>
</div>
);
}