Merge branch 'awen' into shiy
This commit is contained in:
@@ -203,7 +203,6 @@ export async function apiRequest<T>(
|
||||
const config: AxiosRequestConfig = {
|
||||
...options,
|
||||
url,
|
||||
params,
|
||||
headers
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,96 @@
|
||||
### 合同分类
|
||||
|
||||
```
|
||||
CREATE TABLE "public"."contract_categories" (
|
||||
"id" int4 NOT NULL DEFAULT nextval('contract_categories_id_seq'::regclass),
|
||||
"name" varchar(100) COLLATE "pg_catalog"."default" NOT NULL,
|
||||
"icon" varchar(100) COLLATE "pg_catalog"."default",
|
||||
"description" text COLLATE "pg_catalog"."default",
|
||||
"sort_order" int4 DEFAULT 0,
|
||||
"created_at" timestamptz(6) DEFAULT now(),
|
||||
"updated_at" timestamptz(6) DEFAULT now(),
|
||||
CONSTRAINT "contract_categories_pkey" PRIMARY KEY ("id")
|
||||
)
|
||||
;
|
||||
|
||||
ALTER TABLE "public"."contract_categories"
|
||||
OWNER TO "root";
|
||||
|
||||
CREATE TRIGGER "update_contract_categories_updated_at" BEFORE UPDATE ON "public"."contract_categories"
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE "public"."update_updated_at_column"();
|
||||
|
||||
COMMENT ON COLUMN "public"."contract_categories"."id" IS '分类ID(主键)';
|
||||
|
||||
COMMENT ON COLUMN "public"."contract_categories"."name" IS '分类名称';
|
||||
|
||||
COMMENT ON COLUMN "public"."contract_categories"."icon" IS '图标类名(如 font-awesome 类名)';
|
||||
|
||||
COMMENT ON COLUMN "public"."contract_categories"."description" IS '分类描述';
|
||||
|
||||
COMMENT ON COLUMN "public"."contract_categories"."sort_order" IS '排序顺序';
|
||||
|
||||
COMMENT ON COLUMN "public"."contract_categories"."created_at" IS '创建时间(带时区)';
|
||||
|
||||
COMMENT ON COLUMN "public"."contract_categories"."updated_at" IS '最后更新时间(带时区)';
|
||||
|
||||
COMMENT ON TABLE "public"."contract_categories" IS '合同分类表';
|
||||
```
|
||||
|
||||
|
||||
### 合同模板表
|
||||
```
|
||||
CREATE TABLE "public"."contract_templates" (
|
||||
"id" int8 NOT NULL DEFAULT nextval('contract_templates_id_seq'::regclass),
|
||||
"template_code" varchar(50) COLLATE "pg_catalog"."default" NOT NULL,
|
||||
"title" varchar(200) COLLATE "pg_catalog"."default" NOT NULL,
|
||||
"category_id" int4 NOT NULL,
|
||||
"description" text COLLATE "pg_catalog"."default",
|
||||
"file_path" varchar(500) COLLATE "pg_catalog"."default",
|
||||
"file_format" varchar(10) COLLATE "pg_catalog"."default" DEFAULT 'docx'::character varying,
|
||||
"is_featured" bool DEFAULT false,
|
||||
"created_at" timestamptz(6) DEFAULT now(),
|
||||
"updated_at" timestamptz(6) DEFAULT now(),
|
||||
"pdf_file_path" varchar(500) COLLATE "pg_catalog"."default",
|
||||
CONSTRAINT "contract_templates_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "fk_category_id" FOREIGN KEY ("category_id") REFERENCES "public"."contract_categories" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION,
|
||||
CONSTRAINT "contract_templates_template_code_key" UNIQUE ("template_code"),
|
||||
CONSTRAINT "valid_file_format" CHECK (file_format::text = ANY (ARRAY['docx'::character varying, 'pdf'::character varying, 'txt'::character varying]::text[]))
|
||||
)
|
||||
;
|
||||
|
||||
ALTER TABLE "public"."contract_templates"
|
||||
OWNER TO "root";
|
||||
|
||||
CREATE INDEX "idx_contract_templates_category_id" ON "public"."contract_templates" USING btree (
|
||||
"category_id" "pg_catalog"."int4_ops" ASC NULLS LAST
|
||||
);
|
||||
|
||||
CREATE TRIGGER "update_contract_templates_updated_at" BEFORE UPDATE ON "public"."contract_templates"
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE "public"."update_updated_at_column"();
|
||||
|
||||
COMMENT ON COLUMN "public"."contract_templates"."id" IS '模板ID(主键)';
|
||||
|
||||
COMMENT ON COLUMN "public"."contract_templates"."template_code" IS '模板编号(唯一标识)';
|
||||
|
||||
COMMENT ON COLUMN "public"."contract_templates"."title" IS '模板标题';
|
||||
|
||||
COMMENT ON COLUMN "public"."contract_templates"."category_id" IS '所属分类ID(外键)';
|
||||
|
||||
COMMENT ON COLUMN "public"."contract_templates"."description" IS '模板描述';
|
||||
|
||||
COMMENT ON COLUMN "public"."contract_templates"."file_path" IS '文件存储路径';
|
||||
|
||||
COMMENT ON COLUMN "public"."contract_templates"."file_format" IS '文件格式(docx/pdf/txt)';
|
||||
|
||||
COMMENT ON COLUMN "public"."contract_templates"."is_featured" IS '是否推荐模板';
|
||||
|
||||
COMMENT ON COLUMN "public"."contract_templates"."created_at" IS '创建时间(带时区)';
|
||||
|
||||
COMMENT ON COLUMN "public"."contract_templates"."updated_at" IS '最后更新时间(带时区)';
|
||||
|
||||
COMMENT ON COLUMN "public"."contract_templates"."pdf_file_path" IS 'pdf文件存储路径';
|
||||
|
||||
COMMENT ON TABLE "public"."contract_templates" IS '合同模板表';
|
||||
```
|
||||
@@ -0,0 +1,36 @@
|
||||
## 鍐呯綉閮ㄧ讲妫€鏌ユ竻鍗?
|
||||
|
||||
### 鉁?宸插畬鎴愮殑鏈湴鍖?
|
||||
1. **RemixIcon瀛椾綋鏂囦欢** - 宸插鍒跺埌 public/fonts/ 鐩綍
|
||||
- remixicon.woff2 (173KB) - 鐜颁唬娴忚鍣ㄤ紭鍏?
|
||||
- remixicon.woff (237KB) - 鍏煎鎬у閫?
|
||||
- remixicon.ttf (557KB) - 绯荤粺瀛椾綋澶囬€?
|
||||
- remixicon.eot (557KB) - IE娴忚鍣ㄦ敮鎸?
|
||||
- remixicon.svg (2.6MB) - 鏃х増娴忚鍣ㄥ閫?
|
||||
|
||||
2. **CSS鏂囦欢鏈湴鍖?* - pp/styles/remixicon-local.css
|
||||
- 浣跨敤鏈湴瀛椾綋璺緞 /fonts/remixicon.*
|
||||
- 鍖呭惈瀹屾暣鍥炬爣鏄犲皠
|
||||
- 鍚敤 ont-display: swap 浼樺寲鍔犺浇
|
||||
|
||||
3. **瀛椾綋棰勫姞杞戒紭鍖?* - pp/root.tsx
|
||||
- 棰勫姞杞絯off2鍜寃off鏍煎紡
|
||||
- 浣跨敤crossOrigin="anonymous"
|
||||
- 浼樺厛鍔犺浇鏈€閲嶈鐨勫瓧浣撴牸寮?
|
||||
|
||||
### 馃敡 鎶€鏈疄鐜?
|
||||
- **瑙e喅棣栨鍔犺浇闂**: 閫氳繃瀛椾綋棰勫姞杞界‘淇濆浘鏍囩珛鍗虫樉绀?
|
||||
- **鍐呯綉鍏煎鎬?*: 鎵€鏈夎祫婧愰兘鏉ヨ嚜鏈湴锛屾棤澶栭儴渚濊禆
|
||||
- **鎬ц兘浼樺寲**: 浣跨敤鏈€鏂扮殑瀛椾綋鍔犺浇绛栫暐
|
||||
- **娴忚鍣ㄥ吋瀹?*: 鏀寔IE9+鍒版渶鏂版祻瑙堝櫒
|
||||
|
||||
### 馃搵 閮ㄧ讲娉ㄦ剰浜嬮」
|
||||
1. 纭繚 public/fonts/ 鐩綍鍦ㄧ敓浜х幆澧冧腑鍙闂?
|
||||
2. 妫€鏌ユ湇鍔″櫒MIME绫诲瀷閰嶇疆鏀寔瀛椾綋鏂囦欢
|
||||
3. 寤鸿鍚敤gzip鍘嬬缉浠ュ噺灏戝瓧浣撴枃浠朵紶杈撳ぇ灏?
|
||||
4. 鍙垹闄や笉闇€瑕佺殑瀛椾綋鏍煎紡浠ュ噺灏忛儴缃插寘澶у皬
|
||||
|
||||
### 馃殌 鎬ц兘寤鸿
|
||||
- 淇濈暀 woff2 鍜?woff 鏍煎紡鍗冲彲瑕嗙洊99%+鐨勬祻瑙堝櫒
|
||||
- 濡傞渶鏀寔IE8-锛屼繚鐣?eot 鏍煎紡
|
||||
- svg鏍煎紡浠呯敤浜庢瀬鏃х殑绉诲姩娴忚鍣紝鍙€夋嫨鎬т繚鐣?
|
||||
@@ -0,0 +1,494 @@
|
||||
# 中国烟草AI合同及卷宗审核系统 - 开发规范指南
|
||||
|
||||
## 📋 概述
|
||||
|
||||
本文档定义了「中国烟草AI合同及卷宗审核系统」的开发规范,包括技术架构、设计风格、代码规范、UI组件等各个方面,确保团队开发的一致性和代码质量。
|
||||
|
||||
## 🏗 技术架构规范
|
||||
|
||||
### 核心技术栈
|
||||
- **前端框架**: Remix (React) + TypeScript
|
||||
- **构建工具**: Vite
|
||||
- **样式系统**: Tailwind CSS + 自定义CSS
|
||||
- **图标库**: Remixicon (已本地化)
|
||||
- **文档处理**: react-pdf, mammoth, docx-preview
|
||||
- **代码规范**: ESLint + TypeScript
|
||||
- **状态管理**: React Context + useState/useEffect
|
||||
|
||||
### 项目结构规范
|
||||
```
|
||||
app/
|
||||
├── api/ # API层接口
|
||||
├── components/ # 组件
|
||||
│ ├── ui/ # 通用UI组件
|
||||
│ ├── layout/ # 布局组件
|
||||
│ ├── error/ # 错误处理组件
|
||||
│ └── [feature]/ # 功能特定组件
|
||||
├── routes/ # Remix路由页面
|
||||
├── styles/ # 样式文件
|
||||
│ ├── main.css # 主样式
|
||||
│ └── components/ # 组件样式
|
||||
├── types/ # TypeScript类型定义
|
||||
├── contexts/ # React Context
|
||||
├── models/ # 数据模型
|
||||
└── utils.ts # 工具函数
|
||||
```
|
||||
|
||||
## 🎨 设计系统规范
|
||||
|
||||
### 颜色主题
|
||||
```css
|
||||
/* 主色调 - 中国烟草企业绿 */
|
||||
--color-primary: #00684a; /* 主色 */
|
||||
--color-primary-hover: #005a3f; /* 悬停色 */
|
||||
--color-primary-light: rgba(0, 104, 74, 0.1); /* 浅色背景 */
|
||||
|
||||
/* 状态颜色 */
|
||||
--color-success: #52c41a; /* 成功绿 */
|
||||
--color-warning: #faad14; /* 警告橙 */
|
||||
--color-error: #f5222d; /* 错误红 */
|
||||
|
||||
/* 中性色系 */
|
||||
--color-gray-50: #f8f9fa; /* 最浅灰 */
|
||||
--color-gray-100: #f1f3f5;
|
||||
--color-gray-200: #e9ecef;
|
||||
--color-gray-300: #dee2e6;
|
||||
--color-gray-400: #ced4da;
|
||||
--color-gray-500: #adb5bd; /* 中性灰 */
|
||||
--color-gray-600: #868e96;
|
||||
--color-gray-700: #495057;
|
||||
--color-gray-800: #343a40;
|
||||
--color-gray-900: #212529; /* 最深灰 */
|
||||
```
|
||||
|
||||
### 字体规范
|
||||
```css
|
||||
font-family: [
|
||||
"-apple-system",
|
||||
"BlinkMacSystemFont",
|
||||
"Segoe UI",
|
||||
"Roboto",
|
||||
"Helvetica Neue",
|
||||
"Arial",
|
||||
"Noto Sans SC", /* 中文优先 */
|
||||
"PingFang SC",
|
||||
"Microsoft YaHei UI",
|
||||
"Microsoft YaHei",
|
||||
"sans-serif"
|
||||
];
|
||||
```
|
||||
|
||||
### 间距系统
|
||||
- 使用Tailwind默认间距: 4px基数 (1, 2, 3, 4, 5, 6...)
|
||||
- 页面容器内边距: `p-5` (20px)
|
||||
- 卡片内边距: `p-4` (16px)
|
||||
- 组件间距: `mb-4` `mt-6` (16px, 24px)
|
||||
|
||||
### 圆角规范
|
||||
- 按钮、卡片: `rounded-md` (6px)
|
||||
- 输入框: `rounded-md` (6px)
|
||||
- 头像: `rounded-full`
|
||||
- 标签: `rounded-md`
|
||||
|
||||
### 阴影系统
|
||||
```css
|
||||
/* 卡片阴影 */
|
||||
.card: shadow-sm; /* 默认 */
|
||||
.card:hover: shadow-md; /* 悬停 */
|
||||
.sidebar: shadow-[0_0_15px_rgba(0,0,0,0.05)]; /* 侧边栏 */
|
||||
```
|
||||
|
||||
## 🧩 组件设计规范
|
||||
|
||||
### 按钮组件
|
||||
```tsx
|
||||
// 类型定义
|
||||
type ButtonType = 'primary' | 'default' | 'danger';
|
||||
type ButtonSize = 'small' | 'medium' | 'large';
|
||||
|
||||
// 样式类
|
||||
.ant-btn-primary: bg-[#00684a] text-white hover:bg-[#005a3f]
|
||||
.ant-btn-default: bg-white border border-gray-300 text-gray-800
|
||||
.ant-btn-danger: bg-[#f5222d] text-white hover:bg-[#cf1f29]
|
||||
```
|
||||
|
||||
### 卡片组件
|
||||
```tsx
|
||||
// 基础结构
|
||||
<Card title="标题" icon="ri-icon-name" className="additional-classes">
|
||||
<div className="card-body">内容</div>
|
||||
</Card>
|
||||
|
||||
// 样式规范
|
||||
.card: bg-white rounded-lg shadow overflow-hidden
|
||||
.card-header: px-5 py-4 border-b border-gray-100
|
||||
.card-title: text-base font-medium text-gray-900
|
||||
```
|
||||
|
||||
### 布局组件
|
||||
```tsx
|
||||
// 侧边栏宽度
|
||||
.sidebar: w-[280px] /* 展开状态 */
|
||||
.sidebar.collapsed: w-20 /* 收缩状态 */
|
||||
|
||||
// 主内容区适配
|
||||
.main-content: ml-[280px] /* 对应侧边栏宽度 */
|
||||
.main-content.sidebar-collapsed: ml-20
|
||||
```
|
||||
|
||||
## 📝 代码规范
|
||||
|
||||
### TypeScript规范
|
||||
```typescript
|
||||
// 接口命名使用PascalCase
|
||||
interface DocumentUI {
|
||||
id: string;
|
||||
name: string;
|
||||
status: ProcessingStatus;
|
||||
}
|
||||
|
||||
// 类型联合使用字符串字面量
|
||||
type ProcessingStatus = 'Waiting' | 'Cutting' | 'Extractioning' | 'Evaluationing' | 'Processed';
|
||||
|
||||
// 组件Props接口
|
||||
interface ComponentProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### React组件规范
|
||||
```tsx
|
||||
// 函数组件使用function声明
|
||||
export function ComponentName({ prop1, prop2 }: ComponentProps) {
|
||||
// useState放在顶部
|
||||
const [state, setState] = useState<Type>(initialValue);
|
||||
|
||||
// useEffect按逻辑分组
|
||||
useEffect(() => {
|
||||
// 副作用逻辑
|
||||
}, [dependencies]);
|
||||
|
||||
// 事件处理函数
|
||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
// 处理逻辑
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="component-container">
|
||||
{/* JSX内容 */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 文件命名规范
|
||||
- 组件文件: `PascalCase.tsx` (如: `Button.tsx`)
|
||||
- 路由文件: `kebab-case.tsx` (如: `user-profile.tsx`)
|
||||
- 样式文件: `kebab-case.css` (如: `button.css`)
|
||||
- 工具文件: `camelCase.ts` (如: `utils.ts`)
|
||||
|
||||
### 导入顺序规范
|
||||
```typescript
|
||||
// 1. React相关
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, useLoaderData } from '@remix-run/react';
|
||||
|
||||
// 2. 第三方库
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
// 3. 内部组件 (~/ 别名)
|
||||
import { Card } from '~/components/ui/Card';
|
||||
import { Button } from '~/components/ui/Button';
|
||||
|
||||
// 4. 类型定义
|
||||
import type { DocumentUI } from '~/types/document';
|
||||
|
||||
// 5. 样式文件
|
||||
import styles from '~/styles/components/component.css?url';
|
||||
```
|
||||
|
||||
### CSS类命名规范
|
||||
```css
|
||||
/* BEM方法论 + 功能前缀 */
|
||||
.sidebar-menu-item /* 组件-子元素-状态 */
|
||||
.sidebar-menu-item.active /* 状态修饰符 */
|
||||
.ant-btn-primary /* 组件库前缀 */
|
||||
.text-primary /* 工具类 */
|
||||
```
|
||||
|
||||
## 🎯 UI/UX设计规范
|
||||
|
||||
### 交互动效
|
||||
```css
|
||||
/* 统一过渡动效 */
|
||||
.transition-all-ease: transition-all duration-200 ease-in-out;
|
||||
|
||||
/* 悬停效果 */
|
||||
.sidebar-menu-item:hover: bg-[rgba(0,104,74,0.05)];
|
||||
.card:hover: shadow-md;
|
||||
```
|
||||
|
||||
### 状态指示
|
||||
```typescript
|
||||
// 文件处理状态
|
||||
const statusConfig = {
|
||||
"Waiting": { label: "上传中", icon: "ri-loader-line", color: "blue" },
|
||||
"Cutting": { label: "切分中", icon: "ri-loader-line", color: "purple" },
|
||||
"Extractioning": { label: "抽取中", icon: "ri-loader-line", color: "cyan" },
|
||||
"Evaluationing": { label: "评查中", icon: "ri-loader-line", color: "teal" },
|
||||
"Processed": { label: "已完成", icon: "ri-check-line", color: "green" }
|
||||
};
|
||||
```
|
||||
|
||||
### 图标使用规范 (RemixIcon 本地化)
|
||||
```tsx
|
||||
// 基本图标使用
|
||||
<i className="ri-home-line"></i>
|
||||
<i className="ri-file-list-3-line"></i>
|
||||
<i className="ri-user-line"></i>
|
||||
|
||||
// 图标尺寸控制
|
||||
<i className="ri-home-line ri-lg"></i> // 大图标
|
||||
<i className="ri-home-line ri-xl"></i> // 特大图标
|
||||
<i className="ri-home-line ri-2x"></i> // 2倍大小
|
||||
|
||||
// 结合样式使用
|
||||
<i className="ri-error-warning-line text-red-500"></i>
|
||||
<i className="ri-check-line text-green-600"></i>
|
||||
<i className="ri-information-line text-blue-500"></i>
|
||||
|
||||
// 在按钮组件中使用
|
||||
<Button icon="ri-add-line">添加</Button>
|
||||
<Button icon="ri-edit-line">编辑</Button>
|
||||
<Button icon="ri-delete-bin-line">删除</Button>
|
||||
```
|
||||
|
||||
**图标系统特点:**
|
||||
- **本地化部署**: 字体文件已复制到 `public/fonts/` 目录,无外网依赖
|
||||
- **预加载优化**: 通过 `<link rel="preload">` 确保首次访问图标立即显示
|
||||
- **完整支持**: 包含3000+图标,支持所有RemixIcon官方图标
|
||||
- **性能优化**: 启用 `font-display: swap` 提供更好的加载体验
|
||||
- **浏览器兼容**: 支持IE9+到最新浏览器
|
||||
|
||||
**⚠️ 重要注意事项 - 避免样式冲突:**
|
||||
|
||||
在使用CSS样式隔离(如 `all: unset` 或强制 `font-family: inherit`)时,必须为RemixIcon图标添加例外规则:
|
||||
|
||||
```css
|
||||
/* 错误示例 - 会导致图标不显示 */
|
||||
.my-isolated-container * {
|
||||
font-family: inherit !important; /* 这会覆盖图标字体 */
|
||||
}
|
||||
|
||||
/* 正确示例 - 添加图标例外规则 */
|
||||
.my-isolated-container * {
|
||||
font-family: inherit !important;
|
||||
}
|
||||
.my-isolated-container [class^="ri-"],
|
||||
.my-isolated-container [class*=" ri-"],
|
||||
.my-isolated-container i[class^="ri-"],
|
||||
.my-isolated-container 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;
|
||||
}
|
||||
```
|
||||
|
||||
**常见问题排查:**
|
||||
1. **图标显示为方块或问号**: 检查是否有CSS规则覆盖了 `font-family: 'remixicon'`
|
||||
2. **只在特定页面不显示**: 检查该页面是否使用了样式隔离规则
|
||||
3. **首次加载不显示**: 确认字体预加载配置正确
|
||||
4. **部分图标不显示**: 检查CSS选择器优先级和 `!important` 使用
|
||||
|
||||
### 国际化和本地化
|
||||
- 界面语言: 简体中文为主
|
||||
- 日期格式: `YYYY年MM月DD日`
|
||||
- 时间格式: `HH:mm:ss`
|
||||
- 数字格式: 使用中文习惯 (如: 万、千)
|
||||
|
||||
## 🔧 工具配置规范
|
||||
|
||||
### ESLint配置要点
|
||||
```javascript
|
||||
// 启用的规则
|
||||
- "plugin:react/recommended"
|
||||
- "plugin:react-hooks/recommended"
|
||||
- "plugin:@typescript-eslint/recommended"
|
||||
- "plugin:jsx-a11y/recommended"
|
||||
```
|
||||
|
||||
### Tailwind配置扩展
|
||||
```typescript
|
||||
// tailwind.config.ts
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
DEFAULT: '#1677ff', // 保持Remix默认,实际使用CSS变量覆盖
|
||||
// ... 其他色阶
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 📊 性能优化规范
|
||||
|
||||
### 组件优化
|
||||
```typescript
|
||||
// 使用React.memo对纯组件优化
|
||||
export const MemoizedComponent = React.memo(Component);
|
||||
|
||||
// 防抖工具函数使用
|
||||
import { debounce } from '~/utils';
|
||||
const debouncedHandler = debounce(handler, 300);
|
||||
```
|
||||
|
||||
### 资源加载
|
||||
```typescript
|
||||
// 样式文件异步加载
|
||||
import styles from '~/styles/component.css?url';
|
||||
|
||||
// 组件懒加载
|
||||
const LazyComponent = lazy(() => import('~/components/LazyComponent'));
|
||||
```
|
||||
|
||||
## 🚨 错误处理规范
|
||||
|
||||
### 错误边界
|
||||
```tsx
|
||||
// 统一错误处理组件
|
||||
<AppErrorBoundary
|
||||
status={500}
|
||||
statusText="服务器错误"
|
||||
message="服务器发生了意外错误,请稍后重试"
|
||||
/>
|
||||
```
|
||||
|
||||
### API错误处理
|
||||
```typescript
|
||||
// 统一的API响应处理
|
||||
if (response.error) {
|
||||
console.error('API错误:', response.error);
|
||||
return Response.json(
|
||||
{ error: response.error },
|
||||
{ status: response.status || 500 }
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 📱 响应式设计规范
|
||||
|
||||
### 断点使用
|
||||
- `sm`: 640px+ (移动端横屏)
|
||||
- `md`: 768px+ (平板)
|
||||
- `lg`: 1024px+ (桌面)
|
||||
- `xl`: 1280px+ (大屏)
|
||||
|
||||
### 适配策略
|
||||
```css
|
||||
/* 移动端优先 */
|
||||
.content-container: p-5;
|
||||
@screen sm: .content-container: p-6;
|
||||
|
||||
/* 侧边栏响应式 */
|
||||
@screen md: .sidebar-toggle: block;
|
||||
```
|
||||
|
||||
## 🔍 可访问性规范
|
||||
|
||||
### 语义化HTML
|
||||
```tsx
|
||||
// 使用语义化标签
|
||||
<main className="main-content">
|
||||
<nav className="breadcrumb" aria-label="页面导航">
|
||||
<section className="content-section">
|
||||
```
|
||||
|
||||
### 键盘导航
|
||||
```tsx
|
||||
// 确保所有交互元素可键盘访问
|
||||
<button
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleClick()}
|
||||
aria-label="操作按钮"
|
||||
>
|
||||
```
|
||||
|
||||
## 📋 开发流程规范
|
||||
|
||||
### 组件开发流程
|
||||
1. 创建组件TypeScript接口定义
|
||||
2. 实现组件逻辑 (`.tsx`)
|
||||
3. 编写组件样式 (`.css`)
|
||||
4. 在`main.css`中导入样式
|
||||
5. 编写使用示例
|
||||
6. 进行测试验证
|
||||
|
||||
### 页面开发流程
|
||||
1. 在`routes/`下创建路由文件
|
||||
2. 定义`loader`函数处理数据获取
|
||||
3. 实现页面组件
|
||||
4. 配置`meta`和`links`
|
||||
5. 添加错误处理
|
||||
6. 测试页面功能
|
||||
|
||||
## 🚀 部署和构建规范
|
||||
|
||||
### 环境变量
|
||||
```typescript
|
||||
// 生产环境配置
|
||||
NODE_ENV=production
|
||||
DATABASE_URL=...
|
||||
SESSION_SECRET=...
|
||||
```
|
||||
|
||||
### 构建优化
|
||||
```typescript
|
||||
// vite.config.ts优化配置
|
||||
export default defineConfig({
|
||||
plugins: [remix(), tsconfigPaths()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
// 代码分割优化
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 开发检查清单
|
||||
|
||||
### 新组件开发
|
||||
- [ ] TypeScript接口定义完整
|
||||
- [ ] 样式使用设计系统颜色
|
||||
- [ ] 支持必要的props (className, disabled等)
|
||||
- [ ] 添加适当的过渡动效
|
||||
- [ ] 编写组件文档注释
|
||||
|
||||
### 新页面开发
|
||||
- [ ] loader函数处理数据和错误
|
||||
- [ ] meta信息配置完整
|
||||
- [ ] 响应式设计适配
|
||||
- [ ] 错误边界处理
|
||||
- [ ] 面包屑导航配置
|
||||
|
||||
### 代码提交前
|
||||
- [ ] ESLint检查通过
|
||||
- [ ] TypeScript编译无错误
|
||||
- [ ] 样式符合设计系统
|
||||
- [ ] 组件功能测试完成
|
||||
- [ ] 代码注释清晰完整
|
||||
|
||||
---
|
||||
|
||||
*此规范将根据项目发展持续更新和完善。*
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
# 中国烟草AI合同及卷宗审核系统 - 图标优化解决方案
|
||||
|
||||
## 🎯 问题描述
|
||||
|
||||
### 原始问题
|
||||
1. **首次访问图标不显示**: 由于RemixIcon字体文件加载时机问题,首次访问页面时图标可能不显示,需要刷新页面才能正常显示
|
||||
2. **内网部署资源依赖**: 系统将部署到无法访问外网的内网环境,需要将所有外部资源本地化
|
||||
|
||||
### 技术原因分析
|
||||
- **字体加载延迟**: 图标字体文件较大(173KB-2.6MB),首次加载时可能在页面渲染后才完成
|
||||
- **缓存机制**: 浏览器第二次访问时字体文件已被缓存,因此图标能正常显示
|
||||
- **外部依赖**: 原本通过`import "remixicon/fonts/remixicon.css"`从npm包导入
|
||||
|
||||
## ✅ 解决方案实施
|
||||
|
||||
### 1. 字体文件本地化
|
||||
```bash
|
||||
# 复制字体文件到public目录
|
||||
cp node_modules/remixicon/fonts/remixicon.* public/fonts/
|
||||
```
|
||||
|
||||
**复制的文件清单:**
|
||||
- `remixicon.woff2` (173KB) - 现代浏览器优先格式
|
||||
- `remixicon.woff` (237KB) - 广泛兼容格式
|
||||
- `remixicon.ttf` (557KB) - 系统字体备选
|
||||
- `remixicon.eot` (557KB) - IE浏览器支持
|
||||
- `remixicon.svg` (2.6MB) - 旧版浏览器备选
|
||||
|
||||
### 2. CSS文件本地化
|
||||
创建 `app/styles/remixicon-local.css`,包含:
|
||||
|
||||
```css
|
||||
@font-face {
|
||||
font-family: "remixicon";
|
||||
src: url('/fonts/remixicon.eot'); /* IE9*/
|
||||
src: url('/fonts/remixicon.eot#iefix') format('embedded-opentype'),
|
||||
url("/fonts/remixicon.woff2") format("woff2"),
|
||||
url("/fonts/remixicon.woff") format("woff"),
|
||||
url('/fonts/remixicon.ttf') format('truetype'),
|
||||
url('/fonts/remixicon.svg#remixicon') format('svg');
|
||||
font-display: swap; /* 优化字体加载显示策略 */
|
||||
}
|
||||
```
|
||||
|
||||
**关键优化:**
|
||||
- 使用本地路径 `/fonts/` 替代相对路径
|
||||
- 启用 `font-display: swap` 提供更好的加载体验
|
||||
- 包含完整的3000+图标定义
|
||||
|
||||
### 3. 字体预加载优化
|
||||
在 `app/root.tsx` 的 `links` 函数中添加:
|
||||
|
||||
```typescript
|
||||
export function links() {
|
||||
return [
|
||||
{ rel: "stylesheet", href: styles },
|
||||
{ rel: "stylesheet", href: remixiconStyles }, // 本地化CSS
|
||||
// 预加载关键字体文件,解决首次加载问题
|
||||
{ rel: "preload", href: "/fonts/remixicon.woff2", as: "font", type: "font/woff2", crossOrigin: "anonymous" },
|
||||
{ rel: "preload", href: "/fonts/remixicon.woff", as: "font", type: "font/woff", crossOrigin: "anonymous" },
|
||||
// ... 其他资源
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
**预加载优势:**
|
||||
- 确保字体文件在页面渲染前开始下载
|
||||
- 优先加载最重要的格式(woff2, woff)
|
||||
- 使用 `crossOrigin="anonymous"` 确保跨域兼容性
|
||||
|
||||
### 4. 移除外部依赖
|
||||
修改 `app/root.tsx`:
|
||||
```typescript
|
||||
// 注释掉原始导入
|
||||
// import "remixicon/fonts/remixicon.css";
|
||||
|
||||
// 使用本地化版本
|
||||
import remixiconStyles from "~/styles/remixicon-local.css?url";
|
||||
```
|
||||
|
||||
## 📊 性能对比
|
||||
|
||||
### 优化前
|
||||
- ❌ 首次加载图标不显示
|
||||
- ❌ 依赖外部npm包资源
|
||||
- ❌ 字体文件加载时机随机
|
||||
- ❌ 无法在内网环境部署
|
||||
|
||||
### 优化后
|
||||
- ✅ 首次加载图标立即显示
|
||||
- ✅ 完全本地化,无外部依赖
|
||||
- ✅ 字体文件预加载,确保及时可用
|
||||
- ✅ 支持内网部署
|
||||
- ✅ 更好的字体加载策略(font-display: swap)
|
||||
|
||||
## 🔧 技术实现细节
|
||||
|
||||
### 字体加载优化策略
|
||||
1. **格式优先级**: woff2 > woff > ttf > eot > svg
|
||||
2. **预加载顺序**: 优先加载woff2和woff格式,覆盖99%+现代浏览器
|
||||
3. **fallback机制**: 提供完整的格式支持链,确保兼容性
|
||||
|
||||
### 浏览器兼容性
|
||||
- **Chrome/Firefox/Safari**: woff2 格式(最优)
|
||||
- **IE 11+**: woff 格式
|
||||
- **IE 9-10**: eot 格式
|
||||
- **旧版移动浏览器**: svg 格式备选
|
||||
|
||||
### 内网部署优化
|
||||
- 所有资源路径使用绝对路径 `/fonts/`
|
||||
- 无CDN或外部API依赖
|
||||
- 可通过nginx/Apache等静态服务器直接托管
|
||||
|
||||
## 📋 部署检查清单
|
||||
|
||||
### 服务器配置
|
||||
- [ ] 确保 `public/fonts/` 目录在生产环境中可访问
|
||||
- [ ] 配置正确的MIME类型支持字体文件:
|
||||
```nginx
|
||||
location ~* \.(woff2|woff|ttf|eot|svg)$ {
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
```
|
||||
|
||||
### 性能优化建议
|
||||
- [ ] 启用gzip压缩减少传输大小(可减少60-80%)
|
||||
- [ ] 设置合适的缓存策略(建议1年)
|
||||
- [ ] 如需减小部署包,可删除不需要的字体格式
|
||||
|
||||
### 可选优化
|
||||
```bash
|
||||
# 仅保留现代浏览器格式(可减少80%+文件大小)
|
||||
rm public/fonts/remixicon.ttf
|
||||
rm public/fonts/remixicon.eot
|
||||
rm public/fonts/remixicon.svg
|
||||
```
|
||||
|
||||
## 🚀 效果验证
|
||||
|
||||
使用提供的检查脚本验证优化效果:
|
||||
```bash
|
||||
powershell -ExecutionPolicy Bypass -File "scripts/check-local-resources.ps1"
|
||||
```
|
||||
|
||||
**预期结果:**
|
||||
- ✅ 所有字体文件本地化完成
|
||||
- ✅ 无外部依赖检测
|
||||
- ✅ 构建成功
|
||||
- ✅ 系统准备好内网部署
|
||||
|
||||
## 📝 维护说明
|
||||
|
||||
### 版本更新
|
||||
当需要更新RemixIcon版本时:
|
||||
1. 更新 `package.json` 中的 remixicon 版本
|
||||
2. 重新复制字体文件到 `public/fonts/`
|
||||
3. 更新 `app/styles/remixicon-local.css` 中的图标定义
|
||||
|
||||
### 监控建议
|
||||
- 定期检查字体文件加载性能
|
||||
- 监控首屏渲染时间
|
||||
- 验证图标在不同浏览器中的显示效果
|
||||
|
||||
---
|
||||
|
||||
**总结**: 通过字体本地化、预加载优化和加载策略改进,成功解决了图标首次加载问题,并确保系统可在内网环境稳定部署。
|
||||
@@ -0,0 +1,789 @@
|
||||
## 🗄️ PostgreSQL数据对接规范
|
||||
|
||||
### 概述
|
||||
|
||||
本系统使用PostgreSQL作为主数据库,通过PostgREST提供RESTful API接口。所有数据操作统一通过封装的`postgrest-client.ts`模块进行,确保数据访问的一致性和可维护性。
|
||||
|
||||
### 技术架构
|
||||
|
||||
```
|
||||
前端组件 (Remix Routes)
|
||||
↓
|
||||
API层 (app/api/[module]/[api].ts)
|
||||
↓
|
||||
PostgREST客户端 (postgrest-client.ts)
|
||||
↓
|
||||
PostgreSQL数据库
|
||||
```
|
||||
|
||||
### 基础封装方法
|
||||
|
||||
#### 1. 导入PostgREST客户端
|
||||
|
||||
```typescript
|
||||
import {
|
||||
postgrestGet,
|
||||
postgrestPost,
|
||||
postgrestPut,
|
||||
postgrestDelete,
|
||||
type PostgrestParams
|
||||
} from "../postgrest-client";
|
||||
```
|
||||
|
||||
#### 2. 查询操作 (GET)
|
||||
|
||||
```typescript
|
||||
// 基础查询
|
||||
const params: PostgrestParams = {
|
||||
select: '*',
|
||||
filter: {
|
||||
'id': `eq.${id}`,
|
||||
'status': `eq.active`
|
||||
},
|
||||
order: 'created_at.desc',
|
||||
limit: 20,
|
||||
offset: 0
|
||||
};
|
||||
|
||||
const response = await postgrestGet('table_name', params);
|
||||
|
||||
// 复杂查询示例
|
||||
const complexParams: PostgrestParams = {
|
||||
select: 'id,name,status,created_at,user_id,users(name)', // 关联查询
|
||||
filter: {
|
||||
'created_at': `gte.2023-01-01`,
|
||||
'status': `in.(active,pending)`
|
||||
},
|
||||
or: 'name.ilike.*keyword*,description.ilike.*keyword*', // OR条件
|
||||
order: 'created_at.desc,name.asc',
|
||||
limit: 50
|
||||
};
|
||||
|
||||
const response = await postgrestGet('documents', complexParams);
|
||||
```
|
||||
|
||||
#### 3. 创建操作 (POST)
|
||||
|
||||
```typescript
|
||||
// 单条记录创建
|
||||
const newRecord = {
|
||||
name: '文档名称',
|
||||
document_number: 'DOC001',
|
||||
type_id: 1,
|
||||
user_id: 123,
|
||||
status: 'pending'
|
||||
};
|
||||
|
||||
const response = await postgrestPost('documents', newRecord);
|
||||
|
||||
// 批量创建
|
||||
const batchRecords = [
|
||||
{ name: '文档1', type_id: 1 },
|
||||
{ name: '文档2', type_id: 2 }
|
||||
];
|
||||
|
||||
const response = await postgrestPost('documents', batchRecords);
|
||||
```
|
||||
|
||||
#### 4. 更新操作 (PUT/PATCH)
|
||||
|
||||
```typescript
|
||||
// 根据ID更新
|
||||
const updateData = {
|
||||
status: 'completed',
|
||||
audit_status: 1,
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
const response = await postgrestPut(
|
||||
'documents',
|
||||
updateData,
|
||||
{ id: documentId }
|
||||
);
|
||||
|
||||
// 根据条件批量更新
|
||||
const response = await postgrestPut(
|
||||
'evaluation_results',
|
||||
{ status: 'reviewed' },
|
||||
{ document_id: docId, status: 'pending' }
|
||||
);
|
||||
```
|
||||
|
||||
#### 5. 删除操作 (DELETE)
|
||||
|
||||
```typescript
|
||||
// 根据ID删除
|
||||
const response = await postgrestDelete('documents', {
|
||||
filter: { 'id': `eq.${id}` }
|
||||
});
|
||||
|
||||
// 条件删除
|
||||
const response = await postgrestDelete('temp_files', {
|
||||
filter: { 'created_at': `lt.2023-01-01` }
|
||||
});
|
||||
```
|
||||
|
||||
### API层实现规范
|
||||
|
||||
#### 1. 文件结构
|
||||
```
|
||||
app/api/
|
||||
├── [module]/ # 功能模块
|
||||
│ ├── [feature].ts # 具体功能API
|
||||
│ └── types.ts # 类型定义 (可选)
|
||||
├── postgrest-client.ts # PostgREST客户端
|
||||
├── axios-client.ts # HTTP客户端
|
||||
└── error-handler.ts # 错误处理
|
||||
```
|
||||
|
||||
#### 2. API文件模板
|
||||
|
||||
```typescript
|
||||
// app/api/evaluation_points/reviews.ts
|
||||
import { postgrestGet, type PostgrestParams, postgrestPut, postgrestPost } from "../postgrest-client";
|
||||
import { getDocument } from "~/api/files/documents";
|
||||
import { formatDate } from "~/utils";
|
||||
|
||||
/**
|
||||
* 数据提取工具函数
|
||||
* 统一处理不同格式的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;
|
||||
}
|
||||
|
||||
// 类型定义
|
||||
interface EvaluationResult {
|
||||
id: string | number;
|
||||
document_id: string | number;
|
||||
evaluation_point_id: string | number;
|
||||
evaluated_results?: {
|
||||
result?: boolean;
|
||||
message?: string;
|
||||
data?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface EvaluationPoint {
|
||||
id: string | number;
|
||||
evaluation_point_groups_id: string | number;
|
||||
suggestion_message_type?: string;
|
||||
suggestion_message?: string;
|
||||
score?: number;
|
||||
updated_at?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// 前端使用的结果类型
|
||||
interface ReviewPointResult {
|
||||
id: string | number;
|
||||
title: string;
|
||||
groupName: string;
|
||||
status: string;
|
||||
content: string;
|
||||
suggestion: string;
|
||||
result?: boolean;
|
||||
score: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取评查点数据
|
||||
* @param fileId 文件ID
|
||||
* @returns 评查点结果列表和统计数据
|
||||
*/
|
||||
export async function getReviewPoints(fileId: string) {
|
||||
try {
|
||||
// 步骤1: 获取文档基础数据
|
||||
const documentData = await getDocument(fileId);
|
||||
if (documentData.error) {
|
||||
return { error: documentData.error, status: documentData.status || 500 };
|
||||
}
|
||||
|
||||
// 步骤2: 查询评查结果
|
||||
const evaluationResultsParams: PostgrestParams = {
|
||||
select: '*',
|
||||
filter: { 'document_id': `eq.${fileId}` }
|
||||
};
|
||||
const evaluationResultsResponse = await postgrestGet('evaluation_results', evaluationResultsParams);
|
||||
|
||||
if (evaluationResultsResponse.error) {
|
||||
return { error: evaluationResultsResponse.error, status: evaluationResultsResponse.status };
|
||||
}
|
||||
|
||||
const evaluationResultsData = extractApiData<EvaluationResult[]>(evaluationResultsResponse.data) || [];
|
||||
|
||||
if (evaluationResultsData.length <= 0) {
|
||||
return {
|
||||
data: [],
|
||||
stats: { total: 0, success: 0, warning: 0, error: 0, score: 0 },
|
||||
error: '获取评查结果数据失败'
|
||||
};
|
||||
}
|
||||
|
||||
// 步骤3: 获取评查点详情
|
||||
const evaluationPointIds = evaluationResultsData.map(item => item.evaluation_point_id).filter(Boolean);
|
||||
|
||||
const evaluationPointsParams: PostgrestParams = {
|
||||
select: '*',
|
||||
filter: { 'id': `in.(${evaluationPointIds.join(',')})` }
|
||||
};
|
||||
const evaluationPointsResponse = await postgrestGet('evaluation_points', evaluationPointsParams);
|
||||
|
||||
if (evaluationPointsResponse.error) {
|
||||
return { error: evaluationPointsResponse.error, status: evaluationPointsResponse.status };
|
||||
}
|
||||
|
||||
const evaluationPointsData = extractApiData<EvaluationPoint[]>(evaluationPointsResponse.data) || [];
|
||||
|
||||
// 步骤4: 数据处理和转换
|
||||
const resultData: ReviewPointResult[] = evaluationResultsData.map(result => {
|
||||
const point = evaluationPointsData.find(p => p.id === result.evaluation_point_id);
|
||||
|
||||
return {
|
||||
id: result.id,
|
||||
title: result.evaluated_results?.message || '',
|
||||
groupName: point?.group_name || '',
|
||||
status: point?.suggestion_message_type || '',
|
||||
content: result.evaluated_results?.data || '',
|
||||
suggestion: point?.suggestion_message || '',
|
||||
result: result.evaluated_results?.result,
|
||||
score: point?.score || 0
|
||||
};
|
||||
});
|
||||
|
||||
// 步骤5: 统计数据计算
|
||||
const stats = {
|
||||
total: resultData.length,
|
||||
success: resultData.filter(item => item.result === true).length,
|
||||
warning: resultData.filter(item => item.status === 'warning').length,
|
||||
error: resultData.filter(item => item.status === 'error').length,
|
||||
score: resultData.reduce((sum, item) => sum + item.score, 0)
|
||||
};
|
||||
|
||||
return {
|
||||
data: resultData,
|
||||
stats,
|
||||
document: documentData.data
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取评查数据失败:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : '获取评查数据失败',
|
||||
status: 500
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新评查结果
|
||||
* @param resultId 评查结果ID
|
||||
* @param editAuditStatusId 审核状态ID
|
||||
* @param result 评查结果
|
||||
* @param message 评查意见
|
||||
*/
|
||||
export async function updateReviewResult(
|
||||
resultId: string,
|
||||
editAuditStatusId: string | number,
|
||||
result: string,
|
||||
message: string
|
||||
): Promise<{
|
||||
data?: unknown;
|
||||
error?: string;
|
||||
status?: number;
|
||||
}> {
|
||||
try {
|
||||
if (!resultId) {
|
||||
return { error: '评查结果ID不能为空', status: 400 };
|
||||
}
|
||||
|
||||
// 获取当前数据
|
||||
const currentResultResponse = await postgrestGet('evaluation_results', {
|
||||
select: '*',
|
||||
filter: { id: `eq.${resultId}` }
|
||||
});
|
||||
|
||||
if (currentResultResponse.error) {
|
||||
return { error: currentResultResponse.error, status: currentResultResponse.status };
|
||||
}
|
||||
|
||||
const currentResultData = extractApiData<EvaluationResult[]>(currentResultResponse.data);
|
||||
|
||||
if (!currentResultData || currentResultData.length === 0) {
|
||||
return { error: '未找到评查结果数据', status: 404 };
|
||||
}
|
||||
|
||||
const currentResult = currentResultData[0];
|
||||
const currentEvaluatedResults = currentResult.evaluated_results || {};
|
||||
|
||||
// 构建更新数据
|
||||
const isReview = result === 'review';
|
||||
const updatedEvaluatedResults = {
|
||||
...currentEvaluatedResults,
|
||||
...(isReview ? { message } : { result: result === 'true', message }),
|
||||
};
|
||||
|
||||
// 更新评查结果
|
||||
const resultResponse = await postgrestPut(
|
||||
'evaluation_results',
|
||||
{ evaluated_results: updatedEvaluatedResults },
|
||||
{ id: resultId }
|
||||
);
|
||||
|
||||
if (resultResponse.error) {
|
||||
return { error: resultResponse.error, status: resultResponse.status };
|
||||
}
|
||||
|
||||
// 处理审核状态
|
||||
const editAuditStatusValue = isReview ? 0 : 1;
|
||||
|
||||
if (editAuditStatusId && editAuditStatusId !== '') {
|
||||
// 更新现有记录
|
||||
const auditStatusResponse = await postgrestPut(
|
||||
'audit_status',
|
||||
{ edit_audit_status: editAuditStatusValue },
|
||||
{ id: editAuditStatusId }
|
||||
);
|
||||
|
||||
if (auditStatusResponse.error) {
|
||||
return { error: auditStatusResponse.error, status: auditStatusResponse.status };
|
||||
}
|
||||
} else {
|
||||
// 创建新记录
|
||||
const newAuditStatus = {
|
||||
document_id: currentResult.document_id,
|
||||
evaluation_point_id: currentResult.evaluation_point_id,
|
||||
evaluation_result_id: resultId,
|
||||
edit_audit_status: editAuditStatusValue
|
||||
};
|
||||
|
||||
const postResponse = await postgrestPost('audit_status', newAuditStatus);
|
||||
|
||||
if (postResponse.error) {
|
||||
return { error: postResponse.error, status: postResponse.status };
|
||||
}
|
||||
}
|
||||
|
||||
return { data: extractApiData<unknown>(resultResponse.data) };
|
||||
} catch (error) {
|
||||
console.error('更新评查结果失败:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : '更新评查结果失败',
|
||||
status: 500
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 错误处理规范
|
||||
|
||||
```typescript
|
||||
// 统一的错误处理格式
|
||||
interface ApiResponse<T> {
|
||||
data?: T;
|
||||
error?: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
// 在API函数中的错误处理
|
||||
export async function apiFunction(): Promise<ApiResponse<DataType>> {
|
||||
try {
|
||||
const response = await postgrestGet('table_name', params);
|
||||
|
||||
if (response.error) {
|
||||
return {
|
||||
error: response.error,
|
||||
status: response.status
|
||||
};
|
||||
}
|
||||
|
||||
const data = extractApiData<DataType>(response.data);
|
||||
|
||||
if (!data) {
|
||||
return {
|
||||
error: '数据格式错误',
|
||||
status: 500
|
||||
};
|
||||
}
|
||||
|
||||
return { data };
|
||||
} catch (error) {
|
||||
console.error('API调用失败:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : '未知错误',
|
||||
status: 500
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 前端路由集成规范
|
||||
|
||||
#### 1. Loader函数中调用API
|
||||
|
||||
```typescript
|
||||
// app/routes/reviews.tsx
|
||||
import { getReviewPoints, updateReviewResult, confirmReviewResults } from "~/api/evaluation_points/reviews";
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const id = url.searchParams.get('id') || undefined;
|
||||
const previousRoute = url.searchParams.get('previousRoute') || '';
|
||||
|
||||
if (!id) {
|
||||
return Response.json({ result: false, message: '文件ID不能为空' });
|
||||
}
|
||||
|
||||
// 获取评查点数据
|
||||
const reviewData = await getReviewPoints(id);
|
||||
|
||||
if ('error' in reviewData && reviewData.error) {
|
||||
console.error("获取评查点数据错误:", reviewData.error);
|
||||
return Response.json({ result: false, message: reviewData.error });
|
||||
}
|
||||
|
||||
// 确保数据格式正确
|
||||
if ('document' in reviewData && 'data' in reviewData && 'reviewInfo' in reviewData && 'stats' in reviewData) {
|
||||
return Response.json({
|
||||
previousRoute: previousRoute,
|
||||
document: reviewData.document,
|
||||
reviewPoints: reviewData.data,
|
||||
reviewInfo: reviewData.reviewInfo,
|
||||
statistics: reviewData.stats
|
||||
});
|
||||
} else {
|
||||
console.error("返回的评查数据格式不正确", JSON.stringify(reviewData, null, 2));
|
||||
return Response.json({ result: false, message: '返回的评查数据格式不正确' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取评查数据失败:', error);
|
||||
return Response.json({ result: false, message: '获取评查数据失败' });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 组件中处理API响应
|
||||
|
||||
```typescript
|
||||
// 在React组件中使用
|
||||
export default function ReviewDetails() {
|
||||
const loaderData = useLoaderData<typeof loader>();
|
||||
const { document, reviewPoints, statistics, reviewInfo } = loaderData;
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 处理loader错误
|
||||
useEffect(() => {
|
||||
if (Object.keys(loaderData).find(key => key === 'result') && !loaderData.result) {
|
||||
messageService.show({
|
||||
title: '错误',
|
||||
message: loaderData.message,
|
||||
type: 'error',
|
||||
confirmText: '确定',
|
||||
onConfirm: () => {
|
||||
navigate(-1);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [loaderData, navigate]);
|
||||
|
||||
// 处理状态更新
|
||||
const handleReviewPointStatusChange = async (
|
||||
reviewPointResultId: string,
|
||||
editAuditStatusId: string | number,
|
||||
newStatus: string,
|
||||
message: string
|
||||
) => {
|
||||
try {
|
||||
const response = await updateReviewResult(
|
||||
reviewPointResultId,
|
||||
editAuditStatusId,
|
||||
newStatus,
|
||||
message
|
||||
);
|
||||
|
||||
if (response.error) {
|
||||
console.error('更新评查结果失败:', response.error);
|
||||
toastService.error(`更新评查结果失败: ${response.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新本地状态
|
||||
setReviewData(prevData => {
|
||||
// 更新逻辑...
|
||||
});
|
||||
|
||||
toastService.success('评查点状态已更新');
|
||||
} catch (error) {
|
||||
console.error('更新评查结果出错:', error);
|
||||
toastService.error('更新评查结果失败,请稍后重试');
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 数据类型定义规范
|
||||
|
||||
#### 1. 数据库实体类型
|
||||
|
||||
```typescript
|
||||
// 数据库表对应的接口
|
||||
interface Document {
|
||||
id: number;
|
||||
user_id: number | null;
|
||||
type_id: number;
|
||||
name: string;
|
||||
document_number: string;
|
||||
path: string;
|
||||
storage_type: string;
|
||||
file_size: number;
|
||||
upload_time: string;
|
||||
is_test_document: boolean;
|
||||
evaluation_level: string;
|
||||
status: 'pass' | 'warning' | 'waiting' | 'processing' | 'fail';
|
||||
file_status: 'Waiting' | 'Cutting' | 'Extractioning' | 'Evaluationing' | 'Processed';
|
||||
audit_status: number; // -1: 不通过, 0: 待审核, 1: 通过, 2: 警告, 3: 审核中
|
||||
ocr_result?: Record<string, unknown>;
|
||||
extracted_results?: unknown;
|
||||
summary?: unknown;
|
||||
remark?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 前端UI类型
|
||||
|
||||
```typescript
|
||||
// 前端组件使用的接口
|
||||
interface ReviewFileUI {
|
||||
id: string;
|
||||
status: string;
|
||||
path: string;
|
||||
fileName: string;
|
||||
fileCode: string;
|
||||
fileType: string;
|
||||
fileTypeId: number;
|
||||
fileSize: number;
|
||||
uploadTime: string;
|
||||
reviewStatus: string;
|
||||
reviewStatusCode: number;
|
||||
issueCount: number;
|
||||
score?: number;
|
||||
auditStatus: number | null;
|
||||
issues: Array<{
|
||||
severity: 'info' | 'warning' | 'error' | 'critical';
|
||||
message: string;
|
||||
}>;
|
||||
createdBy: string;
|
||||
passCount: number;
|
||||
warningCount: number;
|
||||
failCount: number;
|
||||
manualCount: number;
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. API参数类型
|
||||
|
||||
```typescript
|
||||
// 搜索参数类型
|
||||
interface DocumentSearchParams {
|
||||
keyword?: string;
|
||||
status?: string;
|
||||
fileType?: string;
|
||||
dateRange?: [string, string];
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
```
|
||||
|
||||
### PostgREST客户端功能
|
||||
|
||||
#### 1. 查询参数转换
|
||||
|
||||
```typescript
|
||||
// 转换通用参数为PostgREST格式
|
||||
export function transformParams(params: PostgrestParams): QueryParams {
|
||||
const result: QueryParams = {};
|
||||
|
||||
// 处理select参数
|
||||
if (params.select) {
|
||||
result.select = params.select;
|
||||
}
|
||||
|
||||
// 处理过滤条件
|
||||
if (params.filter) {
|
||||
Object.entries(params.filter).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
result[key] = value as string | number | boolean;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 处理排序
|
||||
if (params.order) {
|
||||
result.order = params.order;
|
||||
}
|
||||
|
||||
// 处理分页
|
||||
if (params.limit !== undefined) {
|
||||
result.limit = params.limit;
|
||||
}
|
||||
|
||||
if (params.offset !== undefined) {
|
||||
result.offset = params.offset;
|
||||
}
|
||||
|
||||
// 处理OR条件
|
||||
if (params.or) {
|
||||
if (typeof params.or === 'string') {
|
||||
result.or = params.or;
|
||||
} else if (Array.isArray(params.or)) {
|
||||
const orConditions = params.or.map(condition => {
|
||||
const [field, operator] = Object.entries(condition)[0];
|
||||
return `${field}.${operator}`;
|
||||
});
|
||||
result.or = `(${orConditions.join(',')})`;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 开发环境日志
|
||||
|
||||
```typescript
|
||||
function logPostgrestQuery(endpoint: string, params?: QueryParams, method: string = 'GET'): void {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
const baseUrl = 'http://nas.7bm.co:3000';
|
||||
const normalizedEndpoint = endpoint.startsWith('/') ? endpoint.substring(1) : endpoint;
|
||||
|
||||
console.log('\n📦 PostgREST 查询日志 ======================start=============');
|
||||
console.log(`📦 HTTP 方法: ${method}`);
|
||||
console.log(`📦 API 端点: ${decodeUrlForDisplay(`${baseUrl}/${normalizedEndpoint}`)}`);
|
||||
|
||||
if (params && Object.keys(params).length > 0) {
|
||||
console.log('📦 查询参数:');
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
console.log(` - ${key}: ${JSON.stringify(value)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log('PostgREST 查询日志=============================end============\n');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 数据预处理
|
||||
|
||||
```typescript
|
||||
function preprocessData(data: Record<string, unknown>): Record<string, unknown> {
|
||||
const processed: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
// 处理null值
|
||||
if (value === null) {
|
||||
processed[key] = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 处理布尔值字符串
|
||||
if (typeof value === 'string' && (value.toLowerCase() === 'true' || value.toLowerCase() === 'false')) {
|
||||
processed[key] = value.toLowerCase() === 'true';
|
||||
}
|
||||
// 处理ID字段
|
||||
else if ((key === 'id' || key.endsWith('_id') || key === 'pid') && value !== undefined) {
|
||||
try {
|
||||
const numValue = Number(value);
|
||||
if (!isNaN(numValue)) {
|
||||
processed[key] = numValue;
|
||||
} else {
|
||||
processed[key] = value;
|
||||
}
|
||||
} catch {
|
||||
processed[key] = value;
|
||||
}
|
||||
}
|
||||
// 其他值保持不变
|
||||
else {
|
||||
processed[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
```
|
||||
|
||||
### 开发最佳实践
|
||||
|
||||
#### 1. API命名规范
|
||||
- 获取数据: `get[EntityName]s()` 或 `get[EntityName]()`
|
||||
- 创建数据: `create[EntityName]()`
|
||||
- 更新数据: `update[EntityName]()`
|
||||
- 删除数据: `delete[EntityName]()`
|
||||
|
||||
#### 2. 错误处理策略
|
||||
- 所有API函数必须返回统一的响应格式
|
||||
- 在API层处理数据库错误,前端只处理业务逻辑错误
|
||||
- 使用TypeScript严格类型检查避免运行时错误
|
||||
|
||||
#### 3. 性能优化
|
||||
- 使用`select`参数只获取需要的字段
|
||||
- 合理使用`limit`和`offset`进行分页
|
||||
- 避免N+1查询,使用关联查询获取相关数据
|
||||
|
||||
#### 4. 调试技巧
|
||||
- 开发环境自动打印查询日志
|
||||
- 使用浏览器网络面板检查实际请求
|
||||
- 通过PostgREST文档验证查询语法
|
||||
|
||||
### 数据对接检查清单
|
||||
|
||||
#### 新功能开发前
|
||||
- [ ] 确认数据库表结构和关系
|
||||
- [ ] 定义TypeScript接口类型
|
||||
- [ ] 规划API函数命名和参数
|
||||
- [ ] 设计错误处理策略
|
||||
|
||||
#### API实现阶段
|
||||
- [ ] 导入必要的postgrest方法
|
||||
- [ ] 实现extractApiData数据提取函数
|
||||
- [ ] 定义所有相关的类型接口
|
||||
- [ ] 编写查询参数构建逻辑
|
||||
- [ ] 实现数据转换和处理逻辑
|
||||
- [ ] 添加完整的错误处理
|
||||
|
||||
#### 前端集成阶段
|
||||
- [ ] 在loader函数中调用API
|
||||
- [ ] 处理loading和error状态
|
||||
- [ ] 实现UI状态更新逻辑
|
||||
- [ ] 添加用户反馈(toast/message)
|
||||
- [ ] 测试各种边界情况
|
||||
|
||||
#### 上线前检查
|
||||
- [ ] API函数测试通过
|
||||
- [ ] 错误处理验证完成
|
||||
- [ ] 性能测试满足要求
|
||||
- [ ] 日志输出正常
|
||||
- [ ] 代码注释完整
|
||||
|
||||
---
|
||||
|
||||
*此数据对接规范基于实际项目经验总结,将根据项目发展持续更新和完善。*
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 2.6 MiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 1.7 MiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user