541 lines
20 KiB
TypeScript
541 lines
20 KiB
TypeScript
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>
|
||
);
|
||
}
|