Merge branch 'awen' into shiy

This commit is contained in:
2025-05-30 21:50:32 +08:00
23 changed files with 15102 additions and 573 deletions
-1
View File
@@ -203,7 +203,6 @@ export async function apiRequest<T>(
const config: AxiosRequestConfig = {
...options,
url,
params,
headers
};
+369
View File
@@ -0,0 +1,369 @@
import { postgrestGet, type PostgrestParams } from "../postgrest-client";
/**
* 数据提取工具函数
* 统一处理不同格式的API响应
*/
function extractApiData<T>(responseData: unknown): T | null {
if (!responseData) {
return null;
}
// 格式1: { code: number, msg: string, data: T }
if (typeof responseData === 'object' && responseData !== null &&
'code' in responseData &&
'data' in responseData &&
(responseData as { data: unknown }).data) {
return (responseData as { data: T }).data;
}
// 格式2: 直接是数据对象
return responseData as T;
}
// 类型定义
export interface ContractCategory {
id: number;
name: string;
icon?: string;
description?: string;
sort_order: number;
created_at: string;
updated_at: string;
}
export interface ContractTemplate {
id: number;
template_code: string;
title: string;
category_id: number;
description?: string;
file_path?: string;
file_format: 'docx' | 'pdf' | 'txt';
is_featured: boolean;
created_at: string;
updated_at: string;
pdf_file_path?: string;
// 关联的分类信息
category?: ContractCategory;
}
export interface TemplateSearchParams {
keyword?: string;
category?: string;
category_id?: number;
file_format?: string;
is_featured?: boolean;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
export interface SearchResult {
templates: ContractTemplate[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
/**
* 获取所有合同分类
*/
export async function getContractCategories() {
try {
const params: PostgrestParams = {
select: '*',
order: 'sort_order.asc,name.asc'
};
const response = await postgrestGet<ContractCategory[]>('contract_categories', params);
if (response.error) {
return { error: response.error, status: response.status || 500 };
}
const categories = extractApiData<ContractCategory[]>(response.data) || [];
return { data: categories };
} catch (error) {
console.error('获取合同分类失败:', error);
return {
error: error instanceof Error ? error.message : '获取合同分类失败',
status: 500
};
}
}
/**
* 获取所有合同分类及其模板数量(使用聚合查询)
*/
export async function getContractCategoriesWithCount() {
try {
// 获取所有分类
const categoriesResponse = await postgrestGet<ContractCategory[]>('contract_categories', {
select: '*',
order: 'sort_order.asc,name.asc'
});
if (categoriesResponse.error) {
return { error: categoriesResponse.error, status: categoriesResponse.status || 500 };
}
const categories = extractApiData<ContractCategory[]>(categoriesResponse.data) || [];
// 获取每个分类的模板数量
const categoriesWithCount = await Promise.all(
categories.map(async (category) => {
try {
// 简化方案:获取该分类下的所有模板ID,然后计算数量
const countResponse = await postgrestGet<{ id: number }[]>('contract_templates', {
select: 'id',
filter: { 'category_id': `eq.${category.id}` }
});
let templateCount = 0;
if (!countResponse.error && countResponse.data) {
const templates = extractApiData<{ id: number }[]>(countResponse.data) || [];
templateCount = templates.length;
}
return {
...category,
template_count: templateCount
};
} catch (error) {
console.error(`获取分类${category.name}的模板数量失败:`, error);
return {
...category,
template_count: 0
};
}
})
);
return { data: categoriesWithCount };
} catch (error) {
console.error('获取分类及模板数量失败:', error);
return {
error: error instanceof Error ? error.message : '获取分类及模板数量失败',
status: 500
};
}
}
// 扩展分类接口,包含模板数量
export interface ContractCategoryWithCount extends ContractCategory {
template_count: number;
}
/**
* 获取合同模板列表(支持筛选和分页)
*/
export async function getContractTemplates(searchParams: TemplateSearchParams = {}) {
try {
const {
keyword,
category,
category_id,
file_format,
is_featured,
page = 1,
pageSize = 6,
sortBy = 'updated_at',
sortOrder = 'desc'
} = searchParams;
// 构建查询参数
const params: PostgrestParams = {
select: 'id,template_code,title,category_id,description,file_path,file_format,is_featured,created_at,updated_at,pdf_file_path,category:contract_categories(id,name,icon,description)',
limit: pageSize,
offset: (page - 1) * pageSize,
order: `${sortBy}.${sortOrder}`
};
// 构建过滤条件
const filters: Record<string, string> = {};
if (category_id) {
filters['category_id'] = `eq.${category_id}`;
}
if (file_format) {
filters['file_format'] = `eq.${file_format}`;
}
if (is_featured !== undefined) {
filters['is_featured'] = `eq.${is_featured}`;
}
// 处理关键词搜索
if (keyword && keyword.trim()) {
const cleanKeyword = keyword.trim();
// 先查询匹配的分类ID
let matchingCategoryIds: number[] = [];
try {
const categoryResponse = await postgrestGet<ContractCategory[]>('contract_categories', {
select: 'id',
filter: { 'name': `ilike.*${cleanKeyword}*` }
});
if (categoryResponse.data) {
const categories = extractApiData<ContractCategory[]>(categoryResponse.data) || [];
matchingCategoryIds = categories.map(cat => cat.id);
console.log('匹配的分类ID:', matchingCategoryIds);
}
} catch (error) {
console.error('查询分类失败:', error);
}
// 构建搜索条件
const searchConditions = [
`title.ilike.*${cleanKeyword}*`,
`description.ilike.*${cleanKeyword}*`,
`template_code.ilike.*${cleanKeyword}*`,
// 如果有匹配的分类,添加分类ID条件
...(matchingCategoryIds.length > 0 ? [`category_id.in.(${matchingCategoryIds.join(',')})`] : [])
];
params.or = `(${searchConditions.join(',')})`;
console.log('搜索关键词:', cleanKeyword);
console.log('搜索条件:', params.or);
}
// 如果有分类名称,需要先获取分类ID
if (category && !category_id) {
const categoryResponse = await postgrestGet<ContractCategory[]>('contract_categories', {
select: 'id',
filter: { 'name': `eq.${category}` }
});
if (categoryResponse.data) {
const categories = extractApiData<ContractCategory[]>(categoryResponse.data) || [];
if (categories.length > 0) {
filters['category_id'] = `eq.${categories[0].id}`;
}
}
}
if (Object.keys(filters).length > 0) {
params.filter = filters;
}
// 执行查询
const response = await postgrestGet<ContractTemplate[]>('contract_templates', params);
if (response.error) {
return { error: response.error, status: response.status || 500 };
}
const templates = extractApiData<ContractTemplate[]>(response.data) || [];
// 获取总数(用于分页)
const countParams: PostgrestParams = {
select: 'id',
filter: params.filter,
or: params.or
};
const countResponse = await postgrestGet<{ id: number }[]>('contract_templates', countParams);
let total = 0;
if (!countResponse.error && countResponse.data) {
const countData = extractApiData<{ id: number }[]>(countResponse.data) || [];
total = countData.length;
}
return {
data: {
templates,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize)
} as SearchResult
};
} catch (error) {
console.error('获取合同模板失败:', error);
return {
error: error instanceof Error ? error.message : '获取合同模板失败',
status: 500
};
}
}
/**
* 根据ID获取单个合同模板
*/
export async function getContractTemplate(id: string | number) {
try {
const params: PostgrestParams = {
select: 'id,template_code,title,category_id,description,file_path,file_format,is_featured,created_at,updated_at,pdf_file_path,category:contract_categories(id,name,icon,description)',
filter: { 'id': `eq.${id}` }
};
const response = await postgrestGet<ContractTemplate[]>('contract_templates', params);
if (response.error) {
return { error: response.error, status: response.status || 500 };
}
const templates = extractApiData<ContractTemplate[]>(response.data) || [];
if (templates.length === 0) {
return { error: '模板不存在', status: 404 };
}
return { data: templates[0] };
} catch (error) {
console.error('获取合同模板详情失败:', error);
return {
error: error instanceof Error ? error.message : '获取合同模板详情失败',
status: 500
};
}
}
/**
* 获取推荐模板
*/
export async function getFeaturedTemplates(limit: number = 6) {
try {
const params: PostgrestParams = {
select: 'id,template_code,title,category_id,description,file_path,file_format,is_featured,created_at,updated_at,pdf_file_path,category:contract_categories(id,name,icon,description)',
filter: { 'is_featured': 'eq.true' },
order: 'updated_at.desc',
limit
};
const response = await postgrestGet<ContractTemplate[]>('contract_templates', params);
if (response.error) {
return { error: response.error, status: response.status || 500 };
}
const templates = extractApiData<ContractTemplate[]>(response.data) || [];
return { data: templates };
} catch (error) {
console.error('获取推荐模板失败:', error);
return {
error: error instanceof Error ? error.message : '获取推荐模板失败',
status: 500
};
}
}
/**
* 搜索合同模板(智能搜索)
*/
export async function searchContractTemplates(
query: string,
filters: Omit<TemplateSearchParams, 'keyword'> = {}
) {
return await getContractTemplates({
keyword: query,
...filters
});
}
@@ -40,10 +40,10 @@ export function SearchResultHeader({
value={sortBy}
onChange={(e) => onSortChange(e.target.value)}
>
<option value="relevance"></option>
<option value="relevance"></option>
<option value="newest"></option>
<option value="popular">使</option>
<option value="rating"></option>
{/* <option value="popular">使用频率</option>
<option value="rating">评分最高</option> */}
</select>
</div>
</div>
@@ -1,15 +1,17 @@
import { useState } from 'react';
// import { useState } from 'react';
import { useNavigate } from '@remix-run/react';
interface Template {
id: string;
title: string;
type: string;
// type: string;
description: string;
updateTime: string;
useCount: number;
rating: number;
// useCount: number;
// rating: number;
category: string;
file_path?: string;
file_format?: string;
}
interface TemplateCardProps {
@@ -18,21 +20,66 @@ interface TemplateCardProps {
}
export function TemplateCard({ template, onClick }: TemplateCardProps) {
const [isFavorited, setIsFavorited] = useState(false);
// 注释掉收藏功能,后续版本再开发
// const [isFavorited, setIsFavorited] = useState(false);
const navigate = useNavigate();
const handleFavoriteClick = (e: React.MouseEvent) => {
/* const handleFavoriteClick = (e: React.MouseEvent) => {
e.stopPropagation();
setIsFavorited(!isFavorited);
}; */
// MinIO下载URL构建函数
const buildDownloadUrl = (filePath: string): string => {
// 使用实际的MinIO配置
const minioHost = 'http://nas.7bm.co:9000';
const bucketName = 'docauditai';
// 确保文件路径不以/开头
const cleanPath = filePath.startsWith('/') ? filePath.substring(1) : filePath;
return `${minioHost}/${bucketName}/${cleanPath}`;
};
// 下载文件函数
const downloadFile = async (filePath: string, fileName: string) => {
try {
const downloadUrl = buildDownloadUrl(filePath);
// 清理文件名,移除可能导致问题的字符
const cleanFileName = fileName.replace(/[<>:"/\\|?*]/g, '_');
// 创建临时下载链接
const link = document.createElement('a');
link.href = downloadUrl;
link.download = cleanFileName;
link.target = '_blank';
// 触发下载
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
console.log('开始下载文件:', cleanFileName);
} catch (error) {
console.error('下载文件失败:', error);
alert('下载失败,请稍后重试');
}
};
const handleActionClick = (e: React.MouseEvent, action: string) => {
e.stopPropagation();
switch (action) {
case '立即使用':
console.log('下载并使用模板:', template.id);
// 这里应该触发下载逻辑
case '立即下载':
if (template.file_path) {
// 构建文件名,使用模板标题和文件格式
const fileExtension = template.file_format || 'docx';
const fileName = `${template.title}.${fileExtension}`;
downloadFile(template.file_path, fileName);
} else {
alert('文件路径不存在,无法下载');
}
break;
case '预览':
// 导航到模板详情页面
@@ -43,7 +90,7 @@ export function TemplateCard({ template, onClick }: TemplateCardProps) {
}
};
const renderStars = (rating: number) => {
/* const renderStars = (rating: number) => {
const stars = [];
const fullStars = Math.floor(rating);
const hasHalfStar = rating % 1 !== 0;
@@ -58,7 +105,7 @@ export function TemplateCard({ template, onClick }: TemplateCardProps) {
}
}
return stars;
};
}; */
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -76,28 +123,43 @@ export function TemplateCard({ template, onClick }: TemplateCardProps) {
tabIndex={0}
aria-label={`查看${template.title}详情`}
>
<div className="template-header">
{/* 注释掉头部的type和rating显示 */}
{/* <div className="template-header">
<div className="template-type">{template.type}</div>
<div className="flex items-center gap-1 text-yellow-500">
{renderStars(template.rating)}
<span className="text-xs ml-1">{template.rating}</span>
</div>
</div>
</div> */}
<h3 className="template-title">{template.title}</h3>
<p className="template-desc">{template.description}</p>
<p
className="template-desc"
style={{
height: '4.5rem', // 固定高度约3行文字
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 3, // 限制3行
WebkitBoxOrient: 'vertical',
lineHeight: '1.5rem'
}}
>
{template.description}
</p>
<div className="template-meta">
<span>{template.updateTime}</span>
<span>使{template.useCount.toLocaleString()}</span>
{/* 注释掉使用次数显示 */}
{/* <span>使用次数:{template.useCount.toLocaleString()}</span> */}
</div>
<div className="template-actions mt-3">
<button
className="action-btn primary"
onClick={(e) => handleActionClick(e, '立即使用')}
onClick={(e) => handleActionClick(e, '立即下载')}
>
使
</button>
<button
className="action-btn"
@@ -105,13 +167,14 @@ export function TemplateCard({ template, onClick }: TemplateCardProps) {
>
</button>
<button
{/* 注释掉收藏按钮 */}
{/* <button
className="action-btn"
onClick={handleFavoriteClick}
title={isFavorited ? '取消收藏' : '收藏'}
>
<i className={isFavorited ? 'ri-star-fill text-yellow-500' : 'ri-star-line'}></i>
</button>
</button> */}
</div>
</div>
);
@@ -3,12 +3,14 @@ import { TemplateCard } from './TemplateCard';
interface Template {
id: string;
title: string;
type: string;
// type: string;
description: string;
updateTime: string;
useCount: number;
rating: number;
// useCount: number;
// rating: number;
category: string;
file_path?: string;
file_format?: string;
}
interface TemplateGridProps {
+179 -251
View File
@@ -1,98 +1,16 @@
import type { MetaFunction, LoaderFunctionArgs } from '@remix-run/node';
import { useLoaderData, useNavigate } from '@remix-run/react';
import { useState } 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';
// 导入FilePreview组件
import { FilePreview } from '~/components/reviews';
export const links = () => [
{ rel: 'stylesheet', href: styles },
// 添加专门的样式隔离
{
rel: 'stylesheet',
href: 'data:text/css;base64,' + btoa(`
.file-preview-isolation {
all: unset !important;
display: block !important;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important;
font-size: 14px !important;
line-height: 1.5 !important;
color: #333 !important;
background: #fff !important;
width: 100% !important;
min-height: 600px !important;
position: relative !important;
isolation: isolate !important;
contain: layout style !important;
}
.file-preview-isolation * {
font-family: inherit !important;
box-sizing: border-box !important;
}
.file-preview-isolation .file-preview-header {
display: flex !important;
justify-content: space-between !important;
align-items: center !important;
padding: 8px 16px !important;
background: #f8f9fa !important;
border-bottom: 1px solid #dee2e6 !important;
font-size: 14px !important;
line-height: 1.5 !important;
max-width: none !important;
width: 100% !important;
text-overflow: unset !important;
white-space: nowrap !important;
overflow: visible !important;
}
.file-preview-isolation .file-preview-actions {
display: flex !important;
align-items: center !important;
gap: 8px !important;
}
.file-preview-isolation button,
.file-preview-isolation .ant-btn {
padding: 4px 8px !important;
margin: 0 2px !important;
border: 1px solid #d9d9d9 !important;
border-radius: 4px !important;
background: white !important;
color: #333 !important;
font-size: 12px !important;
line-height: 1.4 !important;
cursor: pointer !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
min-height: 24px !important;
text-decoration: none !important;
white-space: nowrap !important;
vertical-align: middle !important;
}
.file-preview-isolation input,
.file-preview-isolation .ant-input {
padding: 4px 8px !important;
border: 1px solid #d9d9d9 !important;
border-radius: 4px !important;
background: white !important;
color: #333 !important;
font-size: 12px !important;
line-height: 1.4 !important;
min-height: 24px !important;
width: auto !important;
max-width: 40px !important;
text-align: center !important;
outline: none !important;
}
.file-preview-isolation .file-preview-content {
max-height: calc(100vh - 150px) !important;
overflow: auto !important;
background: #f8f9fa !important;
padding: 0 !important;
margin: 0 !important;
}
`)
}
{ rel: 'stylesheet', href: filePreviewStyles },
];
export const meta: MetaFunction<typeof loader> = ({ data }) => {
@@ -112,123 +30,108 @@ export const handle = {
}
};
// 模拟详细数据
const getTemplateDetail = (id: string) => {
const templates = {
'1': {
id: '1',
title: '烟草产品销售合同(2023版)',
type: '销售合同 · 标准版',
description: '本模板是专为烟草行业设计的标准销售合同,严格遵循《烟草专卖法》等相关法律法规,涵盖了烟草产品销售过程中的各个关键环节。',
updateTime: '2023年10月25日',
useCount: 2156,
rating: 4.9,
fileSize: '245KB',
scope: '烟草产品销售',
legalBasis: '《合同法》《烟草专卖法》',
templateCode: 'XS-2023-001',
reviews: [
{
user: '李经理',
rating: 5,
comment: '模板非常专业,条款完整,符合行业规范。我们公司一直在使用这个模板,效果很好。',
date: '2023-10-20'
},
{
user: '王总',
rating: 4,
comment: '模板结构清晰,易于理解和使用。特别是违约责任条款写得很详细,对我们很有帮助。',
date: '2023-10-18'
}
],
features: [
{ title: '法律合规', description: '严格遵循烟草行业法律法规,确保合同条款合法有效', icon: 'ri-shield-check-line', color: 'green' },
{ title: '条款完整', description: '涵盖销售全流程,条款结构完整,逻辑清晰', icon: 'ri-settings-3-line', color: 'blue' },
{ title: '易于定制', description: '模板化设计,可根据具体业务需求灵活调整', icon: 'ri-edit-line', color: 'purple' },
{ title: '行业标准', description: '符合烟草行业标准,被广泛使用和认可', icon: 'ri-award-line', color: 'orange' }
],
structure: [
{ step: 1, title: '合同主体', description: '甲乙双方基本信息、资质证明' },
{ step: 2, title: '标的物条款', description: '产品名称、规格、数量、质量标准' },
{ step: 3, title: '价格与付款', description: '价格条款、付款方式、结算周期' },
{ step: 4, title: '交付与验收', description: '交付时间、地点、方式、验收标准' },
{ step: 5, title: '违约责任', description: '违约情形、责任承担、损失赔偿' },
{ step: 6, title: '争议解决', description: '争议处理方式、管辖法院' }
],
preview: `
中文合同预览内容...
烟草产品销售合同
合同编号:_______________
甲方(销售方):_________________________
地址:_____________________________________
法定代表人:_______________ 联系电话:_______________
烟草专卖许可证号:_________________________
乙方(采购方):_________________________
地址:_____________________________________
法定代表人:_______________ 联系电话:_______________
烟草专卖零售许可证号:_____________________
根据《中华人民共和国合同法》、《中华人民共和国烟草专卖法》等相关法律法规,
甲乙双方在平等、自愿、公平、诚信的基础上,就烟草产品销售事宜达成如下协议:
第一条 标的物
1.1 产品名称:_________________________
1.2 产品规格:_________________________
1.3 产品数量:_________________________
1.4 产品单价:_________________________
1.5 合同总金额:_______________________
... 更多条款内容请下载完整模板查看 ...
`
}
};
return templates[id as keyof typeof templates] || null;
};
export async function loader({ params }: LoaderFunctionArgs) {
const template = getTemplateDetail(params.id!);
const templateId = params.id!;
if (!template) {
try {
const response = await getContractTemplate(templateId);
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 });
}
return { template };
}
export default function ContractTemplateDetail() {
const { template } = useLoaderData<typeof loader>();
const { template }: { template: ContractTemplate } = useLoaderData<typeof loader>();
const navigate = useNavigate();
const [isFavorited, setIsFavorited] = useState(false);
// 注释掉收藏功能
// const [isFavorited, setIsFavorited] = useState(false);
const handleBack = () => {
navigate(-1);
};
// MinIO下载URL构建函数
const buildDownloadUrl = (filePath: string): string => {
const minioHost = 'http://nas.7bm.co:9000';
const bucketName = 'docauditai';
const cleanPath = filePath.startsWith('/') ? filePath.substring(1) : filePath;
return `${minioHost}/${bucketName}/${cleanPath}`;
};
// 下载文件函数
const downloadFile = async (filePath: string, fileName: string) => {
try {
const downloadUrl = buildDownloadUrl(filePath);
const cleanFileName = fileName.replace(/[<>:"/\\|?*]/g, '_');
const link = document.createElement('a');
link.href = downloadUrl;
link.download = cleanFileName;
link.target = '_blank';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
console.log('开始下载文件:', cleanFileName);
} catch (error) {
console.error('下载文件失败:', error);
alert('下载失败,请稍后重试');
}
};
const handleDownload = () => {
console.log('下载模板:', template.id);
// 这里应该是实际的下载逻辑
if (template.file_path) {
const fileExtension = template.file_format || 'docx';
const fileName = `${template.title}.${fileExtension}`;
downloadFile(template.file_path, fileName);
} else {
alert('文件路径不存在,无法下载');
}
};
const handlePreview = () => {
console.log('预览模板:', template.id);
// 这里应该打开预览模态框或新页面
// 页面内预览,滚动到预览区域
const previewElement = document.getElementById('template-preview');
if (previewElement) {
previewElement.scrollIntoView({ behavior: 'smooth' });
}
};
const handleFavorite = () => {
/* const handleFavorite = () => {
setIsFavorited(!isFavorited);
console.log('收藏状态:', !isFavorited);
};
}; */
const handleShare = () => {
console.log('分享模板:', template.id);
// 这里应该是分享功能
};
/* const handleShare = () => {
// 复制当前页面URL到剪贴板
navigator.clipboard.writeText(window.location.href).then(() => {
alert('链接已复制到剪贴板');
}).catch(() => {
alert('复制失败,请手动复制链接');
});
}; */
const renderStars = (rating: number) => {
// 注释掉评分相关功能
/* const renderStars = (rating: number) => {
const stars = [];
const fullStars = Math.floor(rating);
@@ -240,14 +143,14 @@ export default function ContractTemplateDetail() {
}
}
return stars;
};
}; */
// 创建文件内容对象用于FilePreview组件
const fileContent = {
const fileContent = template.pdf_file_path ? {
title: template.title,
contractNumber: template.templateCode,
// 设置PDF路径,FilePreview会自动拼接基础URL
path: 'contract-template/买卖合同/买卖合同范本.pdf',
contractNumber: template.template_code,
// 使用pdf_file_path字段
path: template.pdf_file_path,
parties: {
partyA: {
name: '',
@@ -263,7 +166,7 @@ export default function ContractTemplateDetail() {
}
},
sections: []
};
} : null;
return (
<div className="contract-search-results">
@@ -282,7 +185,8 @@ export default function ContractTemplateDetail() {
<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="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">
@@ -290,20 +194,35 @@ export default function ContractTemplateDetail() {
<span className="text-sm ml-1">{template.rating} (156评价)</span>
</div>
</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.templateCode}</span>
<span>{template.template_code}</span>
</div>
<div className="meta-item">
<span className="meta-label text-gray-500"></span>
<span>{template.updateTime}</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>
@@ -318,7 +237,7 @@ export default function ContractTemplateDetail() {
<div className="meta-item">
<span className="meta-label text-gray-500">法律依据:</span>
<span>{template.legalBasis}</span>
</div>
</div> */}
</div>
<div className="detail-actions flex gap-3">
@@ -329,27 +248,30 @@ export default function ContractTemplateDetail() {
<i className="ri-download-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={handlePreview}
>
<i className="ri-eye-line"></i>
线
</button>
<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
</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>
</button> */}
</div>
</div>
@@ -359,14 +281,18 @@ export default function ContractTemplateDetail() {
<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.description || '该合同模板为标准格式,包含完整的合同条款结构,适用于相关业务场景的合同签署。'}
{template.category?.description && (
<>
<br /><br />
<strong></strong>{template.category.description}
</>
)}
</p>
</div>
{/* 主要特点 */}
<div className="content-section mb-8">
{/* 注释掉主要特点模块 */}
{/* <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) => (
@@ -379,10 +305,10 @@ export default function ContractTemplateDetail() {
</div>
))}
</div>
</div>
</div> */}
{/* 合同条款结构 */}
<div className="content-section mb-8">
{/* 注释掉合同条款结构模块 */}
{/* <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) => (
@@ -397,51 +323,53 @@ export default function ContractTemplateDetail() {
</div>
))}
</div>
</div>
</div> */}
{/* 合同预览 */}
<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"
style={{
// 使用CSS变量避免继承
'--font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
'--font-size': '14px',
'--line-height': '1.5',
'--text-color': '#333333',
'--bg-color': '#ffffff',
// 强制重置所有可能的样式
all: 'unset',
display: 'block',
fontFamily: 'var(--font-family)',
fontSize: 'var(--font-size)',
lineHeight: 'var(--line-height)',
color: 'var(--text-color)',
backgroundColor: 'var(--bg-color)',
width: '100%',
minHeight: '600px',
position: 'relative',
isolation: 'isolate', // 创建新的层叠上下文
contain: 'layout style', // CSS容器化
zIndex: 0
} as React.CSSProperties}
>
<FilePreview
fileContent={fileContent}
activeReviewPointResultId={null}
targetPage={undefined}
isStructuredView={false}
/>
{/* 合同预览 - 只有当存在pdf_file_path时才显示 */}
{fileContent && (
<div className="content-section mb-8" id="template-preview">
<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"
style={{
// 使用CSS变量避免继承
'--font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
'--font-size': '14px',
'--line-height': '1.5',
'--text-color': '#333333',
'--bg-color': '#ffffff',
// 强制重置所有可能的样式
all: 'unset',
display: 'block',
fontFamily: 'var(--font-family)',
fontSize: 'var(--font-size)',
lineHeight: 'var(--line-height)',
color: 'var(--text-color)',
backgroundColor: 'var(--bg-color)',
width: '100%',
minHeight: '600px',
position: 'relative',
isolation: 'isolate', // 创建新的层叠上下文
contain: 'layout style', // CSS容器化
zIndex: 0
} as React.CSSProperties}
>
<FilePreview
fileContent={fileContent}
activeReviewPointResultId={null}
targetPage={undefined}
isStructuredView={false}
/>
</div>
</div>
</div>
</div>
)}
{/* 用户评价 */}
<div className="content-section">
{/* 注释掉用户评价模块 */}
{/* <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) => (
@@ -462,7 +390,7 @@ export default function ContractTemplateDetail() {
</div>
))}
</div>
</div>
</div> */}
</div>
</div>
</div>
+189 -123
View File
@@ -1,10 +1,11 @@
import type { MetaFunction, LoaderFunctionArgs } from '@remix-run/node';
import { useLoaderData, useSearchParams, useNavigate } from '@remix-run/react';
import { useState } from 'react';
import { SearchResultHeader } from '~/components/contract-template/SearchResultHeader';
import { useState, useEffect } from 'react';
import { FilterTabs } from '~/components/contract-template/FilterTabs';
import { TemplateGrid } from '~/components/contract-template/TemplateGrid';
import { Pagination } from '~/components/ui/Pagination';
import { getContractTemplates, getContractCategoriesWithCount } from '~/api/contract-template/templates';
import type { ContractTemplate, TemplateSearchParams, ContractCategoryWithCount } from '~/api/contract-template/templates';
import styles from '~/styles/pages/contract-template.css?url';
export const links = () => [
@@ -21,129 +22,155 @@ export const meta: MetaFunction = () => {
];
};
// 模拟数据 - 完整的模板库
const mockTemplates = [
{
id: '1',
title: '烟草产品销售合同(2023版)',
type: '标准版',
description: '最新版本的烟草产品销售合同模板,包含完整的法律条款、风险控制措施和行业标准要求。',
updateTime: '2023-10-25',
useCount: 2156,
rating: 4.9,
category: '销售合同'
},
{
id: '2',
title: '零售商销售协议模板',
type: '标准版',
description: '专为零售商设计的销售协议,涵盖商品配送、结算方式、退换货政策等关键条款。',
updateTime: '2023-10-20',
useCount: 1834,
rating: 4.8,
category: '销售合同'
},
{
id: '3',
title: '大客户销售合同模板',
type: '专业版',
description: '适用于大客户的专业销售合同,包含定制化条款、特殊优惠政策和长期合作框架。',
updateTime: '2023-10-18',
useCount: 1245,
rating: 4.7,
category: '销售合同'
},
{
id: '4',
title: '小额销售合同(简化版)',
type: '简化版',
description: '适用于小额交易的简化版销售合同,条款精简但保证法律效力。',
updateTime: '2023-10-15',
useCount: 956,
rating: 4.6,
category: '销售合同'
},
{
id: '5',
title: '区域代理销售合同',
type: '标准版',
description: '区域代理商专用销售合同,明确代理权限、销售目标和考核标准。',
updateTime: '2023-10-12',
useCount: 743,
rating: 4.5,
category: '销售合同'
},
{
id: '6',
title: '批发销售合同模板',
type: '标准版',
description: '适用于大宗批发业务的销售合同,包含数量折扣、物流配送等专业条款。',
updateTime: '2023-10-10',
useCount: 612,
rating: 4.4,
category: '销售合同'
},
{
id: '7',
title: '设备采购合同标准模板',
type: '标准版',
description: '设备采购专用合同模板,包含详细的技术要求、验收标准、质保条款等内容。',
updateTime: '2023-10-08',
useCount: 589,
rating: 4.6,
category: '采购合同'
},
{
id: '8',
title: '服务类采购合同',
type: '标准版',
description: '适用于各类服务采购,包含服务标准、交付要求、考核指标等条款。',
updateTime: '2023-10-05',
useCount: 432,
rating: 4.5,
category: '采购合同'
}
];
// 将数据库模板转换为前端显示格式
function transformTemplate(template: ContractTemplate) {
return {
id: template.id.toString(),
title: template.title,
// type: templateType,
description: template.description || '',
updateTime: new Date(template.updated_at).toLocaleDateString('zh-CN'),
// useCount: mockUsageCount,
// rating: parseFloat(mockRating),
category: template.category?.name || '其他',
file_path: template.file_path,
file_format: template.file_format
};
}
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const category = url.searchParams.get('category') || '';
const category_id = url.searchParams.get('category_id') || '';
const type = url.searchParams.get('type') || '';
const sortBy = url.searchParams.get('sortBy') || 'relevance';
const page = parseInt(url.searchParams.get('page') || '1');
const pageSize = 6;
// 筛选模板
let filteredTemplates = mockTemplates;
if (category) {
filteredTemplates = filteredTemplates.filter(t => t.category === category);
}
if (type) {
filteredTemplates = filteredTemplates.filter(t => t.type === type);
}
const total = filteredTemplates.length;
const startIndex = (page - 1) * pageSize;
const templates = filteredTemplates.slice(startIndex, startIndex + pageSize);
try {
// 根据sortBy值设置数据库排序参数
let dbSortBy = 'id';
let dbSortOrder: 'asc' | 'desc' = 'asc';
switch (sortBy) {
case 'relevance':
dbSortBy = 'id';
dbSortOrder = 'asc';
break;
case 'newest':
dbSortBy = 'updated_at';
dbSortOrder = 'desc';
break;
/* case 'popular':
// 暂时按创建时间排序,后续可以加入使用频率字段
dbSortBy = 'created_at';
dbSortOrder = 'desc';
break;
case 'rating':
// 暂时按特色推荐排序,后续可以加入评分字段
dbSortBy = 'is_featured';
dbSortOrder = 'desc';
break; */
default:
dbSortBy = 'id';
dbSortOrder = 'asc';
}
return {
templates,
total,
page,
pageSize,
category,
type
};
// 构建搜索参数
const searchParams: TemplateSearchParams = {
page,
pageSize,
sortBy: dbSortBy,
sortOrder: dbSortOrder
};
// 优先使用category_id,其次使用category名称
if (category_id) {
searchParams.category_id = parseInt(category_id);
} else if (category) {
searchParams.category = category;
}
// 并行获取模板数据和分类数据
const [templatesResponse, categoriesResponse] = await Promise.all([
getContractTemplates(searchParams),
getContractCategoriesWithCount()
]);
// 处理模板数据
if (templatesResponse.error) {
console.error('获取模板列表失败:', templatesResponse.error);
return {
templates: [],
total: 0,
page,
pageSize,
category,
category_id,
type,
sortBy,
categories: []
};
}
// 处理分类数据
const categories: ContractCategoryWithCount[] = categoriesResponse.error ? [] : categoriesResponse.data || [];
// 转换模板数据格式
const transformedTemplates = templatesResponse.data?.templates.map(transformTemplate) || [];
// 注释掉类型筛选,因为数据库中没有type字段且已隐藏该功能
/* if (type) {
transformedTemplates = transformedTemplates.filter(t => t.type === type);
} */
// 获取当前分类信息(用于显示)
let currentCategory = '全部';
if (category_id) {
const cat = categories.find(c => c.id === parseInt(category_id));
currentCategory = cat?.name || '全部';
} else if (category) {
currentCategory = category;
}
return {
templates: transformedTemplates,
total: templatesResponse.data?.total || 0,
page,
pageSize,
category: currentCategory,
category_id,
type,
sortBy,
categories
};
} catch (error) {
console.error('加载模板列表失败:', error);
return {
templates: [],
total: 0,
page,
pageSize,
category,
category_id,
type,
sortBy,
categories: []
};
}
}
export default function ContractTemplateList() {
const { templates, total, page, pageSize, category } = useLoaderData<typeof loader>();
const { templates, total, page, pageSize, category, category_id, categories, sortBy } = useLoaderData<typeof loader>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [activeFilter, setActiveFilter] = useState(category || '全部');
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [sortBy, setSortBy] = useState('newest');
// 监听category变化,同步更新activeFilter状态
useEffect(() => {
setActiveFilter(category || '全部');
}, [category]);
const handleTemplateClick = (templateId: string) => {
navigate(`/contract-template/detail/${templateId}`);
@@ -155,8 +182,17 @@ export default function ContractTemplateList() {
if (filter === '全部') {
params.delete('category');
params.delete('category_id');
} else {
params.set('category', filter);
// 根据分类名称找到对应的ID
const selectedCategory = categories.find(cat => cat.name === filter);
if (selectedCategory) {
params.set('category_id', selectedCategory.id.toString());
params.delete('category'); // 删除旧的category参数
} else {
params.set('category', filter);
params.delete('category_id');
}
}
params.delete('page'); // 重置页码
@@ -169,13 +205,22 @@ export default function ContractTemplateList() {
navigate(`/contract-template/list?${params.toString()}`);
};
const handleSortChange = (newSort: string) => {
const params = new URLSearchParams(searchParams);
params.set('sortBy', newSort);
params.delete('page'); // 重置页码
navigate(`/contract-template/list?${params.toString()}`);
};
// 动态生成筛选选项
const allCategories = [...new Set(mockTemplates.map(t => t.category))];
// 计算所有分类的总模板数量
const totalAllTemplates = categories.reduce((sum, cat) => sum + (cat.template_count || 0), 0);
const filters = [
{ label: '全部', count: mockTemplates.length },
...allCategories.map(cat => ({
label: cat,
count: mockTemplates.filter(t => t.category === cat).length
{ label: '全部', count: totalAllTemplates },
...categories.map(cat => ({
label: cat.name,
count: cat.template_count || 0
}))
];
@@ -183,7 +228,7 @@ export default function ContractTemplateList() {
const currentCategory = category || '全部';
return (
<div className="contract-search-results">
<div className="contract-search-results" key={`${category}-${category_id}-${page}`}>
{/* 页面头部 */}
<div className="result-header">
<div>
@@ -195,13 +240,34 @@ export default function ContractTemplateList() {
</div>
</div>
<div className="flex items-center gap-4">
<SearchResultHeader
total={0}
viewMode={viewMode}
onViewModeChange={setViewMode}
sortBy={sortBy}
onSortChange={setSortBy}
/>
{/* 视图切换 */}
<div className="view-toggle">
<button
className={`view-btn ${viewMode === 'grid' ? 'active' : ''}`}
onClick={() => setViewMode('grid')}
aria-label="网格视图"
>
<i className="ri-grid-line"></i>
</button>
<button
className={`view-btn ${viewMode === 'list' ? 'active' : ''}`}
onClick={() => setViewMode('list')}
aria-label="列表视图"
>
<i className="ri-list-check"></i>
</button>
</div>
{/* 排序选择 */}
<select
className="px-3 py-3 border border-gray-200 rounded-lg text-sm"
value={sortBy}
onChange={(e) => handleSortChange(e.target.value)}
>
<option value="relevance"></option>
<option value="newest"></option>
{/* <option value="popular">使用频率</option>
<option value="rating">评分最高</option> */}
</select>
</div>
</div>
+54 -16
View File
@@ -1,6 +1,8 @@
import type { MetaFunction } from '@remix-run/node';
import { useNavigate } from '@remix-run/react';
import { useNavigate, useLoaderData } from '@remix-run/react';
import { ContractSearchHero } from '~/components/contract-template/ContractSearchHero';
import { getContractCategoriesWithCount } from '~/api/contract-template/templates';
import type { ContractCategoryWithCount } from '~/api/contract-template/templates';
import styles from '~/styles/pages/contract-template.css?url';
export const links = () => [
@@ -22,18 +24,50 @@ export const handle = {
breadcrumb: "智能搜索"
};
/**
* 转换分类数据为前端显示格式
* @param category 分类数据
* @returns 转换后的分类数据
*/
function transformCategory(category: ContractCategoryWithCount) {
return {
id: category.id,
name: category.name,
icon: category.icon || 'ri-file-text-line',
count: category.template_count
};
}
/**
* 加载分类数据
* @returns 分类数据
*/
export async function loader() {
try {
// 使用聚合查询获取分类及其模板数量
const categoriesResponse = await getContractCategoriesWithCount();
// 处理分类数据
if (categoriesResponse.error) {
console.error('获取分类失败:', categoriesResponse.error);
return { categories: [] };
}
const categories = categoriesResponse.data || [];
// 转换分类数据格式
const categoriesWithCount = categories.map(transformCategory);
return { categories: categoriesWithCount };
} catch (error) {
console.error('加载分类数据失败:', error);
return { categories: [] };
}
}
export default function ContractTemplateSearchIndex() {
const navigate = useNavigate();
// 模拟分类数据
const categories = [
{ name: '销售合同', icon: 'ri-contract-line', count: 128 },
{ name: '采购合同', icon: 'ri-shopping-cart-line', count: 96 },
{ name: '物流运输', icon: 'ri-truck-line', count: 64 },
{ name: '人事劳务', icon: 'ri-user-settings-line', count: 52 },
{ name: '租赁合同', icon: 'ri-building-line', count: 38 },
{ name: '保密协议', icon: 'ri-shield-check-line', count: 24 }
];
const { categories } = useLoaderData<typeof loader>();
const handleSearch = (query: string) => {
if (query.trim()) {
@@ -41,8 +75,12 @@ export default function ContractTemplateSearchIndex() {
}
};
const handleCategoryClick = (categoryName: string) => {
navigate(`/contract-template/list?category=${encodeURIComponent(categoryName)}`);
/**
* 处理分类点击事件 - 使用ID而不是名称
* @param categoryId 分类ID
*/
const handleCategoryClick = (categoryId: number) => {
navigate(`/contract-template/list?category_id=${categoryId}`);
};
return (
@@ -51,13 +89,13 @@ export default function ContractTemplateSearchIndex() {
<div className="quick-categories">
{categories.map((category, index) => (
<div
key={index}
key={category.id || index}
className="category-card"
onClick={() => handleCategoryClick(category.name)}
onClick={() => handleCategoryClick(category.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleCategoryClick(category.name);
handleCategoryClick(category.id);
}
}}
role="button"
+200 -157
View File
@@ -6,6 +6,8 @@ import { SearchResultHeader } from '~/components/contract-template/SearchResultH
import { FilterTabs } from '~/components/contract-template/FilterTabs';
import { TemplateGrid } from '~/components/contract-template/TemplateGrid';
import { Pagination } from '~/components/ui/Pagination';
import { searchContractTemplates, getContractCategories } from '~/api/contract-template/templates';
import type { ContractTemplate, ContractCategory } from '~/api/contract-template/templates';
import styles from '~/styles/pages/contract-template.css?url';
export const links = () => [
@@ -27,177 +29,200 @@ export const handle = {
breadcrumb: "搜索结果"
};
// 模拟数据 - 扩展搜索结果
const mockSearchResults = [
{
id: '1',
title: '烟草产品销售合同标准模板',
type: '销售合同',
description: '适用于烟草产品销售业务,包含完整的违约责任条款、付款方式、交付条件等核心要素,符合行业规范要求。',
updateTime: '2023-10-25',
useCount: 1248,
rating: 4.8,
category: '销售合同'
},
{
id: '2',
title: '零售商销售协议模板',
type: '销售合同',
description: '专为零售商设计的销售协议,详细规定了违约责任、退换货政策、结算方式等条款。',
updateTime: '2023-10-20',
useCount: 856,
rating: 4.6,
category: '销售合同'
},
{
id: '3',
title: '设备采购合同(含违约条款)',
type: '采购合同',
description: '设备采购专用合同模板,包含详细的违约责任条款、质量保证、验收标准等内容。',
updateTime: '2023-10-18',
useCount: 642,
rating: 4.7,
category: '采购合同'
},
{
id: '4',
title: '批发销售合同模板',
type: '销售合同',
description: '适用于大宗批发业务的销售合同,强化了违约责任条款和风险控制措施。',
updateTime: '2023-10-15',
useCount: 534,
rating: 4.5,
category: '销售合同'
},
{
id: '5',
title: '技术服务合同(标准版)',
type: '服务合同',
description: '技术服务类合同模板,包含服务标准、违约责任、知识产权保护等关键条款。',
updateTime: '2023-10-12',
useCount: 423,
rating: 4.4,
category: '服务合同'
},
{
id: '6',
title: '区域代理销售合同',
type: '销售合同',
description: '区域代理商专用销售合同,明确代理权限、销售目标、违约责任等核心条款。',
updateTime: '2023-10-10',
useCount: 312,
rating: 4.3,
category: '销售合同'
},
{
id: '7',
title: '物流运输服务合同',
type: '物流运输',
description: '专业的物流运输服务合同模板,涵盖运输责任、保险、违约赔偿等关键条款。',
updateTime: '2023-10-08',
useCount: 267,
rating: 4.2,
category: '物流运输'
},
{
id: '8',
title: '仓储管理服务协议',
type: '物流运输',
description: '仓储管理专用合同,包含货物保管、出入库管理、损失责任等详细条款。',
updateTime: '2023-10-05',
useCount: 189,
rating: 4.1,
category: '物流运输'
},
{
id: '9',
title: '劳务派遣合同模板',
type: '人事劳务',
description: '劳务派遣服务合同,明确派遣关系、工资福利、社保缴纳等人事管理条款。',
updateTime: '2023-10-03',
useCount: 345,
rating: 4.6,
category: '人事劳务'
},
{
id: '10',
title: '商业机密保护协议',
type: '保密协议',
description: '企业商业机密保护专用协议,涵盖信息范围、保密义务、违约责任等核心内容。',
updateTime: '2023-10-01',
useCount: 156,
rating: 4.5,
category: '保密协议'
},
{
id: '11',
title: '办公场地租赁合同',
type: '租赁合同',
description: '办公场地租赁标准合同,包含租金支付、物业管理、违约处理等全面条款。',
updateTime: '2023-09-28',
useCount: 278,
rating: 4.3,
category: '租赁合同'
},
{
id: '12',
title: '设备租赁协议(长期)',
type: '租赁合同',
description: '长期设备租赁专用协议,详细规定租赁期限、维护责任、续租条件等关键条款。',
updateTime: '2023-09-25',
useCount: 198,
rating: 4.4,
category: '租赁合同'
}
];
// 带搜索统计的分类类型
interface CategoryWithSearchCount extends ContractCategory {
searchCount: number;
}
// 前端显示的模板类型
interface DisplayTemplate {
id: string;
title: string;
// type: string;
description: string;
updateTime: string;
// useCount: number;
// rating: number;
category: string;
file_path?: string;
file_format?: string;
}
// 将数据库模板转换为前端显示格式
function transformTemplate(template: ContractTemplate) {
// 模拟使用次数和评分(实际项目中可以从其他表获取)
/* const mockUsageCount = Math.floor(Math.random() * 2000) + 100;
const mockRating = (Math.random() * 1.5 + 3.5).toFixed(1); */
return {
id: template.id.toString(),
title: template.title,
// type: template.is_featured ? '推荐版' : '标准版',
description: template.description || '',
updateTime: new Date(template.updated_at).toLocaleDateString('zh-CN'),
// useCount: mockUsageCount,
// rating: parseFloat(mockRating),
category: template.category?.name || '其他',
file_path: template.file_path,
file_format: template.file_format
};
}
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const query = url.searchParams.get('q') || '';
const category = url.searchParams.get('category') || '';
const sortBy = url.searchParams.get('sortBy') || 'relevance';
const page = parseInt(url.searchParams.get('page') || '1');
const pageSize = 6;
// 模拟搜索耗时
// 记录搜索开始时间
const startTime = Date.now();
// 根据sortBy值设置数据库排序参数
let dbSortBy = 'id';
let dbSortOrder: 'asc' | 'desc' = 'asc';
// 这里应该是实际的搜索逻辑
// 目前返回模拟数据
let filteredResults = mockSearchResults;
// 如果有查询条件,进行筛选
if (query || category) {
filteredResults = mockSearchResults.filter(item => {
const matchesQuery = !query ||
item.title.toLowerCase().includes(query.toLowerCase()) ||
item.description.toLowerCase().includes(query.toLowerCase());
const matchesCategory = !category || item.category === category;
return matchesQuery && matchesCategory;
});
switch (sortBy) {
case 'relevance':
dbSortBy = 'id';
dbSortOrder = 'asc';
break;
case 'newest':
dbSortBy = 'updated_at';
dbSortOrder = 'desc';
break;
/* case 'popular':
dbSortBy = 'created_at';
dbSortOrder = 'desc';
break;
case 'rating':
dbSortBy = 'is_featured';
dbSortOrder = 'desc';
break; */
default:
dbSortBy = 'id';
dbSortOrder = 'asc';
}
try {
// 并行获取搜索结果和分类数据
const [searchResponse, categoriesResponse] = await Promise.all([
searchContractTemplates(query, {
category,
page,
pageSize,
sortBy: dbSortBy,
sortOrder: dbSortOrder
}),
getContractCategories()
]);
// 计算搜索耗时
const endTime = Date.now();
const searchTime = (endTime - startTime) / 1000;
const searchTimeText = `搜索用时 ${searchTime.toFixed(1)}`;
// 处理搜索结果
if (searchResponse.error) {
console.error('搜索合同模板失败:', searchResponse.error);
return {
results: [],
query,
category,
total: 0,
page,
pageSize,
sortBy,
searchTime: '搜索失败',
categories: []
};
}
return {
results: filteredResults,
query,
category,
total: filteredResults.length,
page,
pageSize: 6,
searchTime: searchTimeText
};
// 处理分类数据
const categories = categoriesResponse.error ? [] : categoriesResponse.data || [];
// 转换模板数据格式
const transformedResults = searchResponse.data?.templates.map(transformTemplate) || [];
// 为每个分类获取搜索结果统计
let categoriesWithSearchCount: CategoryWithSearchCount[] = [];
if (query && query.trim()) {
// 并行为每个分类获取搜索结果数量
const categorySearchPromises = categories.map(async (cat): Promise<CategoryWithSearchCount> => {
try {
const categorySearchResponse = await searchContractTemplates(query, {
category: cat.name,
page: 1,
pageSize: 1000 // 设置较大的pageSize来获取总数
});
const count = categorySearchResponse.error ? 0 : (categorySearchResponse.data?.total || 0);
return {
...cat,
searchCount: count
};
} catch (error) {
console.error(`获取分类${cat.name}的搜索统计失败:`, error);
return {
...cat,
searchCount: 0
};
}
});
categoriesWithSearchCount = await Promise.all(categorySearchPromises);
} else {
// 如果没有搜索关键词,searchCount设为0
categoriesWithSearchCount = categories.map(cat => ({
...cat,
searchCount: 0
}));
}
// 计算搜索耗时
const endTime = Date.now();
const searchTime = (endTime - startTime) / 1000;
const searchTimeText = `搜索用时 ${searchTime.toFixed(1)}`;
return {
results: transformedResults,
query,
category,
total: searchResponse.data?.total || 0,
page,
pageSize,
sortBy,
searchTime: searchTimeText,
categories: categoriesWithSearchCount
};
} catch (error) {
console.error('加载搜索结果失败:', error);
return {
results: [],
query,
category,
total: 0,
page,
pageSize,
sortBy,
searchTime: '搜索失败',
categories: []
};
}
}
export default function ContractTemplateSearchResults() {
const { results, query, total, page, pageSize, searchTime } = useLoaderData<typeof loader>();
const { results, query, total, page, pageSize, sortBy, searchTime, categories }: {
results: DisplayTemplate[];
query: string;
total: number;
page: number;
pageSize: number;
sortBy: string;
searchTime: string;
categories: CategoryWithSearchCount[];
} = useLoaderData<typeof loader>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [activeFilter, setActiveFilter] = useState('全部');
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [sortBy, setSortBy] = useState('relevance');
const handleSearch = (newQuery: string) => {
if (newQuery.trim()) {
@@ -211,7 +236,16 @@ export default function ContractTemplateSearchResults() {
const handleFilterChange = (filter: string) => {
setActiveFilter(filter);
// 这里可以添加实际的筛选逻辑
const params = new URLSearchParams(searchParams);
if (filter === '全部') {
params.delete('category');
} else {
params.set('category', filter);
}
params.delete('page'); // 重置页码
navigate(`/contract-template/search/results?${params.toString()}`);
};
const handlePageChange = (newPage: number) => {
@@ -220,11 +254,20 @@ export default function ContractTemplateSearchResults() {
navigate(`/contract-template/search/results?${params.toString()}`);
};
const handleSortChange = (newSort: string) => {
const params = new URLSearchParams(searchParams);
params.set('sortBy', newSort);
params.delete('page'); // 重置页码
navigate(`/contract-template/search/results?${params.toString()}`);
};
// 动态生成筛选选项
const filters = [
{ label: '全部', count: total },
{ label: '销售合同', count: results.filter(r => r.category === '销售合同').length },
{ label: '采购合同', count: results.filter(r => r.category === '采购合同').length },
{ label: '服务合同', count: results.filter(r => r.category === '服务合同').length }
...categories.map(cat => ({
label: cat.name,
count: cat.searchCount || 0
}))
];
const totalPages = Math.ceil(total / pageSize);
@@ -244,7 +287,7 @@ export default function ContractTemplateSearchResults() {
viewMode={viewMode}
onViewModeChange={setViewMode}
sortBy={sortBy}
onSortChange={setSortBy}
onSortChange={handleSortChange}
/>
{/* 筛选标签 */}
@@ -0,0 +1,102 @@
/* 文件预览样式隔离 */
.file-preview-isolation {
all: unset !important;
display: block !important;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important;
font-size: 14px !important;
line-height: 1.5 !important;
color: #333 !important;
background: #fff !important;
width: 100% !important;
min-height: 600px !important;
position: relative !important;
isolation: isolate !important;
contain: layout style !important;
}
.file-preview-isolation * {
font-family: inherit !important;
box-sizing: border-box !important;
}
/* 为RemixIcon图标添加字体例外规则 */
.file-preview-isolation [class^="ri-"],
.file-preview-isolation [class*=" ri-"],
.file-preview-isolation i[class^="ri-"],
.file-preview-isolation i[class*=" ri-"] {
font-family: 'remixicon' !important;
font-style: normal !important;
font-weight: normal !important;
font-variant: normal !important;
text-transform: none !important;
line-height: 1 !important;
-webkit-font-smoothing: antialiased !important;
-moz-osx-font-smoothing: grayscale !important;
speak: none !important;
}
.file-preview-isolation .file-preview-header {
display: flex !important;
justify-content: space-between !important;
align-items: center !important;
padding: 8px 16px !important;
background: #f8f9fa !important;
border-bottom: 1px solid #dee2e6 !important;
font-size: 14px !important;
line-height: 1.5 !important;
max-width: none !important;
width: 100% !important;
text-overflow: unset !important;
white-space: nowrap !important;
overflow: visible !important;
}
.file-preview-isolation .file-preview-actions {
display: flex !important;
align-items: center !important;
gap: 8px !important;
}
.file-preview-isolation button,
.file-preview-isolation .ant-btn {
padding: 4px 8px !important;
margin: 0 2px !important;
border: 1px solid #d9d9d9 !important;
border-radius: 4px !important;
background: white !important;
color: #333 !important;
font-size: 12px !important;
line-height: 1.4 !important;
cursor: pointer !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
min-height: 24px !important;
text-decoration: none !important;
white-space: nowrap !important;
vertical-align: middle !important;
}
.file-preview-isolation input,
.file-preview-isolation .ant-input {
padding: 4px 8px !important;
border: 1px solid #d9d9d9 !important;
border-radius: 4px !important;
background: white !important;
color: #333 !important;
font-size: 12px !important;
line-height: 1.4 !important;
min-height: 24px !important;
width: auto !important;
max-width: 40px !important;
text-align: center !important;
outline: none !important;
}
.file-preview-isolation .file-preview-content {
max-height: calc(100vh - 150px) !important;
overflow: auto !important;
background: #f8f9fa !important;
padding: 0 !important;
margin: 0 !important;
}
+25
View File
@@ -256,4 +256,29 @@
.dark .content-container {
@apply bg-gray-900 text-gray-200;
}
}
/* RemixIcon 图标保护规则 - 防止被样式隔离覆盖 */
[class^="ri-"]:before,
[class*=" ri-"]:before,
i[class^="ri-"]:before,
i[class*=" ri-"]:before {
font-family: 'remixicon' !important;
font-style: normal !important;
font-weight: normal !important;
font-variant: normal !important;
text-transform: none !important;
line-height: 1 !important;
-webkit-font-smoothing: antialiased !important;
-moz-osx-font-smoothing: grayscale !important;
speak: none !important;
}
/* 确保RemixIcon元素本身也使用正确的字体 */
[class^="ri-"],
[class*=" ri-"],
i[class^="ri-"],
i[class*=" ri-"] {
font-family: 'remixicon' !important;
font-style: normal !important;
}
File diff suppressed because it is too large Load Diff