合同基本列表数据查询基本完善
This commit is contained in:
@@ -203,7 +203,6 @@ export async function apiRequest<T>(
|
||||
const config: AxiosRequestConfig = {
|
||||
...options,
|
||||
url,
|
||||
params,
|
||||
headers
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
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_format,is_featured,created_at,updated_at,contract_categories(id,name,icon)',
|
||||
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();
|
||||
// 使用PostgREST的or条件进行模糊搜索,正确的格式需要括号
|
||||
params.or = `(title.ilike.*${cleanKeyword}*,description.ilike.*${cleanKeyword}*,template_code.ilike.*${cleanKeyword}*)`;
|
||||
}
|
||||
|
||||
// 如果有分类名称,需要先获取分类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,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_format,is_featured,created_at,updated_at,contract_categories(id,name,icon)',
|
||||
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
|
||||
});
|
||||
}
|
||||
@@ -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,130 +22,137 @@ 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) {
|
||||
// 模拟使用次数和评分(实际项目中可以从其他表获取)
|
||||
const mockUsageCount = Math.floor(Math.random() * 2000) + 100;
|
||||
const mockRating = (Math.random() * 1.5 + 3.5).toFixed(1);
|
||||
|
||||
// 根据模板属性确定类型
|
||||
let templateType = '标准版';
|
||||
if (template.is_featured) {
|
||||
templateType = '推荐版';
|
||||
} else if (template.description && template.description.includes('简化')) {
|
||||
templateType = '简化版';
|
||||
} else if (template.description && (template.description.includes('专业') || template.description.includes('大客户'))) {
|
||||
templateType = '专业版';
|
||||
}
|
||||
];
|
||||
|
||||
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 || '其他'
|
||||
};
|
||||
}
|
||||
|
||||
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 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 {
|
||||
// 构建搜索参数
|
||||
const searchParams: TemplateSearchParams = {
|
||||
page,
|
||||
pageSize,
|
||||
sortBy: 'updated_at',
|
||||
sortOrder: 'desc'
|
||||
};
|
||||
|
||||
return {
|
||||
templates,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
category,
|
||||
type
|
||||
};
|
||||
// 优先使用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,
|
||||
categories: []
|
||||
};
|
||||
}
|
||||
|
||||
// 处理分类数据
|
||||
const categories: ContractCategoryWithCount[] = categoriesResponse.error ? [] : categoriesResponse.data || [];
|
||||
|
||||
// 转换模板数据格式
|
||||
let 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,
|
||||
categories
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('加载模板列表失败:', error);
|
||||
return {
|
||||
templates: [],
|
||||
total: 0,
|
||||
page,
|
||||
pageSize,
|
||||
category,
|
||||
category_id,
|
||||
type,
|
||||
categories: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function ContractTemplateList() {
|
||||
const { templates, total, page, pageSize, category } = useLoaderData<typeof loader>();
|
||||
const { templates, total, page, pageSize, category, category_id, categories } = 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 +163,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'); // 重置页码
|
||||
|
||||
@@ -170,12 +187,14 @@ export default function ContractTemplateList() {
|
||||
};
|
||||
|
||||
// 动态生成筛选选项
|
||||
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 +202,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 +214,33 @@ 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) => setSortBy(e.target.value)}
|
||||
>
|
||||
<option value="newest">最新更新</option>
|
||||
<option value="popular">使用频率</option>
|
||||
<option value="rating">评分最高</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,172 +29,161 @@ 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;
|
||||
}
|
||||
|
||||
// 将数据库模板转换为前端显示格式
|
||||
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 || '其他'
|
||||
};
|
||||
}
|
||||
|
||||
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 page = parseInt(url.searchParams.get('page') || '1');
|
||||
const pageSize = 6;
|
||||
|
||||
// 模拟搜索耗时
|
||||
// 记录搜索开始时间
|
||||
const startTime = Date.now();
|
||||
|
||||
// 这里应该是实际的搜索逻辑
|
||||
// 目前返回模拟数据
|
||||
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;
|
||||
});
|
||||
try {
|
||||
// 并行获取搜索结果和分类数据
|
||||
const [searchResponse, categoriesResponse] = await Promise.all([
|
||||
searchContractTemplates(query, {
|
||||
category,
|
||||
page,
|
||||
pageSize,
|
||||
sortBy: 'updated_at',
|
||||
sortOrder: 'desc'
|
||||
}),
|
||||
getContractCategories()
|
||||
]);
|
||||
|
||||
// 处理搜索结果
|
||||
if (searchResponse.error) {
|
||||
console.error('搜索合同模板失败:', searchResponse.error);
|
||||
return {
|
||||
results: [],
|
||||
query,
|
||||
category,
|
||||
total: 0,
|
||||
page,
|
||||
pageSize,
|
||||
searchTime: '搜索失败',
|
||||
categories: []
|
||||
};
|
||||
}
|
||||
|
||||
// 处理分类数据
|
||||
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,
|
||||
searchTime: searchTimeText,
|
||||
categories: categoriesWithSearchCount
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('加载搜索结果失败:', error);
|
||||
return {
|
||||
results: [],
|
||||
query,
|
||||
category,
|
||||
total: 0,
|
||||
page,
|
||||
pageSize,
|
||||
searchTime: '搜索失败',
|
||||
categories: []
|
||||
};
|
||||
}
|
||||
|
||||
// 计算搜索耗时
|
||||
const endTime = Date.now();
|
||||
const searchTime = (endTime - startTime) / 1000;
|
||||
const searchTimeText = `搜索用时 ${searchTime.toFixed(1)}秒`;
|
||||
|
||||
return {
|
||||
results: filteredResults,
|
||||
query,
|
||||
category,
|
||||
total: filteredResults.length,
|
||||
page,
|
||||
pageSize: 6,
|
||||
searchTime: searchTimeText
|
||||
};
|
||||
}
|
||||
|
||||
export default function ContractTemplateSearchResults() {
|
||||
const { results, query, total, page, pageSize, searchTime } = useLoaderData<typeof loader>();
|
||||
const { results, query, total, page, pageSize, searchTime, categories }: {
|
||||
results: DisplayTemplate[];
|
||||
query: string;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
searchTime: string;
|
||||
categories: CategoryWithSearchCount[];
|
||||
} = useLoaderData<typeof loader>();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const [activeFilter, setActiveFilter] = useState('全部');
|
||||
@@ -211,7 +202,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 +220,13 @@ export default function ContractTemplateSearchResults() {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user