完成文档列表页面ui,封装部分上传文件的公共组件,封装请求接口

This commit is contained in:
2025-04-01 22:14:43 +08:00
parent 8fe88c1d15
commit 706cea8705
37 changed files with 4512 additions and 1459 deletions
+286
View File
@@ -0,0 +1,286 @@
// app/api/client.ts
export type ApiResponse<T> = {
data?: T;
error?: string;
status: number;
};
export type QueryParams = Record<string, string | number | boolean | undefined>;
// 基本数据类型
interface BaseItem {
id: string;
[key: string]: unknown;
}
// 为模拟数据预定义类型定义(导出以允许其他文件引用)
// 这些类型被用在模拟数据中,虽然没有直接引用
export interface Document extends BaseItem {
name: string;
type: string;
size: number;
status: string;
uploadDate: string;
lastModified: string;
}
export interface Rule extends BaseItem {
code: string;
name: string;
ruleType: string;
groupId: string;
groupName: string;
priority: string;
description: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface RuleGroup extends BaseItem {
name: string;
description: string;
isActive: boolean;
}
export interface CountItem extends BaseItem {
count: number;
}
// 获取 API 基础 URL,支持服务器端和客户端环境
const API_BASE_URL = typeof process !== 'undefined' && process.env.API_BASE_URL
? process.env.API_BASE_URL
: 'http://nas.7bm.co:54302/api/docauditai'; // 如果服务器不可用,会自动使用模拟数据
// 获取 API 访问令牌
const API_TOKEN = typeof process !== 'undefined' && process.env.API_TOKEN
? process.env.API_TOKEN
: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYXBpX3VzZXIifQ.KVLm7rZOF0MuX3MR9LqiYA14gba-MaDK_EQXrJ9u5_Y';
// 是否使用模拟数据(开发环境使用)
const USE_MOCK_DATA = true; // 设置为 true 可启用模拟数据
/**
* 构建完整的 API URL,支持服务器端和客户端环境
*/
function buildUrl(endpoint: string, params?: QueryParams): string {
// 创建 URL 字符串
const url = new URL(
endpoint.startsWith('http') ? endpoint : API_BASE_URL + endpoint,
// 服务器端使用绝对 URL,客户端使用相对 URL
typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000'
);
// 添加查询参数
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
url.searchParams.append(key, String(value));
}
});
}
return url.toString();
}
// 超时控制
const fetchWithTimeout = async (url: string, options: RequestInit, timeout = 5000) => {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
...options,
signal: controller.signal
});
clearTimeout(id);
return response;
} catch (error) {
clearTimeout(id);
throw error;
}
};
// 模拟数据库响应的类型
type MockData = {
[key: string]: BaseItem[];
};
// 模拟数据库响应
const mockDataResponses: MockData = {
// 文档列表模拟数据
'/documents': [
{
id: '1',
name: '合同.pdf',
type: 'contract',
size: 1024000,
status: 'approved',
uploadDate: new Date().toISOString(),
lastModified: new Date().toISOString()
},
{
id: '2',
name: '报告.docx',
type: 'report',
size: 512000,
status: 'pending',
uploadDate: new Date().toISOString(),
lastModified: new Date().toISOString()
}
],
// 规则列表模拟数据
'/evaluation_points': [
{
id: '1',
code: 'R001',
name: '合同名称',
ruleType: 'essential',
groupId: '1',
groupName: '合同基本要素类检查',
priority: 'high',
description: '文档必须包含合同名称',
isActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
{
id: '2',
code: 'R002',
name: '合同编号',
ruleType: 'legal',
groupId: '2',
groupName: '销售合同专项检查',
priority: 'medium',
description: '文档必须包含合同编号',
isActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
],
// 评查点组列表模拟数据
'/evaluation_point_groups': [
{ id: '1', name: '合同基本要素类检查', description: '合同基本要素类检查', isActive: true },
{ id: '2', name: '销售合同专项检查', description: '销售合同专项检查', isActive: true },
{ id: '3', name: '采购合同专项检查', description: '采购合同专项检查', isActive: true },
{ id: '4', name: '专卖许可证审核规则', description: '专卖许可证审核规则', isActive: true },
{ id: '5', name: '行政处罚规范性检查', description: '行政处罚规范性检查', isActive: true }
],
// 计数查询 - 为了满足 BaseItem 类型,添加 id 字段
'/evaluation_points/count': [{ id: 'count', count: 2 }],
'/documents/count': [{ id: 'count', count: 2 }]
};
/**
* 获取模拟响应数据
* @param endpoint API端点
* @param params 查询参数
* @returns 模拟的响应数据
*/
function getMockResponse<T>(endpoint: string, params?: QueryParams): ApiResponse<T> {
console.log(`[开发模式] 使用模拟数据: ${endpoint}`);
// 移除开头的斜杠以便于匹配
const path = endpoint.startsWith('/') ? endpoint.substring(1) : endpoint;
// 检查是否有匹配的路径
for (const [mockPath, mockData] of Object.entries(mockDataResponses)) {
const normalizedMockPath = mockPath.startsWith('/') ? mockPath.substring(1) : mockPath;
if (path === normalizedMockPath || path.startsWith(normalizedMockPath + '/') || path.startsWith(normalizedMockPath + '?')) {
// 如果 ID 路径参数 (如 /rules/1),返回单个项目
const pathParts = path.split('/');
const mockPathParts = normalizedMockPath.split('/');
if (pathParts.length > mockPathParts.length && !isNaN(Number(pathParts[mockPathParts.length]))) {
const id = pathParts[mockPathParts.length];
const item = mockData.find(i => i.id === id);
return item
? { data: item as unknown as T, status: 200 }
: { error: '未找到', status: 404 };
}
// 处理分页
if (params?.limit && params?.offset) {
const limit = Number(params.limit);
const offset = Number(params.offset);
const paginatedData = mockData.slice(offset, offset + limit);
return { data: paginatedData as unknown as T, status: 200 };
}
// 返回完整数据
return { data: mockData as unknown as T, status: 200 };
}
}
// 没有匹配的模拟数据
return { error: '没有匹配的模拟数据', status: 404 };
}
/**
* 通用 API 请求函数
*/
export async function apiRequest<T>(
endpoint: string,
options: RequestInit = {},
params?: QueryParams
): Promise<ApiResponse<T>> {
// 如果使用模拟数据,直接返回模拟响应
if (USE_MOCK_DATA) {
return getMockResponse<T>(endpoint, params);
}
try {
// 构建 URL
const url = buildUrl(endpoint, params);
// 设置默认请求头
const headers = new Headers(options.headers || {});
if (!headers.has('Content-Type') && options.method !== 'GET') {
headers.set('Content-Type', 'application/json');
}
// 数据库连接授权信息
if (!headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${API_TOKEN}`);
}
// 发送请求,5秒超时
const response = await fetchWithTimeout(url, {
...options,
headers
}, 5000);
// 解析响应
let data = null;
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json') && response.status !== 204) {
data = await response.json();
}
if (!response.ok) {
console.error(`API请求失败: ${response.status} - ${url}`);
return {
error: data?.message || `请求失败: ${response.status}`,
status: response.status
};
}
return {
data,
status: response.status
};
} catch (error) {
console.error('API请求失败:', error);
// 如果超时或网络错误,使用模拟数据(仅开发环境)
if (process.env.NODE_ENV !== 'production') {
console.warn('自动使用模拟数据作为回退');
return getMockResponse<T>(endpoint, params);
}
return {
error: error instanceof Error ? error.message : '未知错误',
status: 500
};
}
}
+46
View File
@@ -0,0 +1,46 @@
/**
* API 错误类,用于封装 API 请求错误
*/
export class ApiError extends Error {
status: number;
constructor(message: string, status: number = 500) {
super(message);
this.name = 'ApiError';
this.status = status;
}
}
/**
* 处理 API 错误
* @param error 捕获的错误对象
* @returns 统一的 ApiError 对象
*/
export function handleApiError(error: unknown): ApiError {
if (error instanceof ApiError) {
return error;
}
if (error instanceof Error) {
return new ApiError(error.message);
}
return new ApiError('未知错误');
}
/**
* 创建一个标准的错误响应
* @param message 错误消息
* @param status HTTP 状态码
*/
export function createErrorResponse(message: string, status: number = 400): Response {
return new Response(
JSON.stringify({ error: message }),
{
status,
headers: {
'Content-Type': 'application/json'
}
}
);
}
+204
View File
@@ -0,0 +1,204 @@
import { postgrestGet, postgrestPost, postgrestPut, postgrestDelete } from '../postgrest-client';
/**
* 评查点列表查询参数
*/
export interface RulesQueryParams {
page?: number;
pageSize?: number;
ruleType?: string;
groupId?: string;
isActive?: boolean;
keyword?: string;
}
/**
* 评查点列表响应数据
*/
export interface RulesListResponse {
rules: Rule[];
totalCount: number;
}
/**
* 评查点详情
*/
export interface Rule {
id: string;
code: string;
name: string;
ruleType: string;
groupId: string;
groupName: string;
priority: string;
description: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
/**
* 评查点分组
*/
export interface RuleGroup {
id: string;
name: string;
description: string;
isActive: boolean;
}
/**
* 获取评查点列表
* @param params 查询参数
* @returns 评查点列表、总数和评查点组
*/
export async function getRulesList(params: RulesQueryParams): Promise<{data: RulesListResponse; error?: never} | {data?: never; error: string; status?: number}> {
try {
// 构建 PostgREST 查询参数
const { page = 1, pageSize = 10, ...filters } = params;
const offset = (page - 1) * pageSize;
// 构建过滤条件
const filterArray: Record<string, unknown> = {};
if (filters.ruleType) {
filterArray['rule_type'] = `eq.${filters.ruleType}`;
}
if (filters.groupId) {
filterArray['group_id'] = `eq.${filters.groupId}`;
}
if (filters.isActive !== undefined) {
filterArray['is_active'] = `eq.${filters.isActive}`;
}
if (filters.keyword) {
// 关键字搜索
filterArray['or'] = `name.ilike.*${filters.keyword}*,code.ilike.*${filters.keyword}*`;
}
// 执行多个 API 调用(获取评查点列表、总数和评查点组)
const [rulesResponse, countResponse] = await Promise.all([
postgrestGet<Rule[]>('/evaluation_points', {
select: '*',
order: 'created_at.desc',
limit: pageSize,
offset,
filter: filterArray
}),
postgrestGet<[{count: number}]>('/evaluation_points/count', {
filter: filterArray
}),
]);
// 处理错误情况
if (rulesResponse.error) {
return { error: `获取评查点列表失败: ${rulesResponse.error}`, status: 500 };
}
if (countResponse.error) {
return { error: `获取评查点总数失败: ${countResponse.error}`, status: 500 };
}
// 确保数据不为 undefined,提供默认值
const rules = rulesResponse.data || [];
const totalCount = countResponse.data && countResponse.data[0] ? countResponse.data[0].count : 0;
// 成功返回数据
return {
data: {
rules,
totalCount,
}
};
} catch (error) {
console.error('获取评查点列表出错:', error);
return {
error: error instanceof Error ? error.message : '获取评查点列表失败',
status: 500
};
}
}
/**
* 获取单个评查点详情
* @param id 评查点ID
* @returns 评查点详情
*/
export async function getRule(id: string): Promise<{data: Rule; error?: never} | {data?: never; error: string; status?: number}> {
return postgrestGet<Rule>(`/evaluation_points/${id}`);
}
/**
* 创建新评查点
* @param ruleData 评查点数据
* @returns 创建的评查点
*/
export async function createRule(ruleData: Omit<Rule, 'id' | 'createdAt' | 'updatedAt'>): Promise<{data: Rule; error?: never} | {data?: never; error: string; status?: number}> {
return postgrestPost<Rule, Omit<Rule, 'id' | 'createdAt' | 'updatedAt'>>('/evaluation_points', ruleData);
}
/**
* 更新评查点
* @param id 评查点ID
* @param ruleData 评查点数据
* @returns 更新后的评查点
*/
export async function updateRule(id: string, ruleData: Partial<Omit<Rule, 'id' | 'createdAt' | 'updatedAt'>>): Promise<{data: Rule; error?: never} | {data?: never; error: string; status?: number}> {
return postgrestPut<Rule, Partial<Omit<Rule, 'id' | 'createdAt' | 'updatedAt'>>>(`/evaluation_points/${id}`, ruleData);
}
/**
* 删除评查点
* @param id 评查点ID
* @returns 删除结果
*/
export async function deleteRule(id: string): Promise<{data: Rule; error?: never} | {data?: never; error: string; status?: number}> {
return postgrestDelete<Rule>(`/evaluation_points/${id}`);
}
/**
* 复制评查点
* @param id 评查点ID
* @returns 新创建的评查点
*/
export async function duplicateRule(id: string): Promise<{data: Rule; error?: never} | {data?: never; error: string; status?: number}> {
try {
// 1. 获取原评查点详情
const ruleResponse = await getRule(id);
if (ruleResponse.error || !ruleResponse.data) {
return { error: ruleResponse.error || '获取评查点详情失败', status: 500 };
}
// 2. 准备新评查点数据
const rule = ruleResponse.data;
// 创建新评查点对象
const newRuleData = {
code: `${rule.code}-COPY`,
name: `${rule.name} (复制)`,
ruleType: rule.ruleType,
groupId: rule.groupId,
groupName: rule.groupName,
priority: rule.priority,
description: rule.description,
isActive: rule.isActive
};
// 3. 创建新评查点
return createRule(newRuleData);
} catch (error) {
console.error('复制评查点出错:', error);
return {
error: error instanceof Error ? error.message : '复制评查点失败',
status: 500
};
}
}
+196
View File
@@ -0,0 +1,196 @@
// app/api/postgrest-client.ts
import { apiRequest, type QueryParams } from './client';
import { handleApiError } from './error-handler';
/**
* PostgresREST 特定的查询参数接口
*/
export interface PostgrestParams {
select?: string;
order?: string;
limit?: number;
offset?: number;
filter?: Record<string, unknown>;
schema?: string; // 指定 PostgreSQL schema
[key: string]: unknown; // 允许添加其他参数
}
/**
* 将通用查询参数转换为 PostgresREST 支持的格式
* @param params 查询参数
* @returns 转换后的 PostgresREST 参数
*/
export function transformParams(params: PostgrestParams): QueryParams {
const result: QueryParams = {};
// 处理 select 参数
if (params.select) {
result.select = params.select;
}
// 处理 order 参数
if (params.order) {
result.order = params.order;
}
// 处理 limit 和 offset 参数
if (params.limit !== undefined) {
result.limit = params.limit;
}
if (params.offset !== undefined) {
result.offset = params.offset;
}
// 处理 schema 参数
if (params.schema) {
result.schema = params.schema;
}
// 处理过滤条件 - PostgresREST 格式
if (params.filter) {
Object.entries(params.filter).forEach(([key, value]) => {
// 如果值不为 undefined,则添加到查询参数中
if (value !== undefined) {
// 支持 PostgreSQL 的比较操作符 (eq, gt, lt, gte, lte, like, ilike 等)
result[key] = value as string | number | boolean;
}
});
}
// 处理其他额外参数
Object.entries(params).forEach(([key, value]) => {
// 跳过已处理的特殊参数
if (!['select', 'order', 'limit', 'offset', 'filter', 'schema'].includes(key) && value !== undefined) {
result[key] = value as string | number | boolean;
}
});
return result;
}
/**
* 发送 GET 请求到 PostgresREST 接口
* @param endpoint 端点
* @param params 查询参数
* @returns 响应数据
*/
export async function postgrestGet<T>(endpoint: string, params?: PostgrestParams): Promise<{data: T; error?: never} | {data?: never; error: string; status?: number}> {
try {
const queryParams = params ? transformParams(params) : {};
// 添加前缀表示使用 docauditai 数据库
const apiEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
const response = await apiRequest<T>(
apiEndpoint,
{ method: 'GET' },
queryParams
);
if (response.error) {
throw new Error(response.error);
}
return { data: response.data as T };
} catch (error) {
const apiError = handleApiError(error);
return { error: apiError.message, status: apiError.status };
}
}
/**
* 发送 POST 请求到 PostgresREST 接口
* @param endpoint 端点
* @param data 请求体数据
* @returns 响应数据
*/
export async function postgrestPost<T, D = Record<string, unknown>>(endpoint: string, data: D): Promise<{data: T; error?: never} | {data?: never; error: string; status?: number}> {
try {
// 添加前缀表示使用 docauditai 数据库
const apiEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
const response = await apiRequest<T>(
apiEndpoint,
{
method: 'POST',
body: JSON.stringify(data),
headers: {
'Prefer': 'return=representation'
}
}
);
if (response.error) {
throw new Error(response.error);
}
return { data: response.data as T };
} catch (error) {
const apiError = handleApiError(error);
return { error: apiError.message, status: apiError.status };
}
}
/**
* 发送 PUT 请求到 PostgresREST 接口
* @param endpoint 端点
* @param data 请求体数据
* @returns 响应数据
*/
export async function postgrestPut<T, D = Record<string, unknown>>(endpoint: string, data: D): Promise<{data: T; error?: never} | {data?: never; error: string; status?: number}> {
try {
// 添加前缀表示使用 docauditai 数据库
const apiEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
const response = await apiRequest<T>(
apiEndpoint,
{
method: 'PUT',
body: JSON.stringify(data),
headers: {
'Prefer': 'return=representation'
}
}
);
if (response.error) {
throw new Error(response.error);
}
return { data: response.data as T };
} catch (error) {
const apiError = handleApiError(error);
return { error: apiError.message, status: apiError.status };
}
}
/**
* 发送 DELETE 请求到 PostgresREST 接口
* @param endpoint 端点
* @returns 响应数据
*/
export async function postgrestDelete<T>(endpoint: string): Promise<{data: T; error?: never} | {data?: never; error: string; status?: number}> {
try {
// 添加前缀表示使用 docauditai 数据库
const apiEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
const response = await apiRequest<T>(
apiEndpoint,
{
method: 'DELETE',
headers: {
'Prefer': 'return=representation'
}
}
);
if (response.error) {
throw new Error(response.error);
}
return { data: response.data as T };
} catch (error) {
const apiError = handleApiError(error);
return { error: apiError.message, status: apiError.status };
}
}
+6
View File
@@ -42,6 +42,12 @@ export function Sidebar({ onToggle, collapsed }: SidebarProps) {
title: '文件列表',
path: '/files',
icon: 'ri-file-list-3-line'
},
{
id:'documents',
title:'文档列表',
path:'/documents',
icon:'ri-file-list-3-line'
}
]
},
+98
View File
@@ -0,0 +1,98 @@
import dateRangePickerStyles from "~/styles/components/date-range-picker.css?url";
export interface DateRangePickerProps {
startDate: string;
endDate: string;
onStartDateChange: (value: string) => void;
onEndDateChange: (value: string) => void;
startLabel?: string;
endLabel?: string;
className?: string;
startId?: string;
endId?: string;
}
export function links() {
return [
{ rel: "stylesheet", href: dateRangePickerStyles }
];
}
/**
* 日期范围选择器组件
* 用于选择日期范围,如开始日期和结束日期
*/
export function DateRangePicker({
startDate,
endDate,
onStartDateChange,
onEndDateChange,
startLabel = "从",
endLabel = "至",
className = "",
startId = "date-start",
endId = "date-end"
}: DateRangePickerProps) {
return (
<div className={`date-range-picker ${className}`}>
<div className="date-range-fields">
<div className="date-field">
<label htmlFor={startId} className="date-label">{startLabel}</label>
<input
id={startId}
type="date"
className="date-input"
value={startDate}
onChange={(e) => onStartDateChange(e.target.value)}
/>
</div>
<span className="date-separator"></span>
<div className="date-field">
<label htmlFor={endId} className="date-label">{endLabel}</label>
<input
id={endId}
type="date"
className="date-input"
value={endDate}
onChange={(e) => onEndDateChange(e.target.value)}
/>
</div>
</div>
</div>
);
}
// 简化版日期范围选择器,适用于紧凑布局,不显示标签
export function SimpleDateRangePicker({
startDate,
endDate,
onStartDateChange,
onEndDateChange,
className = "",
startId = "date-start-simple",
endId = "date-end-simple"
}: Omit<DateRangePickerProps, 'startLabel' | 'endLabel'>) {
return (
<div className={`date-range-picker simple-date-range-picker ${className}`}>
<div className="date-range-fields">
<input
id={startId}
type="date"
className="date-input"
value={startDate}
onChange={(e) => onStartDateChange(e.target.value)}
aria-label="开始日期"
/>
<span className="date-separator"></span>
<input
id={endId}
type="date"
className="date-input"
value={endDate}
onChange={(e) => onEndDateChange(e.target.value)}
aria-label="结束日期"
/>
</div>
</div>
);
}
+95
View File
@@ -0,0 +1,95 @@
import fileTagStyles from '~/styles/components/file-tag.css?url';
// 支持的文件拓展名类型
export type FileExtension =
| 'pdf'
| 'doc'
| 'docx'
| 'xls'
| 'xlsx'
| 'ppt'
| 'pptx'
| 'zip'
| 'rar'
| 'txt'
| 'jpg'
| 'png'
| 'gif'
| string;
interface FileTagProps {
extension: FileExtension;
className?: string;
size?: 'default' | 'sm' | 'lg';
showIcon?: boolean;
showText?: boolean;
showBackground?: boolean;
}
export function links() {
return [{ rel: "stylesheet", href: fileTagStyles }];
}
/**
* 文件标签组件
* 用于显示文件的扩展名,如PDF、Word、Excel等
* @param extension 文件扩展名
* @param className 额外的类名
* @param size 尺寸:default, sm, lg
* @param showIcon 是否显示图标,默认为true
* @param showText 是否显示文件扩展名文本,默认为false
* @param showBackground 是否显示背景颜色,默认为true
*/
export function FileTag({
extension,
className = '',
size = 'default',
showIcon = true,
showText = false,
showBackground = true
}: FileTagProps) {
// 获取干净的扩展名(去除点号并转换为小写)
const getCleanExtension = () => {
return extension.replace(/^\./, '').toLowerCase();
};
// 文件扩展名对应的图标
const getExtensionIcon = () => {
const ext = getCleanExtension();
const extensionIconMap: Record<string, string> = {
'pdf': 'ri-file-pdf-line',
'doc': 'ri-file-word-line',
'docx': 'ri-file-word-line',
'xls': 'ri-file-excel-line',
'xlsx': 'ri-file-excel-line',
'ppt': 'ri-file-ppt-line',
'pptx': 'ri-file-ppt-line',
'zip': 'ri-file-zip-line',
'rar': 'ri-file-zip-line',
'txt': 'ri-file-text-line',
'jpg': 'ri-image-line',
'jpeg': 'ri-image-line',
'png': 'ri-image-line',
'gif': 'ri-file-gif-line',
};
return extensionIconMap[ext] || 'ri-file-line';
};
// 获取文件拓展名对应的类名
const getExtensionClass = () => {
const ext = getCleanExtension();
return `file-tag file-tag-${ext} ${showBackground ? '' : 'file-tag-no-bg'}`;
};
// 获取尺寸类名
const getSizeClass = () => {
return size !== 'default' ? `file-tag-${size}` : '';
};
return (
<span className={`${getExtensionClass()} ${getSizeClass()} ${className}`}>
{showIcon && <i className={`${getExtensionIcon()} file-tag-icon`}></i>}
{showText && <span className="file-tag-text">{getCleanExtension().toUpperCase()}</span>}
</span>
);
}
+72 -25
View File
@@ -1,39 +1,86 @@
import { FileType, FILE_TYPE_LABELS } from '~/routes/rules-files';
import fileTypeTagStyles from '~/styles/components/file-type-tag.css?url';
export type FileType =
| 'sales-contract'
| 'purchase-contract'
| 'license'
| 'punishment'
| 'agreement'
| string;
interface FileTypeTagProps {
fileType: FileType;
type: FileType;
text?: string;
className?: string;
size?: 'default' | 'sm' | 'lg';
showIcon?: boolean;
}
export function links() {
return [{ rel: "stylesheet", href: fileTypeTagStyles }];
}
/**
* 文类型标签组件
* 根据文件类型显示不同样式的标签
* 文类型标签组件
* 用于显示文档的类型,如销售合同、采购合同、许可证等
* @param type 文档类型
* @param text 自定义文本,不提供则使用默认文本
* @param className 额外的类名
* @param size 尺寸:default, sm, lg
* @param showIcon 是否显示图标,默认为true
*/
export function FileTypeTag({ fileType, className = '', size }: FileTypeTagProps) {
const sizeClass = size ? `file-type-tag-${size}` : '';
const tagClassName = `file-type-tag file-type-tag-${fileType.toLowerCase()} ${sizeClass} file-type-tag-with-icon ${className}`;
// 根据文件类型选择图标
const getFileTypeIcon = () => {
switch (fileType) {
case FileType.CONTRACT:
return <i className="ri-file-list-3-line"></i>;
case FileType.LICENSE:
return <i className="ri-vip-crown-line"></i>;
case FileType.PUNISHMENT:
return <i className="ri-scales-line"></i>;
case FileType.REPORT:
return <i className="ri-file-chart-line"></i>;
default:
return <i className="ri-file-line"></i>;
}
export function FileTypeTag({
type,
text,
className = '',
size = 'default',
showIcon = true
}: FileTypeTagProps) {
// 文档类型对应的图标
const getTypeIcon = () => {
const typeIconMap: Record<string, string> = {
'sales-contract': 'ri-file-list-3-line',
'purchase-contract': 'ri-shopping-cart-line',
'license': 'ri-vip-crown-line',
'punishment': 'ri-scales-line',
'agreement': 'ri-file-paper-line',
};
return typeIconMap[type] || 'ri-file-text-line';
};
// 文档类型对应的文本
const getTypeText = () => {
if (text) return text;
const typeTextMap: Record<string, string> = {
'sales-contract': '销售合同',
'purchase-contract': '采购合同',
'license': '专卖许可证',
'punishment': '行政处罚决定书',
'agreement': '承包协议',
};
return typeTextMap[type] || type;
};
// 获取文档类型对应的类名
const getTypeClass = () => {
return `file-type-tag file-type-${type}`;
};
// 获取尺寸类名
const getSizeClass = () => {
return size !== 'default' ? `file-type-tag-${size}` : '';
};
// 获取图标显示相关的类名
const getIconClass = () => {
return !showIcon ? 'file-type-tag-no-icon' : '';
};
return (
<span className={tagClassName}>
{getFileTypeIcon()}
{FILE_TYPE_LABELS[fileType]}
<span className={`${getTypeClass()} ${getSizeClass()} ${getIconClass()} ${className}`}>
{showIcon && <i className={`${getTypeIcon()} file-type-icon`}></i>}
<span className="file-type-text">{getTypeText()}</span>
</span>
);
}
+66 -1
View File
@@ -1,4 +1,5 @@
import { SearchBox } from '~/components/ui/SearchBox';
import { SimpleDateRangePicker, DateRangePicker } from '~/components/ui/DateRangePicker';
interface FilterOption {
value: string;
@@ -131,5 +132,69 @@ export function SearchFilter({
);
}
// 导出筛选下拉框组件
interface DateRangeFilterProps {
label: string;
startDate: string;
endDate: string;
onStartDateChange: (value: string) => void;
onEndDateChange: (value: string) => void;
className?: string;
startLabel?: string;
endLabel?: string;
simple?: boolean;
}
/**
* 日期范围筛选组件
*
* 使用示例:
* ```tsx
* <DateRangeFilter
* label="上传时间"
* startDate={dateFrom}
* endDate={dateTo}
* onStartDateChange={(value) => handleDateChange('dateFrom', value)}
* onEndDateChange={(value) => handleDateChange('dateTo', value)}
* />
* ```
*/
export function DateRangeFilter({
label,
startDate,
endDate,
onStartDateChange,
onEndDateChange,
className = '',
startLabel = "从",
endLabel = "至",
simple = false
}: DateRangeFilterProps) {
return (
<div className={`filter-item ${className}`}>
<label className="filter-label">{label}</label>
{simple ? (
<SimpleDateRangePicker
startDate={startDate}
endDate={endDate}
onStartDateChange={onStartDateChange}
onEndDateChange={onEndDateChange}
className="filter-control"
/>
) : (
<DateRangePicker
startDate={startDate}
endDate={endDate}
onStartDateChange={onStartDateChange}
onEndDateChange={onEndDateChange}
startLabel={startLabel}
endLabel={endLabel}
className="filter-control"
/>
)}
</div>
);
}
// 导出筛选下拉框组件和日期范围筛选组件
export { FilterSelect };
+63 -49
View File
@@ -1,69 +1,83 @@
import { ReviewStatus, REVIEW_STATUS_LABELS } from '~/routes/rules-files';
import statusBadgeStyles from '~/styles/components/status-badge.css?url';
export type StatusType = 'pending' | 'processing' | 'pass' | 'warning' | 'fail' | string;
interface StatusBadgeProps {
status: ReviewStatus;
issueCount?: number;
status: StatusType;
text?: string;
className?: string;
size?: 'default' | 'sm' | 'lg';
clickable?: boolean;
onClick?: () => void;
showIcon?: boolean;
customIcon?: string;
}
export function links() {
return [{ rel: "stylesheet", href: statusBadgeStyles }];
}
/**
* 文件评查状态标签组件
* 根据评查状态显示不同样式的标签
* 状态徽章组件
* 用于显示文档的处理状态,如待审核、审核中、通过等
*/
export function StatusBadge({
status,
issueCount = 0,
text,
className = '',
size,
clickable = false,
onClick
showIcon = true,
customIcon
}: StatusBadgeProps) {
const statusMap: Record<ReviewStatus, string> = {
[ReviewStatus.PASS]: 'success',
[ReviewStatus.WARNING]: 'warning',
[ReviewStatus.FAIL]: 'error',
[ReviewStatus.PENDING]: 'processing'
};
const badgeType = statusMap[status] || 'default';
const sizeClass = size ? `status-badge-${size}` : '';
const clickableClass = clickable ? 'status-badge-clickable' : '';
// 根据状态选择图标
// 状态对应的图标
const getStatusIcon = () => {
switch (status) {
case ReviewStatus.PASS:
return <i className="ri-checkbox-circle-line"></i>;
case ReviewStatus.WARNING:
return <i className="ri-alert-line"></i>;
case ReviewStatus.FAIL:
return <i className="ri-close-circle-line"></i>;
case ReviewStatus.PENDING:
return <i className="ri-time-line"></i>;
default:
return null;
}
// 如果提供了自定义图标,优先使用
if (customIcon) return customIcon;
const statusIconMap: Record<string, string> = {
pending: 'ri-time-line',
processing: 'ri-loader-4-line',
pass: 'ri-checkbox-circle-line',
warning: 'ri-alert-line',
fail: 'ri-error-warning-line',
};
return statusIconMap[status] || '';
};
const handleClick = () => {
if (clickable && onClick) {
onClick();
}
// 状态对应的文本
const getStatusText = () => {
if (text) return text;
const statusTextMap: Record<string, string> = {
pending: '待审核',
processing: '审核中',
pass: '通过',
warning: '警告',
fail: '不通过',
};
// 中英文映射,方便国际化
const statusEnglishTextMap: Record<string, string> = {
pending: 'Pending',
processing: 'Processing',
pass: 'Pass',
warning: 'Warning',
fail: 'Failed',
};
// 获取当前语言环境,这里默认使用中文
const lang: string = 'zh';
return lang === 'en'
? (statusEnglishTextMap[status] || status)
: (statusTextMap[status] || status);
};
// 获取状态对应的类名
const getStatusClass = () => {
return `status-badge status-${status}`;
};
return (
<span
className={`status-badge status-badge-${badgeType} status-badge-with-icon ${sizeClass} ${clickableClass} ${className}`}
onClick={handleClick}
role={clickable ? "button" : undefined}
tabIndex={clickable ? 0 : undefined}
>
{getStatusIcon()}
{REVIEW_STATUS_LABELS[status]}
{issueCount > 0 && ` (${issueCount})`}
<span className={`${getStatusClass()} ${className}`}>
{showIcon && getStatusIcon() && <i className={`${getStatusIcon()} mr-1`}></i>}
{getStatusText()}
</span>
);
}
+2
View File
@@ -1,3 +1,5 @@
/* 封装了状态的点,用于显示状态*/
// 状态类型
type StatusType = 'success' | 'error' | 'warning' | 'default' | 'processing';
interface StatusDotProps {
+26 -42
View File
@@ -3,10 +3,16 @@ import { type MetaFunction } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { Card } from "~/components/ui/Card";
import { Button } from "~/components/ui/Button";
import { StatusBadge, links as statusBadgeLinks } from "~/components/ui/StatusBadge";
import { FileTag, links as fileTagLinks } from "~/components/ui/FileTag";
import { FileTypeTag, links as fileTypeTagLinks } from "~/components/ui/FileTypeTag";
import homeStyles from "~/styles/pages/home.css?url";
export const links = () => [
{ rel: "stylesheet", href: homeStyles }
{ rel: "stylesheet", href: homeStyles },
...statusBadgeLinks(),
...fileTagLinks(),
...fileTypeTagLinks()
];
export const meta: MetaFunction = () => {
@@ -174,11 +180,28 @@ export default function Index() {
{recentFiles.map((file: RecentFile) => (
<div key={file.id} className="doc-item">
<div className="doc-info">
<i className={`doc-icon ${file.name.endsWith('.pdf') ? 'ri-file-pdf-line' : 'ri-file-word-line'}`}></i>
<FileTag
extension={file.name.endsWith('.pdf') ? 'pdf' : 'docx'}
showIcon={true}
showText={false}
showBackground={false}
size="lg"
className="mr-2"
/>
<div>
<div className="doc-name">{file.name}</div>
<div className="doc-meta">
{file.type} · {file.updatedAt}
<FileTypeTag
type={file.type === "合同文档" ? "sales-contract" :
file.type === "专卖许可证" ? "license" :
file.type === "行政处罚决定书" ? "punishment" : "agreement"}
text={file.type}
size="sm"
showIcon={false}
className="mr-2"
/>
<span className="text-gray-500">·</span>
<span className="ml-2 text-gray-500">{file.updatedAt}</span>
</div>
</div>
</div>
@@ -240,42 +263,3 @@ function ShortcutItem({ icon, label, to }: ShortcutItemProps) {
</Button>
);
}
// 状态标签组件
interface StatusBadgeProps {
status: string;
}
function StatusBadge({ status }: StatusBadgeProps) {
const statusMap: Record<string, { label: string, className: string, icon: string }> = {
pass: {
label: '通过',
className: 'status-badge status-success',
icon: 'ri-checkbox-circle-line'
},
warning: {
label: '警告',
className: 'status-badge status-warning',
icon: 'ri-alert-line'
},
fail: {
label: '不通过',
className: 'status-badge status-error',
icon: 'ri-close-circle-line'
},
pending: {
label: '待确认',
className: 'status-badge status-processing',
icon: 'ri-time-line'
}
};
const { label, className, icon } = statusMap[status] || statusMap.pending;
return (
<span className={className}>
<i className={`${icon} mr-1`}></i>
{label}
</span>
);
}
+700
View File
@@ -0,0 +1,700 @@
import { useState } from "react";
import { useSearchParams, Link } from "@remix-run/react";
import { type MetaFunction, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
import { Card } from "~/components/ui/Card";
import { Button } from "~/components/ui/Button";
import { Table } from "~/components/ui/Table";
import { Pagination } from "~/components/ui/Pagination";
import { StatusBadge } from "~/components/ui/StatusBadge";
import { FileTypeTag } from "~/components/ui/FileTypeTag";
import { FileTag } from "~/components/ui/FileTag";
import { FilterPanel, FilterSelect, SearchFilter, DateRangeFilter } from "~/components/ui/FilterPanel";
import documentsIndexStyles from "~/styles/pages/documents_index.css?url";
// 导入样式
export function links() {
return [
{ rel: "stylesheet", href: documentsIndexStyles }
];
}
// 元数据
export const meta: MetaFunction = () => {
return [
{ title: "文档列表 - 中国烟草AI合同及卷宗审核系统" },
{ name: "description", content: "查看和管理系统中的所有文档,包括合同、许可证和行政处罚决定书等" },
];
};
interface DocumentItem {
id: string;
name: string;
documentNumber: string;
type: string;
typeName: string;
size: number;
status: string;
issues: number | null;
uploadTime: string;
fileType: string;
tags?: string[];
}
// 数据加载器
export const loader = async ({ request }: LoaderFunctionArgs) => {
// 获取URL查询参数
const url = new URL(request.url);
const search = url.searchParams.get("search") || "";
const documentType = url.searchParams.get("documentType") || "";
const status = url.searchParams.get("status") || "";
const documentNumber = url.searchParams.get("documentNumber") || "";
const dateFrom = url.searchParams.get("dateFrom") || "";
const dateTo = url.searchParams.get("dateTo") || "";
const page = parseInt(url.searchParams.get("page") || "1", 10);
const pageSize = parseInt(url.searchParams.get("pageSize") || "20", 10);
// 在实际应用中,这里会调用API获取数据
// const response = await fetch(`/api/documents?search=${search}&...`);
// const data = await response.json();
// 使用模拟数据
const mockData = {
documents: [
{
id: "1",
name: "2023年度烟草销售框架合同.pdf",
documentNumber: "XS20230001",
type: "sales-contract",
typeName: "销售合同",
size: 2.5 * 1024 * 1024, // 2.5MB
status: "pass",
issues: 0,
uploadTime: "2023-10-15 15:30",
fileType: "pdf"
},
{
id: "2",
name: "设备采购合同-打印机.docx",
documentNumber: "CG20230052",
type: "purchase-contract",
typeName: "采购合同",
size: 1.2 * 1024 * 1024, // 1.2MB
status: "warning",
issues: 3,
uploadTime: "2023-10-14 09:15",
fileType: "docx"
},
{
id: "3",
name: "烟草零售许可证.pdf",
documentNumber: "ZM2023100345",
type: "license",
typeName: "专卖许可证",
size: 0.8 * 1024 * 1024, // 0.8MB
status: "pending",
issues: null,
uploadTime: "2023-10-13 14:20",
fileType: "pdf"
},
{
id: "4",
name: "非法售烟行政处罚决定书.docx",
documentNumber: "CF20230087",
type: "punishment",
typeName: "行政处罚决定书",
size: 1.5 * 1024 * 1024, // 1.5MB
status: "processing",
issues: null,
uploadTime: "2023-10-10 16:45",
fileType: "docx"
},
{
id: "5",
name: "烟草种植承包协议-2023.pdf",
documentNumber: "CB20230024",
type: "agreement",
typeName: "承包协议",
size: 3.2 * 1024 * 1024, // 3.2MB
status: "fail",
issues: 8,
uploadTime: "2023-10-09 10:30",
fileType: "pdf",
tags: ["测试"]
},
],
total: 156,
page,
pageSize
};
// 返回数据
return Response.json(mockData);
};
// 处理表单提交和删除等操作
export const action = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const action = formData.get("_action");
// 在实际应用中,这里会根据action类型调用相应的API
// 例如删除文档,批量删除,等等
if (action === "delete") {
const id = formData.get("id");
// await fetch(`/api/documents/${id}`, { method: "DELETE" });
return Response.json({ success: true, message: "文档已成功删除" });
}
if (action === "batchDelete") {
const ids = formData.getAll("ids");
// await fetch(`/api/documents/batch-delete`, {
// method: "POST",
// body: JSON.stringify({ ids }),
// headers: { "Content-Type": "application/json" }
// });
return Response.json({ success: true, message: `已成功删除${ids.length}个文档` });
}
// 未知操作
return Response.json({ success: false, message: "未知操作" }, { status: 400 });
};
// 文档类型选项
const documentTypeOptions = [
{ value: "sales-contract", label: "销售合同" },
{ value: "purchase-contract", label: "采购合同" },
{ value: "license", label: "专卖许可证" },
{ value: "punishment", label: "行政处罚决定书" },
{ value: "agreement", label: "承包协议" },
];
// 文档状态选项
const documentStatusOptions = [
{ value: "pending", label: "待审核" },
{ value: "processing", label: "审核中" },
{ value: "pass", label: "通过" },
{ value: "warning", label: "警告" },
{ value: "fail", label: "不通过" },
];
// 格式化文件大小
const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
// 获取文档类型标签背景颜色
// 此函数已不再需要,改用 FileTypeTag 组件
// const getDocumentTypeTagColor = (type: string): string => {
// const colorMap: Record<string, string> = {
// "sales-contract": "blue",
// "purchase-contract": "green",
// "license": "purple",
// "punishment": "yellow",
// "agreement": "orange",
// "default": "gray"
// };
// return colorMap[type] || colorMap.default;
// };
export default function DocumentsIndex() {
const [searchParams, setSearchParams] = useSearchParams();
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
// 从URL获取当前筛选条件
const search = searchParams.get("search") || "";
const documentType = searchParams.get("documentType") || "";
const status = searchParams.get("status") || "";
const documentNumber = searchParams.get("documentNumber") || "";
const dateFrom = searchParams.get("dateFrom") || "";
const dateTo = searchParams.get("dateTo") || "";
const currentPage = parseInt(searchParams.get("page") || "1", 10);
const pageSize = parseInt(searchParams.get("pageSize") || "20", 10);
// API 返回的模拟数据
const mockData = {
documents: [
{
id: "1",
name: "2023年度烟草销售框架合同.pdf",
documentNumber: "XS20230001",
type: "sales-contract",
typeName: "销售合同",
size: 2.5 * 1024 * 1024, // 2.5MB
status: "pass",
issues: 0,
uploadTime: "2023-10-15 15:30",
fileType: "pdf"
},
{
id: "2",
name: "设备采购合同-打印机.docx",
documentNumber: "CG20230052",
type: "purchase-contract",
typeName: "采购合同",
size: 1.2 * 1024 * 1024, // 1.2MB
status: "warning",
issues: 3,
uploadTime: "2023-10-14 09:15",
fileType: "docx"
},
{
id: "3",
name: "烟草零售许可证.pdf",
documentNumber: "ZM2023100345",
type: "license",
typeName: "专卖许可证",
size: 0.8 * 1024 * 1024, // 0.8MB
status: "pending",
issues: null,
uploadTime: "2023-10-13 14:20",
fileType: "pdf"
},
{
id: "4",
name: "非法售烟行政处罚决定书.docx",
documentNumber: "CF20230087",
type: "punishment",
typeName: "行政处罚决定书",
size: 1.5 * 1024 * 1024, // 1.5MB
status: "processing",
issues: null,
uploadTime: "2023-10-10 16:45",
fileType: "docx"
},
{
id: "5",
name: "烟草种植承包协议-2023.pdf",
documentNumber: "CB20230024",
type: "agreement",
typeName: "承包协议",
size: 3.2 * 1024 * 1024, // 3.2MB
status: "fail",
issues: 8,
uploadTime: "2023-10-09 10:30",
fileType: "pdf",
tags: ["测试"]
},
],
total: 156,
page: currentPage,
pageSize
};
// 分页处理函数
const handlePageChange = (page: number) => {
searchParams.set("page", page.toString());
setSearchParams(searchParams);
};
// 每页条数变更处理函数
const handlePageSizeChange = (size: number) => {
searchParams.set("pageSize", size.toString());
searchParams.set("page", "1"); // 重置到第一页
setSearchParams(searchParams);
};
// 处理文档名称搜索
const handleNameSearch = (value: string) => {
const params = new URLSearchParams(searchParams);
if (value) {
params.set("search", value);
} else {
params.delete("search");
}
params.set("page", "1"); // 重置页码
setSearchParams(params);
};
// 处理文档编号变更
const handleDocumentNumberChange = (value: string) => {
const params = new URLSearchParams(searchParams);
if (value) {
params.set("documentNumber", value);
} else {
params.delete("documentNumber");
}
params.set("page", "1"); // 重置页码
setSearchParams(params);
};
// 处理文档类型变更
const handleDocumentTypeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const params = new URLSearchParams(searchParams);
if (e.target.value) {
params.set("documentType", e.target.value);
} else {
params.delete("documentType");
}
params.set("page", "1"); // 重置页码
setSearchParams(params);
};
// 处理状态变更
const handleStatusChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const params = new URLSearchParams(searchParams);
if (e.target.value) {
params.set("status", e.target.value);
} else {
params.delete("status");
}
params.set("page", "1"); // 重置页码
setSearchParams(params);
};
// 处理日期范围变更
const handleDateChange = (field: 'dateFrom' | 'dateTo', value: string) => {
const params = new URLSearchParams(searchParams);
if (value) {
params.set(field, value);
} else {
params.delete(field);
}
params.set("page", "1"); // 重置页码
setSearchParams(params);
};
// 重置搜索条件
const handleReset = () => {
setSearchParams(new URLSearchParams({
page: "1",
pageSize: pageSize.toString()
}));
};
// 行选择变更处理
const handleRowSelectionChange = (id: string) => {
if (selectedRowKeys.includes(id)) {
setSelectedRowKeys(selectedRowKeys.filter(key => key !== id));
} else {
setSelectedRowKeys([...selectedRowKeys, id]);
}
};
// 全选处理
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedRowKeys(mockData.documents.map(doc => doc.id));
} else {
setSelectedRowKeys([]);
}
};
// 删除确认
const confirmDelete = (id: string, name: string) => {
if (window.confirm(`确认删除文档 "${name}"`)) {
// 在实际应用中这里会提交表单到action处理
console.log('删除文档:', id, name);
// 更新选中行
setSelectedRowKeys(selectedRowKeys.filter(key => key !== id));
}
};
// 批量删除确认
const confirmBatchDelete = () => {
if (selectedRowKeys.length === 0) {
alert('请至少选择一个文档');
return;
}
if (window.confirm(`确认删除选中的 ${selectedRowKeys.length} 个文档?`)) {
// 在实际应用中这里会提交表单到action处理
console.log('批量删除文档IDs:', selectedRowKeys);
// 清空选中行
setSelectedRowKeys([]);
}
};
// 表格列定义
const columns = [
{
title: (
<input
type="checkbox"
checked={selectedRowKeys.length === mockData.documents.length}
onChange={(e) => handleSelectAll(e.target.checked)}
/>
),
key: "selection",
width: "50px",
render: (_: unknown, record: DocumentItem) => (
<input
type="checkbox"
checked={selectedRowKeys.includes(record.id)}
onChange={() => handleRowSelectionChange(record.id)}
/>
)
},
{
title: "文档名称",
key: "name",
render: (_: unknown, record: DocumentItem) => (
<div className="flex items-center m-1">
<FileTag
extension={record.fileType}
showIcon={true}
showText={false}
showBackground={false}
size="lg"
className="mr-2"
/>
<div>
<span className="file-name" title={record.name}>{record.name}</span>
<div className="mt-2 flex inline-block">
<FileTypeTag
type={record.type}
text={record.typeName}
size="sm"
showIcon={false}
/>
{record.tags && record.tags.map((tag: string) => (
<span key={tag} className="ml-2 text-xs bg-gray-100 text-gray-500 px-1 rounded">{tag}</span>
))}
</div>
</div>
</div>
)
},
{
title: "文档编号",
key: "documentNumber",
render: (_: unknown, record: DocumentItem) => (
<span className="document-number">{record.documentNumber}</span>
)
},
{
title: "文件大小",
key: "size",
render: (_: unknown, record: DocumentItem) => formatFileSize(record.size)
},
{
title: "审核状态",
key: "status",
render: (_: unknown, record: DocumentItem) => (
<StatusBadge status={record.status} showIcon={false} />
)
},
{
title: "问题数量",
key: "issues",
render: (_: unknown, record: DocumentItem) => (
record.issues === null ? "-" : record.issues
)
},
{
title: "上传时间",
key: "uploadTime",
render: (_: unknown, record: DocumentItem) => record.uploadTime
},
{
title: "操作",
key: "actions",
width: "280px",
render: (_: unknown, record: DocumentItem) => (
<div className="operations-cell">
{record.status === "pending" ? (
<Link
to={`/documents/${record.id}/review`}
className="mr-1 hover:underline"
>
<i className="ri-play-circle-line"></i>
</Link>
) : record.status === "processing" ? (
<Link
to={`/documents/${record.id}/progress`}
className="mr-1 hover:underline"
>
<i className="ri-eye-line"></i>
</Link>
) : (
<Link
to={`/documents/${record.id}`}
className="mr-1 hover:underline"
>
<i className="ri-eye-line"></i>
</Link>
)}
<Link
to={`/documents/${record.id}/edit`}
className="mr-1 text-gray-500 hover:underline hover:text-gray-700"
>
<i className="ri-edit-line"></i>
</Link>
<button
type="button"
className="mr-1 text-gray-500 hover:underline hover:text-gray-700"
onClick={() => alert(`下载文档: ${record.name}`)}
>
<i className="ri-download-line"></i>
</button>
<button
type="button"
className="text-error hover:underline hover:text-red-700"
onClick={() => confirmDelete(record.id, record.name)}
>
<i className="ri-delete-bin-line"></i>
</button>
</div>
)
}
];
return (
<div className="documents-page">
{/* 页面头部 */}
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-medium"></h2>
<div>
<Button
type="primary"
icon="ri-upload-line"
to="/documents/upload"
className="hover:text-white"
>
</Button>
</div>
</div>
{/* 搜索筛选区 */}
<FilterPanel
actions={
<>
<Button
type="default"
icon="ri-refresh-line"
onClick={handleReset}
className="mr-2"
>
</Button>
<Button
type="primary"
icon="ri-search-line"
onClick={() => {
// 保持当前筛选条件,刷新数据
// 在实际应用中,这里可能需要触发某些操作
}}
>
</Button>
</>
}
noActionDivider={true}
>
<SearchFilter
label="文档名称"
placeholder="请输入文档名称"
value={search}
onSearch={handleNameSearch}
instantSearch={true}
className="mr-2 w-50"
/>
<SearchFilter
label="文档编号"
placeholder="请输入文档编号"
value={documentNumber}
onSearch={handleDocumentNumberChange}
instantSearch={true}
className="mr-2 w-50"
/>
<FilterSelect
label="文档类型"
name="documentType"
value={documentType}
options={documentTypeOptions}
onChange={handleDocumentTypeChange}
className="mr-2 w-30"
/>
<FilterSelect
label="审核状态"
name="status"
value={status}
options={documentStatusOptions}
onChange={handleStatusChange}
className="mr-2 w-50"
/>
<DateRangeFilter
label="上传时间"
startDate={dateFrom}
endDate={dateTo}
onStartDateChange={(value) => handleDateChange('dateFrom', value)}
onEndDateChange={(value) => handleDateChange('dateTo', value)}
className="flex-1"
simple={true}
/>
</FilterPanel>
{/* 数据表格 */}
<Card>
<div className="mb-3 flex items-center justify-between">
<div>
<Button
type="default"
icon="ri-delete-bin-line"
onClick={confirmBatchDelete}
className="mr-2"
disabled={selectedRowKeys.length === 0}
>
</Button>
<Button
type="default"
icon="ri-download-line"
>
</Button>
</div>
<div className="text-sm text-secondary">
<span className="font-medium text-primary">{mockData.total}</span>
</div>
</div>
<div className="overflow-x-auto">
<Table
columns={columns}
dataSource={mockData.documents}
rowKey="id"
emptyText="暂无数据"
/>
</div>
{/* 分页 */}
<Pagination
currentPage={currentPage}
total={mockData.total}
pageSize={pageSize}
onChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
pageSizeOptions={[10, 20, 50, 100]}
/>
</Card>
</div>
);
}
// 错误边界处理
export function ErrorBoundary() {
return (
<div className="error-container">
<h1 className="text-xl font-bold text-red-500"></h1>
<p></p>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { Outlet } from "react-router-dom";
import {type MetaFunction} from "@remix-run/node";
export const meta: MetaFunction = () => {
return [
{title: "文档列表 - 中国烟草AI合同及卷宗审核系统"},
{name: "documents", content: "文档列表,新增,修改"}
]
}
export const handle = {
breadcrumb: "文档列表"
}
/**
* 文档列表路由布局
*/
export default function DocumentsLayout() {
return (
<Outlet />
)
}
+632
View File
@@ -0,0 +1,632 @@
import { useState, useRef, useCallback } from "react";
import { type ActionFunctionArgs, type MetaFunction, json } from "@remix-run/node";
import { Form, useActionData, useNavigation, useSubmit } from "@remix-run/react";
import { Card } from "~/components/ui/Card";
import { Button } from "~/components/ui/Button";
import { Alert } from "~/components/ui/Alert";
import { UploadArea, type UploadAreaRef } from "~/components/ui/UploadArea";
import { FileProgress } from "~/components/ui/FileProgress";
import { FileTag } from "~/components/ui/FileTag";
import documentUploadStyles from "~/styles/pages/document-upload.css?url";
export const links = () => [
{ rel: "stylesheet", href: documentUploadStyles }
];
export const meta: MetaFunction = () => {
return [
{ title: "上传文档 - 中国烟草AI合同及卷宗审核系统" },
{ name: "description", content: "上传文档进行AI审核" }
];
};
export const handle = {
breadcrumb: "上传文档"
};
// 模拟API支持的文件类型
const SUPPORTED_FILE_TYPES = [
{ id: "1", name: "销售合同" },
{ id: "2", name: "采购合同" },
{ id: "3", name: "专卖许可证" },
{ id: "4", name: "行政处罚决定书" },
{ id: "5", name: "承包协议" }
];
// 模拟API支持的存储类型
const STORAGE_TYPES = [
{ id: "minio", name: "MinIO对象存储" },
{ id: "local", name: "本地文件系统" },
{ id: "s3", name: "Amazon S3" }
];
// 文件上传完成后的操作选项
const AFTER_UPLOAD_OPTIONS = [
{ id: "list", name: "返回文档列表" },
{ id: "stay", name: "留在当前页面" },
{ id: "audit", name: "立即开始审核" }
];
// 定义接口
interface UploadedFile {
id: string;
name: string;
size: number;
status: "waiting" | "uploading" | "success" | "error";
progress: number;
error?: string;
newName?: string;
type: string;
}
interface ActionData {
success?: boolean;
error?: string;
files?: UploadedFile[];
}
// Action函数处理表单提交
export const action = async ({ request }: ActionFunctionArgs) => {
// 在实际应用中,这里应该处理文件上传逻辑
// 例如使用FormData API获取文件并调用后端API
try {
const formData = await request.formData();
const docType = formData.get("docType") as string;
const docNumber = formData.get("docNumber") as string;
const docRemark = formData.get("docRemark") as string;
const isTestDocument = formData.get("isTestDocument") === "true";
const storageType = formData.get("storageType") as string;
const afterUpload = formData.get("afterUpload") as string;
// 在真实情况下,这里将处理文件上传
// 由于Remix在服务器端不直接处理文件,我们将在客户端处理文件上传
// 然后将文件信息发送给服务器
// 模拟处理过程
await new Promise(resolve => setTimeout(resolve, 1000));
return json<ActionData>({
success: true,
files: [] // 服务器处理的文件列表将返回这里
});
} catch (error) {
console.error("Upload error:", error);
return json<ActionData>(
{
success: false,
error: error instanceof Error ? error.message : "文件上传过程中发生错误"
},
{ status: 400 }
);
}
};
// 格式化文件大小
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
// 获取文件扩展名
function getFileExtension(filename: string): string {
return filename.split('.').pop()?.toLowerCase() || "";
}
// 检查文件类型是否支持
function isFileTypeSupported(filename: string): boolean {
const ext = getFileExtension(filename);
return ["pdf", "doc", "docx", "txt"].includes(ext);
}
export default function DocumentUpload() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const submit = useSubmit();
const uploading = navigation.state === "submitting";
const [files, setFiles] = useState<UploadedFile[]>([]);
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
const [isTestDocument, setIsTestDocument] = useState(false);
const [uploadComplete, setUploadComplete] = useState(false);
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([]);
const uploadAreaRef = useRef<UploadAreaRef>(null);
const formRef = useRef<HTMLFormElement>(null);
// 处理文件选择
const handleFilesSelected = useCallback((fileList: FileList) => {
const newFiles: UploadedFile[] = [];
Array.from(fileList).forEach(file => {
// 检查文件类型
if (!isFileTypeSupported(file.name)) {
alert(`不支持的文件类型: ${file.name}\n请上传PDF、DOC、DOCX或TXT格式文件`);
return;
}
// 检查文件大小
if (file.size > 50 * 1024 * 1024) { // 50MB
alert(`文件过大: ${file.name}\n文件大小不能超过50MB`);
return;
}
// 检查是否已添加
const isDuplicate = files.some(f => f.name === file.name && f.size === file.size);
if (isDuplicate) {
alert(`文件已添加: ${file.name}`);
return;
}
// 添加新文件
newFiles.push({
id: `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
name: file.name,
size: file.size,
status: "waiting",
progress: 0,
type: getFileExtension(file.name)
});
});
setFiles(prev => [...prev, ...newFiles]);
// 重置文件输入,允许再次选择相同文件
uploadAreaRef.current?.resetFileInput();
}, [files]);
// 移除文件
const removeFile = useCallback((fileId: string) => {
setFiles(prev => prev.filter(file => file.id !== fileId));
setSelectedFileIds(prev => prev.filter(id => id !== fileId));
}, []);
// 批量删除文件
const removeSelectedFiles = useCallback(() => {
if (selectedFileIds.length === 0) return;
if (confirm(`确定要删除选中的 ${selectedFileIds.length} 个文件吗?`)) {
setFiles(prev => prev.filter(file => !selectedFileIds.includes(file.id)));
setSelectedFileIds([]);
}
}, [selectedFileIds]);
// 清空文件列表
const clearAllFiles = useCallback(() => {
if (files.length === 0) return;
if (confirm('确定要清空文件列表吗?')) {
setFiles([]);
setSelectedFileIds([]);
}
}, [files.length]);
// 切换文件选择
const toggleFileSelection = useCallback((fileId: string, selected: boolean) => {
if (selected) {
setSelectedFileIds(prev => [...prev, fileId]);
} else {
setSelectedFileIds(prev => prev.filter(id => id !== fileId));
}
}, []);
// 更新文件名
const updateFileName = useCallback((fileId: string, newName: string) => {
setFiles(prev =>
prev.map(file =>
file.id === fileId
? { ...file, newName: newName + '.' + getFileExtension(file.name) }
: file
)
);
}, []);
// 提交表单
const handleSubmit = useCallback((event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = event.currentTarget;
const docType = form.docType.value;
// 表单验证
if (!docType) {
alert('请选择文档类型');
return;
}
if (files.length === 0) {
alert('请至少上传一个文档');
return;
}
// 创建FormData对象
const formData = new FormData(form);
formData.append("isTestDocument", isTestDocument.toString());
// 在实际应用中,这里应该处理文件上传
// 如果Remix不能直接处理文件上传,可以考虑使用预签名URL或其他方法
// 这里我们模拟文件上传进度
simulateUpload();
// 提交表单
submit(formData, { method: "post", encType: "multipart/form-data" });
}, [files.length, isTestDocument, submit]);
// 模拟文件上传进度
const simulateUpload = useCallback(() => {
const updatedFiles = [...files];
// 设置所有文件为上传中状态
updatedFiles.forEach(file => {
file.status = "uploading";
file.progress = 0;
});
setFiles(updatedFiles);
// 模拟进度更新
const interval = setInterval(() => {
setFiles(prevFiles => {
const newFiles = [...prevFiles];
let allComplete = true;
newFiles.forEach(file => {
if (file.status === "uploading") {
// 增加进度
file.progress += Math.random() * 10;
if (file.progress >= 100) {
file.progress = 100;
// 模拟有10%概率上传失败
if (Math.random() > 0.9) {
file.status = "error";
file.error = "上传失败,请重试";
} else {
file.status = "success";
}
} else {
allComplete = false;
}
}
});
// 如果所有文件都完成了,停止定时器
if (allComplete) {
clearInterval(interval);
setTimeout(() => {
// 检查是否有文件上传错误
const hasErrors = newFiles.some(file => file.status === "error");
if (!hasErrors) {
setUploadComplete(true);
}
}, 1000);
}
return newFiles;
});
}, 200);
}, [files]);
// 重新上传文件
const retryUpload = useCallback((fileId: string) => {
setFiles(prev =>
prev.map(file =>
file.id === fileId
? { ...file, status: "uploading", progress: 0, error: undefined }
: file
)
);
// 模拟重新上传
setTimeout(() => {
setFiles(prev =>
prev.map(file => {
if (file.id === fileId) {
const success = Math.random() > 0.1;
return {
...file,
status: success ? "success" : "error",
progress: 100,
error: success ? undefined : "上传失败,请重试"
};
}
return file;
})
);
}, 2000);
}, []);
// 重置表单,继续上传
const resetForm = useCallback(() => {
setFiles([]);
setUploadComplete(false);
setSelectedFileIds([]);
formRef.current?.reset();
}, []);
return (
<div className="document-upload-page">
<div className="page-header">
<h2 className="page-title"></h2>
<div>
<Button to="/documents" type="default" className="mr-2">
<i className="ri-arrow-left-line"></i>
</Button>
<Button
type="primary"
disabled={files.length === 0 || uploading}
onClick={() => formRef.current?.requestSubmit()}
>
<i className="ri-upload-2-line"></i>
</Button>
</div>
</div>
<Card>
{!uploadComplete ? (
<Form ref={formRef} method="post" onSubmit={handleSubmit} encType="multipart/form-data">
<div className="form-grid">
<div className="form-group">
<label className="form-label" htmlFor="docType">
<span className="text-red-500">*</span>
</label>
<select
id="docType"
name="docType"
className="form-select w-full"
required
>
<option value=""></option>
{SUPPORTED_FILE_TYPES.map(type => (
<option key={type.id} value={type.id}>
{type.name}
</option>
))}
</select>
<div className="form-tip"></div>
</div>
<div className="form-group">
<label className="form-label" htmlFor="docNumber">
</label>
<input
type="text"
id="docNumber"
name="docNumber"
className="form-input w-full"
placeholder="请输入合同编号、许可证号等"
/>
<div className="form-tip"></div>
</div>
</div>
<div className="form-group">
<label className="form-label" htmlFor="docRemark">
</label>
<textarea
id="docRemark"
name="docRemark"
className="form-textarea w-full"
placeholder="可输入文档的相关描述或备注信息"
rows={2}
></textarea>
</div>
<div className="form-group">
<label className="form-label">
<span className="text-red-500">*</span>
</label>
<UploadArea
ref={uploadAreaRef}
onFilesSelected={handleFilesSelected}
accept=".pdf,.doc,.docx,.txt"
multiple={true}
icon="ri-upload-cloud-line"
mainText="拖拽文件到此处或点击上传"
tipText="支持 PDF、DOC、DOCX、TXT 格式文档,单个文件大小不超过50MB"
disabled={uploading}
/>
<div className="switch-container">
<label className="switch">
<input
type="checkbox"
checked={isTestDocument}
onChange={e => setIsTestDocument(e.target.checked)}
/>
<span className="slider"></span>
</label>
<span></span>
</div>
{files.length > 0 && (
<div className="batch-actions">
<div>
<span className="text-sm"> {selectedFileIds.length} </span>
</div>
<div>
<Button
type="default"
size="small"
className="mr-2"
onClick={removeSelectedFiles}
disabled={selectedFileIds.length === 0 || uploading}
>
<i className="ri-delete-bin-line"></i>
</Button>
<Button
type="default"
size="small"
onClick={clearAllFiles}
disabled={files.length === 0 || uploading}
>
<i className="ri-close-circle-line"></i>
</Button>
</div>
</div>
)}
<div className="file-list">
{files.map(file => (
<div key={file.id} className="file-item">
<input
type="checkbox"
checked={selectedFileIds.includes(file.id)}
onChange={e => toggleFileSelection(file.id, e.target.checked)}
disabled={uploading || file.status === "uploading"}
className="mr-3"
/>
<FileTag
extension={getFileExtension(file.name)}
size="lg"
className="mr-3"
/>
<div className="file-info">
<div className="file-name flex items-center">
<span>{file.newName || file.name}</span>
{file.status !== "uploading" && (
<button
type="button"
className="ml-2 text-primary text-sm"
onClick={() => {
const fileName = file.name;
const nameWithoutExt = fileName.substring(0, fileName.lastIndexOf('.'));
const newName = prompt('编辑文件名', nameWithoutExt);
if (newName) {
updateFileName(file.id, newName);
}
}}
disabled={uploading}
>
<i className="ri-edit-line"></i>
</button>
)}
</div>
<div className="file-meta">
<span className="file-size">{formatFileSize(file.size)}</span>
<span className={`file-status ${file.status === "error" ? "text-red-500" : ""}`}>
{file.status === "waiting" && "等待上传"}
{file.status === "uploading" && "上传中..."}
{file.status === "success" && "上传成功"}
{file.status === "error" && (
<>
{file.error}
<button
type="button"
className="ml-2 text-primary text-xs"
onClick={() => retryUpload(file.id)}
>
<i className="ri-refresh-line"></i>
</button>
</>
)}
</span>
</div>
<div className="progress-bar">
<div className="progress-bar-inner" style={{ width: `${file.progress}%` }}></div>
</div>
</div>
<div className="file-actions">
<Button
type="text"
size="small"
className="text-red-500"
onClick={() => removeFile(file.id)}
disabled={uploading || file.status === "uploading"}
title="删除文件"
>
<i className="ri-delete-bin-line"></i>
</Button>
</div>
</div>
))}
</div>
</div>
<div className="advanced-options">
<div
className={`advanced-options-toggle ${showAdvancedOptions ? 'open' : ''}`}
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
>
<span></span>
<i className="ri-arrow-down-s-line"></i>
</div>
<div
className="advanced-options-content"
style={{ display: showAdvancedOptions ? 'block' : 'none' }}
>
<div className="grid grid-cols-2 gap-4">
<div className="form-group">
<label className="form-label" htmlFor="storageType"></label>
<select
id="storageType"
name="storageType"
className="form-select w-full"
defaultValue="minio"
>
{STORAGE_TYPES.map(type => (
<option key={type.id} value={type.id}>
{type.name}
</option>
))}
</select>
<div className="form-tip"></div>
</div>
<div className="form-group">
<label className="form-label" htmlFor="afterUpload"></label>
<select
id="afterUpload"
name="afterUpload"
className="form-select w-full"
defaultValue="list"
>
{AFTER_UPLOAD_OPTIONS.map(option => (
<option key={option.id} value={option.id}>
{option.name}
</option>
))}
</select>
<div className="form-tip"></div>
</div>
</div>
</div>
</div>
</Form>
) : (
<div className="upload-complete-actions" style={{ display: "block" }}>
<Alert type="success" className="mb-4">
</Alert>
<div>
<Button type="default" className="mr-2" onClick={resetForm}>
<i className="ri-add-line"></i>
</Button>
<Button to="/documents" type="default" className="mr-2">
<i className="ri-list-check-line"></i>
</Button>
<Button to="/documents/1?action=audit" type="primary">
<i className="ri-play-circle-line"></i>
</Button>
</div>
</div>
)}
</Card>
</div>
);
}
+9 -9
View File
@@ -213,7 +213,6 @@ export default function FilesUpload() {
}
setCurrentFile(selectedFiles[0]);
console.log("currentFile", currentFile);
startUpload(selectedFiles[0]);
}, [fileType, currentFile]);
@@ -246,7 +245,7 @@ export default function FilesUpload() {
setUploadSpeed("完成");
// 完成上传后开始处理流程
startProcessing();
startProcessing(file);
}
return 100;
}
@@ -257,7 +256,7 @@ export default function FilesUpload() {
};
// 开始处理文件
const startProcessing = () => {
const startProcessing = (file: File) => {
setUploadStage("processing");
// 更新步骤状态 - 将第一步标记为完成
@@ -276,7 +275,7 @@ export default function FilesUpload() {
if (currentStepIndex >= processingSteps.length) {
if (processingIntervalRef.current) {
clearInterval(processingIntervalRef.current);
completeProcessing();
completeProcessing(file);
}
return;
}
@@ -322,18 +321,18 @@ export default function FilesUpload() {
};
// 完成处理流程
const completeProcessing = () => {
const completeProcessing = (file: File) => {
// 设置当前状态为已完成
setUploadStage("completed");
// 创建完成的文件对象
if (currentFile) {
if (file) {
console.log("创建完成的文件对象...");
const newFile: UploadedFile = {
id: `file_${Date.now()}`,
name: currentFile.name,
size: currentFile.size,
type: currentFile.type,
name: file.name,
size: file.size,
type: file.type,
fileType: fileType as FileType,
priority,
status: ProcessingStatus.SUCCESS,
@@ -495,6 +494,7 @@ export default function FilesUpload() {
width: "15%",
render: (_: unknown, record: UploadedFile) => (
<Button
className={record.status !== ProcessingStatus.SUCCESS ? "" : "hover:border-green-700 hover:text-green-700"}
type="default"
size="small"
disabled={record.status !== ProcessingStatus.SUCCESS}
-306
View File
@@ -1,306 +0,0 @@
import React from 'react';
import { json, type MetaFunction } from '@remix-run/node';
import { useLoaderData, useSearchParams, Form } from '@remix-run/react';
import { Button } from '~/components/ui/Button';
import { Card } from '~/components/ui/Card';
import { Table } from '~/components/ui/Table';
import { Breadcrumb } from '~/components/layout/Breadcrumb';
import type { File } from '~/models/file';
import { REVIEW_STATUS_LABELS, REVIEW_STATUS_COLORS } from '~/models/file';
export const meta: MetaFunction = () => {
return [
{ title: "中国烟草AI合同及卷宗审核系统 - 文件列表" },
{ name: "description", content: "评查文件列表" }
];
};
export const handle = {
breadcrumb: '文件列表'
};
interface LoaderData {
files: File[];
documentTypes: {
id: string;
name: string;
}[];
totalCount: number;
}
export async function loader({ request }) {
const url = new URL(request.url);
const documentTypeId = url.searchParams.get("documentTypeId") || "";
const reviewStatus = url.searchParams.get("reviewStatus") || "";
const keyword = url.searchParams.get("keyword") || "";
// 模拟数据,实际项目中应从API获取
const files: File[] = [
{
id: "1",
fileName: "2023年度烟草专卖零售许可证.pdf",
fileType: "application/pdf",
documentTypeId: "2",
documentTypeName: "专卖许可证",
fileSize: 1024 * 1024 * 2.5, // 2.5MB
uploaderId: "1",
uploaderName: "张三",
status: "completed",
reviewStatus: "pass",
createdAt: "2023-12-24 14:30",
updatedAt: "2023-12-24 16:45"
},
{
id: "2",
fileName: "烟草制品购销合同(2023-12).docx",
fileType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
documentTypeId: "1",
documentTypeName: "合同文档",
fileSize: 1024 * 1024 * 1.2, // 1.2MB
uploaderId: "1",
uploaderName: "张三",
status: "completed",
reviewStatus: "warning",
createdAt: "2023-12-23 09:15",
updatedAt: "2023-12-23 10:30"
},
{
id: "3",
fileName: "专卖管理处罚决定书(2023-145).pdf",
fileType: "application/pdf",
documentTypeId: "3",
documentTypeName: "行政处罚决定书",
fileSize: 1024 * 1024 * 3.1, // 3.1MB
uploaderId: "2",
uploaderName: "李四",
status: "completed",
reviewStatus: "fail",
createdAt: "2023-12-22 16:45",
updatedAt: "2023-12-22 18:20"
},
{
id: "4",
fileName: "2023年第四季度采购合同.docx",
fileType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
documentTypeId: "1",
documentTypeName: "合同文档",
fileSize: 1024 * 1024 * 1.8, // 1.8MB
uploaderId: "3",
uploaderName: "王五",
status: "completed",
reviewStatus: "pass",
createdAt: "2023-12-20 11:20",
updatedAt: "2023-12-20 14:35"
},
{
id: "5",
fileName: "广告宣传协议书.pdf",
fileType: "application/pdf",
documentTypeId: "1",
documentTypeName: "合同文档",
fileSize: 1024 * 1024 * 0.9, // 0.9MB
uploaderId: "2",
uploaderName: "李四",
status: "pending",
reviewStatus: "pending",
createdAt: "2023-12-18 15:30",
updatedAt: "2023-12-18 15:30"
}
];
const documentTypes = [
{ id: "1", name: "合同文档" },
{ id: "2", name: "专卖许可证" },
{ id: "3", name: "行政处罚决定书" },
{ id: "4", name: "其他文档" }
];
// 过滤数据
let filteredFiles = [...files];
if (documentTypeId) {
filteredFiles = filteredFiles.filter(file => file.documentTypeId === documentTypeId);
}
if (reviewStatus) {
filteredFiles = filteredFiles.filter(file => file.reviewStatus === reviewStatus);
}
if (keyword) {
const lowerKeyword = keyword.toLowerCase();
filteredFiles = filteredFiles.filter(file =>
file.fileName.toLowerCase().includes(lowerKeyword)
);
}
return json<LoaderData>({
files: filteredFiles,
documentTypes,
totalCount: files.length
});
}
export default function FilesList() {
const { files, documentTypes } = useLoaderData<typeof loader>();
const [searchParams] = useSearchParams();
// 文件大小格式化
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
};
// 获取文件图标
const getFileIcon = (fileType: string): string => {
if (fileType.includes('pdf')) return 'ri-file-pdf-line';
if (fileType.includes('word')) return 'ri-file-word-line';
if (fileType.includes('excel') || fileType.includes('spreadsheet')) return 'ri-file-excel-line';
if (fileType.includes('image')) return 'ri-file-image-line';
return 'ri-file-text-line';
};
return (
<div>
<Breadcrumb
items={[
{ title: '文件管理', to: '/files' },
{ title: '文件列表', to: '/files' }
]}
/>
<div className="flex justify-between items-center mb-4">
<div className="flex items-center">
<h2 className="text-xl font-medium"></h2>
<div className="flex items-center ml-4 bg-white px-3 py-1 rounded-md">
<i className="ri-file-list-3-line text-primary text-lg mr-1"></i>
<span className="text-sm text-secondary"></span>
<span className="text-base font-bold text-primary ml-1">{files.length}</span>
</div>
</div>
<Button type="primary" icon="ri-file-upload-line" to="/files/new">
</Button>
</div>
<Card className="mb-5">
<Form method="get" className="flex flex-wrap items-end gap-3">
<div className="w-48">
<label className="form-label"></label>
<select
name="documentTypeId"
className="form-select w-full"
defaultValue={searchParams.get('documentTypeId') || ''}
>
<option value=""></option>
{documentTypes.map(type => (
<option key={type.id} value={type.id}>{type.name}</option>
))}
</select>
</div>
<div className="w-48">
<label className="form-label"></label>
<select
name="reviewStatus"
className="form-select w-full"
defaultValue={searchParams.get('reviewStatus') || ''}
>
<option value=""></option>
{Object.entries(REVIEW_STATUS_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>
<div className="w-64">
<label className="form-label"></label>
<div className="search-box">
<input
type="text"
name="keyword"
className="form-input"
placeholder="搜索文件名称"
defaultValue={searchParams.get('keyword') || ''}
/>
<button type="submit" className="ant-btn ant-btn-primary">
<i className="ri-search-line"></i>
</button>
</div>
</div>
<Button type="default" className="ml-2"></Button>
</Form>
</Card>
<Table
columns={[
{
title: "文件名称",
render: (_, record: File) => (
<div className="flex items-center">
<i className={`${getFileIcon(record.fileType)} text-lg text-gray-500 mr-2`}></i>
<div>
<div className="font-medium">{record.fileName}</div>
<div className="text-xs text-gray-500">{formatFileSize(record.fileSize)}</div>
</div>
</div>
)
},
{ title: "文档类型", dataIndex: "documentTypeName" },
{
title: "评查状态",
dataIndex: "reviewStatus",
render: (value) => (
<span className={`status-badge status-${REVIEW_STATUS_COLORS[value]}`}>
<i className={`ri-${value === 'pass' ? 'checkbox-circle' : value === 'warning' ? 'error-warning' : value === 'fail' ? 'close-circle' : 'time'}-line mr-1`}></i>
{REVIEW_STATUS_LABELS[value]}
</span>
)
},
{
title: "上传人",
dataIndex: "uploaderName"
},
{
title: "上传时间",
dataIndex: "createdAt"
},
{
title: "操作",
render: (_, record: File) => (
<div className="space-x-2">
<Button
type="default"
size="small"
icon="ri-file-search-line"
to={`/reviews/${record.id}`}
>
</Button>
{record.status === 'pending' && (
<Button
type="primary"
size="small"
icon="ri-play-circle-line"
>
</Button>
)}
<Button
type="danger"
size="small"
icon="ri-delete-bin-line"
>
</Button>
</div>
)
}
]}
dataSource={files}
rowKey="id"
/>
</div>
);
}
-34
View File
@@ -1,34 +0,0 @@
import { MetaFunction } from '@remix-run/node';
import { Card } from '~/components/ui/Card';
import { Button } from '~/components/ui/Button';
export const meta: MetaFunction = () => {
return [
{ title: "文件上传 - 中国烟草AI合同及卷宗审核系统" },
{ name: "description", content: "上传文件进行智能评查" }
];
};
export default function FilesNew() {
return (
<div className="p-6">
{/* 页面标识 */}
<div className="mb-4 p-3 bg-blue-100 border border-blue-300 rounded text-blue-800">
<h3 className="font-bold text-lg">当前页面: 文件上传 (files/new.tsx)</h3>
<p></p>
<div className="mt-2">
<a href="/" className="text-blue-600 hover:underline"></a>
</div>
</div>
<Card title="文件上传" icon="ri-upload-cloud-line" className="mt-6">
<div className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-gray-300 rounded-lg bg-gray-50">
<i className="ri-upload-cloud-line text-5xl text-gray-400 mb-4"></i>
<p className="text-lg mb-4"></p>
<Button type="primary" icon="ri-upload-line"></Button>
<p className="text-gray-500 mt-3"> PDFDOCDOCXXLSXLSX </p>
</div>
</Card>
</div>
);
}
-414
View File
@@ -1,414 +0,0 @@
import React, { useState } from 'react';
import { json, type MetaFunction } from '@remix-run/node';
import { useLoaderData, useParams } from '@remix-run/react';
import { Button } from '~/components/ui/Button';
import { Card } from '~/components/ui/Card';
import { Breadcrumb } from '~/components/layout/Breadcrumb';
import type { ReviewResult, RuleCheckResult } from '~/models/review';
import type { File } from '~/models/file';
import { RULE_CHECK_STATUS_LABELS, RULE_CHECK_STATUS_COLORS } from '~/models/review';
export const meta: MetaFunction = () => {
return [
{ title: "中国烟草AI合同及卷宗审核系统 - 评查详情" },
{ name: "description", content: "文件评查详情页面" }
];
};
export const handle = {
breadcrumb: '评查详情'
};
interface LoaderData {
file: File;
reviewResult: ReviewResult;
reviewPoints: RuleCheckResult[];
fileContent?: string; // 模拟文件内容
}
export async function loader({ params }) {
const { reviewId } = params;
// 模拟数据,实际项目中应从API获取
const file: File = {
id: "1",
fileName: "2023年度烟草专卖零售许可证.pdf",
fileType: "application/pdf",
documentTypeId: "2",
documentTypeName: "专卖许可证",
fileSize: 1024 * 1024 * 2.5, // 2.5MB
uploaderId: "1",
uploaderName: "张三",
status: "completed",
reviewStatus: "pass",
createdAt: "2023-12-24 14:30",
updatedAt: "2023-12-24 16:45"
};
const reviewResult: ReviewResult = {
id: reviewId,
fileId: "1",
fileName: "2023年度烟草专卖零售许可证.pdf",
totalPoints: 15,
passPoints: 6,
warningPoints: 7,
errorPoints: 2,
score: 80,
reviewStatus: "warning",
reviewedAt: "2023-12-24 16:45",
reviewerId: "system",
reviewerName: "AI系统",
createdAt: "2023-12-24 14:35",
updatedAt: "2023-12-24 16:45"
};
const reviewPoints: RuleCheckResult[] = [
{
id: "1",
reviewResultId: reviewId,
ruleId: "1",
ruleName: "合同主体信息完整性检查",
status: "pass",
location: "第1页 第3段",
content: "甲方:XX烟草公司,地址:XX市XX区XX路XX号,法定代表人:张XX",
suggestion: "主体信息完整,符合规范",
manualReviewed: false,
createdAt: "2023-12-24 14:40",
updatedAt: "2023-12-24 14:40"
},
{
id: "2",
reviewResultId: reviewId,
ruleId: "2",
ruleName: "许可证编号格式检查",
status: "warning",
location: "第1页 第5段",
content: "许可证编号:(2023)12345",
suggestion: "许可证编号格式不完全符合规范,建议修改为'烟零许(2023)12345号'",
manualReviewed: true,
createdAt: "2023-12-24 14:40",
updatedAt: "2023-12-24 15:20"
},
{
id: "3",
reviewResultId: reviewId,
ruleId: "3",
ruleName: "许可证有效期检查",
status: "fail",
location: "第1页 第8段",
content: "有效期:自2023年1月1日",
suggestion: "许可证缺少有效期截止日期,必须明确注明有效期限",
manualReviewed: false,
createdAt: "2023-12-24 14:40",
updatedAt: "2023-12-24 14:40"
},
{
id: "4",
reviewResultId: reviewId,
ruleId: "4",
ruleName: "经营场所信息检查",
status: "pass",
location: "第1页 第12段",
content: "经营场所:XX市XX区XX街XX号,面积:120平方米",
suggestion: "经营场所信息完整",
manualReviewed: false,
createdAt: "2023-12-24 14:40",
updatedAt: "2023-12-24 14:40"
}
];
// 模拟文件内容,实际项目中应从API获取或使用专用组件展示
const fileContent = `烟草专卖零售许可证
发证机关:XX市烟草专卖局
发证日期:2023年1月1日
甲方:XX烟草公司,地址:XX市XX区XX路XX号,法定代表人:张XX
零售单位名称:XX便利店
许可证编号:(2023)12345
法定代表人/负责人:李XX
经营者类型:个体工商户
有效期:自2023年1月1日
联系电话:123-4567890
经营场所:XX市XX区XX街XX号,面积:120平方米
零售烟草制品品种:卷烟、雪茄烟
特别说明:本许可证不得伪造、变造、转让、涂改。
`;
return json<LoaderData>({
file,
reviewResult,
reviewPoints,
fileContent
});
}
export default function ReviewDetail() {
const { file, reviewResult, reviewPoints, fileContent } = useLoaderData<typeof loader>();
const [activeTab, setActiveTab] = useState('tab-preview');
const [selectedPoint, setSelectedPoint] = useState<string | null>(null);
const handleTabChange = (tabId: string) => {
setActiveTab(tabId);
};
const handlePointSelect = (pointId: string) => {
setSelectedPoint(pointId === selectedPoint ? null : pointId);
};
return (
<div>
<Breadcrumb
items={[
{ title: '评查结果', to: '/reviews' },
{ title: '评查详情', to: `/reviews/${reviewResult.id}` }
]}
/>
<div className="flex justify-between items-center mb-4">
<div className="flex items-center">
<h2 className="text-xl font-medium">{file.fileName}</h2>
<span className={`ml-3 status-badge status-${reviewResult.reviewStatus === 'pass' ? 'success' : reviewResult.reviewStatus === 'warning' ? 'warning' : 'error'}`}>
<i className={`ri-${reviewResult.reviewStatus === 'pass' ? 'checkbox-circle' : reviewResult.reviewStatus === 'warning' ? 'error-warning' : 'close-circle'}-line mr-1`}></i>
{reviewResult.reviewStatus === 'pass' ? '通过' : reviewResult.reviewStatus === 'warning' ? '警告' : '不通过'}
</span>
</div>
<div className="space-x-2">
<Button type="default" icon="ri-download-line">
</Button>
<Button type="primary" icon="ri-check-double-line">
</Button>
</div>
</div>
<div className="tab-container">
<div className="tab-nav">
<div
className={`tab-nav-item ${activeTab === 'tab-preview' ? 'active' : ''}`}
onClick={() => handleTabChange('tab-preview')}
>
<i className="ri-file-text-line"></i>
</div>
<div
className={`tab-nav-item ${activeTab === 'tab-suggestion' ? 'active' : ''}`}
onClick={() => handleTabChange('tab-suggestion')}
>
<i className="ri-lightbulb-line"></i> AI智能分析
</div>
<div
className={`tab-nav-item ${activeTab === 'tab-fileinfo' ? 'active' : ''}`}
onClick={() => handleTabChange('tab-fileinfo')}
>
<i className="ri-information-line"></i>
</div>
</div>
<div className="tab-content">
<div className={`tab-pane ${activeTab === 'tab-preview' ? 'active' : ''}`}>
<div className="flex flex-col lg:flex-row lg:h-[calc(100vh-250px)]">
{/* 文件内容预览 */}
<div className="w-full lg:w-2/3 h-full mb-4 lg:mb-0 lg:pr-4">
<div className="bg-white p-4 rounded-md shadow-sm h-full overflow-y-auto">
<pre className="whitespace-pre-wrap font-sans text-gray-800">
{fileContent}
</pre>
</div>
</div>
{/* 评查点列表 */}
<div className="w-full lg:w-1/3 h-full lg:pl-4">
<div className="review-points-panel h-full flex flex-col">
<div className="review-panel-header py-2 px-4 flex items-center bg-primary-light">
<i className="ri-file-list-check-line text-primary mr-2"></i>
<span className="font-medium text-primary"></span>
</div>
{/* 评查统计 */}
<div className="review-statistics bg-white border-b border-gray-100 py-3 px-4">
<div className="flex justify-between items-center">
<div className="flex items-center">
<div className="w-7 h-7 bg-gray-100 rounded-md flex items-center justify-center">
<span className="text-sm font-semibold text-gray-600">{reviewResult.totalPoints}</span>
</div>
<span className="text-xs text-gray-500 ml-1"></span>
</div>
<div className="h-8 border-r border-gray-200"></div>
<div className="flex items-center">
<div className="w-7 h-7 bg-green-50 rounded-md flex items-center justify-center">
<span className="text-sm font-semibold text-success">{reviewResult.passPoints}</span>
</div>
<span className="text-xs text-gray-500 ml-1"></span>
</div>
<div className="h-8 border-r border-gray-200"></div>
<div className="flex items-center">
<div className="w-7 h-7 bg-yellow-50 rounded-md flex items-center justify-center">
<span className="text-sm font-semibold text-warning">{reviewResult.warningPoints}</span>
</div>
<span className="text-xs text-gray-500 ml-1"></span>
</div>
<div className="h-8 border-r border-gray-200"></div>
<div className="flex items-center">
<div className="w-7 h-7 bg-red-50 rounded-md flex items-center justify-center">
<span className="text-sm font-semibold text-error">{reviewResult.errorPoints}</span>
</div>
<span className="text-xs text-gray-500 ml-1"></span>
</div>
</div>
</div>
{/* 评查点列表 */}
<div className="flex-1 overflow-y-auto">
{reviewPoints.map(point => (
<div
key={point.id}
className={`review-point-item ${selectedPoint === point.id ? 'bg-gray-50' : ''}`}
onClick={() => handlePointSelect(point.id)}
>
<div className="review-point-header">
<div className="review-point-title">{point.ruleName}</div>
<span className={`status-badge status-${RULE_CHECK_STATUS_COLORS[point.status]}`}>
<i className={`ri-${point.status === 'pass' ? 'checkbox-circle' : point.status === 'warning' ? 'error-warning' : 'close-circle'}-line mr-1`}></i>
{RULE_CHECK_STATUS_LABELS[point.status]}
</span>
</div>
<div className="review-point-location">
<i className="ri-file-list-line mr-1"></i>
<span>{point.location}</span>
</div>
{selectedPoint === point.id && (
<div className="mt-2 pt-2 border-t border-gray-100">
<div className="text-xs text-gray-600 mb-1">
<span className="font-medium"></span>
<span>{point.content}</span>
</div>
<div className="text-xs text-gray-600">
<span className="font-medium"></span>
<span>{point.suggestion}</span>
</div>
<div className="mt-2 flex justify-between">
<div className="text-xs text-gray-500">
{point.manualReviewed &&
<span><i className="ri-user-line mr-1"></i></span>
}
</div>
<div>
<Button type="default" size="small">
<i className="ri-edit-line mr-1"></i>
</Button>
</div>
</div>
</div>
)}
</div>
))}
</div>
</div>
</div>
</div>
</div>
<div className={`tab-pane ${activeTab === 'tab-suggestion' ? 'active' : ''}`}>
<Card>
<div className="text-lg font-medium mb-4 text-gray-800">AI智能分析意见</div>
<div className="mb-6">
<div className="font-medium text-gray-700 mb-2"></div>
<div className="p-3 bg-gray-50 rounded-md text-gray-600">
</div>
</div>
<div className="mb-6">
<div className="font-medium text-gray-700 mb-2"></div>
<ul className="list-disc pl-5 space-y-2 text-gray-600">
<li><span className="text-warning font-medium"></span> - "(2023)12345""烟零许(2023)12345号"</li>
<li><span className="text-error font-medium"></span> - "自2023年1月1日"</li>
</ul>
</div>
<div>
<div className="font-medium text-gray-700 mb-2"></div>
<ul className="list-decimal pl-5 space-y-2 text-gray-600">
<li>"烟零许""号"</li>
<li>"自2023年1月1日至2023年12月31日"</li>
<li></li>
</ul>
</div>
</Card>
</div>
<div className={`tab-pane ${activeTab === 'tab-fileinfo' ? 'active' : ''}`}>
<Card>
<div className="text-lg font-medium mb-4 text-gray-800"></div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<table className="w-full">
<tbody>
<tr className="border-b border-gray-100">
<td className="py-2 text-gray-500 w-1/3"></td>
<td className="py-2 text-gray-800">{file.fileName}</td>
</tr>
<tr className="border-b border-gray-100">
<td className="py-2 text-gray-500"></td>
<td className="py-2 text-gray-800">{file.documentTypeName}</td>
</tr>
<tr className="border-b border-gray-100">
<td className="py-2 text-gray-500"></td>
<td className="py-2 text-gray-800">{(file.fileSize / (1024 * 1024)).toFixed(2)} MB</td>
</tr>
<tr className="border-b border-gray-100">
<td className="py-2 text-gray-500"></td>
<td className="py-2 text-gray-800">{file.uploaderName}</td>
</tr>
<tr>
<td className="py-2 text-gray-500"></td>
<td className="py-2 text-gray-800">{file.createdAt}</td>
</tr>
</tbody>
</table>
</div>
<div>
<table className="w-full">
<tbody>
<tr className="border-b border-gray-100">
<td className="py-2 text-gray-500 w-1/3"></td>
<td className="py-2 text-gray-800">
<span className={`status-badge status-${reviewResult.reviewStatus === 'pass' ? 'success' : reviewResult.reviewStatus === 'warning' ? 'warning' : 'error'}`}>
<i className={`ri-${reviewResult.reviewStatus === 'pass' ? 'checkbox-circle' : reviewResult.reviewStatus === 'warning' ? 'error-warning' : 'close-circle'}-line mr-1`}></i>
{reviewResult.reviewStatus === 'pass' ? '通过' : reviewResult.reviewStatus === 'warning' ? '警告' : '不通过'}
</span>
</td>
</tr>
<tr className="border-b border-gray-100">
<td className="py-2 text-gray-500"></td>
<td className="py-2 text-gray-800">{reviewResult.score} </td>
</tr>
<tr className="border-b border-gray-100">
<td className="py-2 text-gray-500"></td>
<td className="py-2 text-gray-800">{reviewResult.reviewedAt}</td>
</tr>
<tr className="border-b border-gray-100">
<td className="py-2 text-gray-500"></td>
<td className="py-2 text-gray-800">{reviewResult.reviewerName}</td>
</tr>
<tr>
<td className="py-2 text-gray-500"></td>
<td className="py-2 text-gray-800">{reviewResult.totalPoints} </td>
</tr>
</tbody>
</table>
</div>
</div>
</Card>
</div>
</div>
</div>
</div>
);
}
-328
View File
@@ -1,328 +0,0 @@
import React from 'react';
import { json, type MetaFunction } from '@remix-run/node';
import { useLoaderData, Link } from '@remix-run/react';
import { Button } from '~/components/ui/Button';
import { Card } from '~/components/ui/Card';
import { Table } from '~/components/ui/Table';
import type { ReviewResult } from '~/models/review';
export const meta: MetaFunction = () => {
return [
{ title: "中国烟草AI合同及卷宗审核系统 - 评查结果" },
{ name: "description", content: "文件评查结果列表" }
];
};
export const handle = {
breadcrumb: '评查结果'
};
interface LoaderData {
reviews: ReviewResult[];
totalCount: number;
currentPage: number;
totalPages: number;
}
export async function loader({ request }) {
// 解析查询参数
const url = new URL(request.url);
const keyword = url.searchParams.get("keyword") || "";
const status = url.searchParams.get("status") || "";
const startDate = url.searchParams.get("startDate") || "";
const endDate = url.searchParams.get("endDate") || "";
const page = parseInt(url.searchParams.get("page") || "1", 10);
const pageSize = parseInt(url.searchParams.get("pageSize") || "10", 10);
// 模拟数据,实际项目中应从API获取
const reviews: ReviewResult[] = [
{
id: "1",
fileId: "1",
fileName: "2023年度烟草专卖零售许可证.pdf",
totalPoints: 15,
passPoints: 11,
warningPoints: 3,
errorPoints: 1,
score: 85,
reviewStatus: "warning",
reviewedAt: "2023-12-24 16:45",
reviewerId: "system",
reviewerName: "AI系统",
createdAt: "2023-12-24 14:35",
updatedAt: "2023-12-24 16:45"
},
{
id: "2",
fileId: "2",
fileName: "烟草零售合同协议书.docx",
totalPoints: 20,
passPoints: 18,
warningPoints: 2,
errorPoints: 0,
score: 92,
reviewStatus: "pass",
reviewedAt: "2023-12-23 10:30",
reviewerId: "user1",
reviewerName: "李四",
createdAt: "2023-12-23 09:15",
updatedAt: "2023-12-23 10:30"
},
{
id: "3",
fileId: "3",
fileName: "烟草采购清单2023.xlsx",
totalPoints: 12,
passPoints: 5,
warningPoints: 3,
errorPoints: 4,
score: 60,
reviewStatus: "fail",
reviewedAt: "2023-12-22 18:20",
reviewerId: "system",
reviewerName: "AI系统",
createdAt: "2023-12-22 17:45",
updatedAt: "2023-12-22 18:20"
},
{
id: "4",
fileId: "4",
fileName: "2023年第三季度烟草销售报告.pdf",
totalPoints: 18,
passPoints: 16,
warningPoints: 2,
errorPoints: 0,
score: 94,
reviewStatus: "pass",
reviewedAt: "2023-12-21 14:10",
reviewerId: "user2",
reviewerName: "王五",
createdAt: "2023-12-21 13:30",
updatedAt: "2023-12-21 14:10"
},
{
id: "5",
fileId: "5",
fileName: "烟草品牌授权书.pdf",
totalPoints: 10,
passPoints: 6,
warningPoints: 3,
errorPoints: 1,
score: 75,
reviewStatus: "warning",
reviewedAt: "2023-12-20 11:25",
reviewerId: "system",
reviewerName: "AI系统",
createdAt: "2023-12-20 10:50",
updatedAt: "2023-12-20 11:25"
}
];
// 根据查询条件过滤结果
let filteredReviews = [...reviews];
if (keyword) {
filteredReviews = filteredReviews.filter(review =>
review.fileName.toLowerCase().includes(keyword.toLowerCase())
);
}
if (status) {
filteredReviews = filteredReviews.filter(review =>
review.reviewStatus === status
);
}
if (startDate) {
const start = new Date(startDate);
filteredReviews = filteredReviews.filter(review =>
new Date(review.reviewedAt) >= start
);
}
if (endDate) {
const end = new Date(endDate);
end.setHours(23, 59, 59, 999);
filteredReviews = filteredReviews.filter(review =>
new Date(review.reviewedAt) <= end
);
}
// 分页
const totalCount = filteredReviews.length;
const totalPages = Math.ceil(totalCount / pageSize);
const startIndex = (page - 1) * pageSize;
const pagedReviews = filteredReviews.slice(startIndex, startIndex + pageSize);
return json<LoaderData>({
reviews: pagedReviews,
totalCount,
currentPage: page,
totalPages
});
}
export default function ReviewsList() {
const { reviews, totalCount, currentPage, totalPages } = useLoaderData<typeof loader>();
const columns = [
{
title: "文件名称",
key: "fileName",
render: (review: ReviewResult) => (
<Link
to={`/reviews/${review.id}`}
className="text-primary hover:text-primary-dark transition-colors"
>
{review.fileName}
</Link>
)
},
{
title: "评查状态",
key: "reviewStatus",
render: (review: ReviewResult) => (
<span className={`status-badge status-${review.reviewStatus === 'pass' ? 'success' : review.reviewStatus === 'warning' ? 'warning' : 'error'}`}>
<i className={`ri-${review.reviewStatus === 'pass' ? 'checkbox-circle' : review.reviewStatus === 'warning' ? 'error-warning' : 'close-circle'}-line mr-1`}></i>
{review.reviewStatus === 'pass' ? '通过' : review.reviewStatus === 'warning' ? '警告' : '不通过'}
</span>
)
},
{
title: "评查得分",
key: "score",
render: (review: ReviewResult) => (
<span className={`font-medium ${review.score >= 90 ? 'text-success' : review.score >= 70 ? 'text-warning' : 'text-error'}`}>
{review.score}
</span>
)
},
{
title: "评查点",
key: "points",
render: (review: ReviewResult) => (
<div className="flex items-center space-x-2">
<span className="text-xs px-2 py-1 bg-gray-100 rounded-full">{review.totalPoints}</span>
<span className="text-xs px-2 py-1 bg-green-50 text-success rounded-full">{review.passPoints}</span>
<span className="text-xs px-2 py-1 bg-yellow-50 text-warning rounded-full">{review.warningPoints}</span>
<span className="text-xs px-2 py-1 bg-red-50 text-error rounded-full">{review.errorPoints}</span>
</div>
)
},
{
title: "评查时间",
key: "reviewedAt",
render: (review: ReviewResult) => review.reviewedAt
},
{
title: "评查人",
key: "reviewerName",
render: (review: ReviewResult) => (
<span className="flex items-center">
<i className={`ri-${review.reviewerId === 'system' ? 'robot-line' : 'user-line'} mr-1 ${review.reviewerId === 'system' ? 'text-primary' : 'text-gray-600'}`}></i>
{review.reviewerName}
</span>
)
},
{
title: "操作",
key: "actions",
render: (review: ReviewResult) => (
<div className="space-x-2">
<Link
to={`/reviews/${review.id}`}
className="btn-text"
>
<i className="ri-search-line mr-1"></i>
</Link>
<button className="btn-text">
<i className="ri-download-line mr-1"></i>
</button>
</div>
)
}
];
return (
<div>
<div className="mb-4 flex justify-between items-center">
<h2 className="text-xl font-medium"></h2>
<Link to="/files/upload" className="btn-primary">
<i className="ri-upload-cloud-line mr-1"></i>
</Link>
</div>
<Card className="mb-4">
<form className="filter-form">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="form-group">
<label htmlFor="keyword" className="form-label"></label>
<div className="relative">
<input
type="text"
id="keyword"
name="keyword"
className="form-input pl-8"
placeholder="文件名称"
/>
<i className="ri-search-line absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"></i>
</div>
</div>
<div className="form-group">
<label htmlFor="status" className="form-label"></label>
<select id="status" name="status" className="form-select">
<option value=""></option>
<option value="pass"></option>
<option value="warning"></option>
<option value="fail"></option>
</select>
</div>
<div className="form-group">
<label htmlFor="startDate" className="form-label"></label>
<input type="date" id="startDate" name="startDate" className="form-input" />
</div>
<div className="form-group">
<label htmlFor="endDate" className="form-label"></label>
<input type="date" id="endDate" name="endDate" className="form-input" />
</div>
</div>
<div className="flex justify-end mt-4">
<button type="reset" className="btn-default mr-2">
<i className="ri-refresh-line mr-1"></i>
</button>
<button type="submit" className="btn-primary">
<i className="ri-search-line mr-1"></i>
</button>
</div>
</form>
</Card>
<Card>
<div className="mb-3 text-gray-500">
<span className="text-primary">{totalCount}</span>
</div>
<Table
columns={columns}
dataSource={reviews}
rowKey="id"
pagination={{
current: currentPage,
pageSize: 10,
total: totalCount,
totalPages: totalPages
}}
/>
</Card>
</div>
);
}
+206 -174
View File
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { json, type MetaFunction, type LoaderFunctionArgs, redirect } from "@remix-run/node";
import { useLoaderData, useSearchParams, useSubmit } from "@remix-run/react";
import { useLoaderData, useSearchParams, useSubmit,Link } from "@remix-run/react";
import { Button } from '~/components/ui/Button';
import { Card } from '~/components/ui/Card';
import { Tag } from '~/components/ui/Tag';
@@ -9,219 +9,245 @@ import rulesStyles from "~/styles/pages/rules_index.css?url";
import type { Rule } from '~/models/rule';
import { RULE_TYPE_LABELS, RULE_TYPE_COLORS, RULE_PRIORITY_LABELS, RULE_PRIORITY_COLORS } from '~/models/rule';
import type { TagColor } from '~/components/ui/Tag';
import { Link } from '@remix-run/react';
import { Table } from '~/components/ui/Table';
import { FilterPanel, FilterSelect, SearchFilter } from '~/components/ui/FilterPanel';
import { Pagination } from '~/components/ui/Pagination';
// import { getRulesList } from '~/api/evaluation_points/rules';
export const links = () => [
{ rel: "stylesheet", href: rulesStyles }
];
// export const handle = {
// breadcrumb: "评查点列表"
// };
export const meta: MetaFunction = () => {
return [
{ title: "中国烟草AI合同及卷宗审核系统 - 评查点列表" },
{ name: "description", content: "管理评查点规则,支持根据类型、规则组和状态进行筛选" },
{ name: "rules", content: "管理评查点规则,支持根据类型、规则组和状态进行筛选" },
{ name: "keywords", content: "评查点,合同审核,规则管理,中国烟草" }
];
};
interface LoaderData {
rules: Rule[];
groups: {
id: string;
name: string;
}[];
totalCount: number;
currentPage: number;
pageSize: number;
totalPages: number;
}
// 模拟数据 - 用于开发阶段展示UI
const mockRules: Rule[] = [
{
id: '1',
code: 'EP001',
name: '合同名称要素检查',
ruleType: 'essential',
ruleGroupId: '1',
groupName: '合同基本要素类检查',
priority: 'high',
description: '检查合同是否包含清晰的合同名称',
checkMethod: 'automatic',
prompt: '查找文档中的合同名称',
isActive: true,
createdAt: '2024-03-15T08:30:00Z',
updatedAt: '2024-03-15T08:30:00Z'
},
{
id: '2',
code: 'EP002',
name: '合同编号要素检查',
ruleType: 'essential',
ruleGroupId: '1',
groupName: '合同基本要素类检查',
priority: 'high',
description: '检查合同是否包含唯一的合同编号',
checkMethod: 'automatic',
prompt: '查找文档中的合同编号',
isActive: true,
createdAt: '2024-03-15T09:15:00Z',
updatedAt: '2024-03-15T09:15:00Z'
},
{
id: '3',
code: 'EP003',
name: '合同主体资格检查',
ruleType: 'legal',
ruleGroupId: '2',
groupName: '销售合同专项检查',
priority: 'medium',
description: '检查合同签署方是否具有合法的主体资格',
checkMethod: 'manual',
prompt: '确认合同签署方的法律主体资格',
isActive: true,
createdAt: '2024-03-16T10:20:00Z',
updatedAt: '2024-03-16T10:20:00Z'
},
{
id: '4',
code: 'EP004',
name: '付款条件检查',
ruleType: 'content',
ruleGroupId: '2',
groupName: '销售合同专项检查',
priority: 'medium',
description: '检查合同中的付款条件是否明确',
checkMethod: 'automatic',
prompt: '提取文档中的付款条件相关内容',
isActive: true,
createdAt: '2024-03-17T11:30:00Z',
updatedAt: '2024-03-17T11:30:00Z'
},
{
id: '5',
code: 'EP005',
name: '违约责任条款检查',
ruleType: 'legal',
ruleGroupId: '3',
groupName: '采购合同专项检查',
priority: 'high',
description: '检查合同是否包含违约责任条款',
checkMethod: 'mixed',
prompt: '提取文档中的违约责任相关条款',
isActive: true,
createdAt: '2024-03-18T13:45:00Z',
updatedAt: '2024-03-18T13:45:00Z'
},
{
id: '6',
code: 'EP006',
name: '合同文本格式检查',
ruleType: 'format',
ruleGroupId: '1',
groupName: '合同基本要素类检查',
priority: 'low',
description: '检查合同文本格式是否符合规范',
checkMethod: 'automatic',
prompt: '检查文档的整体格式规范性',
isActive: false,
createdAt: '2024-03-19T14:50:00Z',
updatedAt: '2024-03-19T14:50:00Z'
},
{
id: '7',
code: 'EP007',
name: '专卖许可证有效性检查',
ruleType: 'legal',
ruleGroupId: '4',
groupName: '专卖许可证审核规则',
priority: 'high',
description: '检查专卖许可证是否在有效期内',
checkMethod: 'automatic',
prompt: '提取专卖许可证有效期信息并判断有效性',
isActive: true,
createdAt: '2024-03-20T15:55:00Z',
updatedAt: '2024-03-20T15:55:00Z'
},
{
id: '8',
code: 'EP008',
name: '处罚决定书格式检查',
ruleType: 'format',
ruleGroupId: '5',
groupName: '行政处罚规范性检查',
priority: 'medium',
description: '检查行政处罚决定书格式是否规范',
checkMethod: 'automatic',
prompt: '检查处罚决定书的格式规范性',
isActive: true,
createdAt: '2024-03-21T16:00:00Z',
updatedAt: '2024-03-21T16:00:00Z'
},
{
id: '9',
code: 'EP009',
name: '处罚依据合法性检查',
ruleType: 'legal',
ruleGroupId: '5',
groupName: '行政处罚规范性检查',
priority: 'high',
description: '检查行政处罚依据是否合法',
checkMethod: 'manual',
prompt: '审核处罚依据的法律合法性',
isActive: true,
createdAt: '2024-03-22T09:10:00Z',
updatedAt: '2024-03-22T09:10:00Z'
},
{
id: '10',
code: 'EP010',
name: '业务特殊条款检查',
ruleType: 'business',
ruleGroupId: '3',
groupName: '采购合同专项检查',
priority: 'medium',
description: '检查合同是否包含烟草行业特殊条款',
checkMethod: 'mixed',
prompt: '识别文档中的烟草行业特殊要求条款',
isActive: true,
createdAt: '2024-03-23T10:15:00Z',
updatedAt: '2024-03-23T10:15:00Z'
}
];
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const ruleType = url.searchParams.get("ruleType") || "";
const groupId = url.searchParams.get("groupId") || "";
const isActive = url.searchParams.get("isActive") || "";
const keyword = url.searchParams.get("keyword") || "";
const currentPage = parseInt(url.searchParams.get("page") || "1", 10);
const pageSize = parseInt(url.searchParams.get("pageSize") || "10", 10);
// 从 URL 参数中提取查询条件
const params = {
ruleType: url.searchParams.get("ruleType") || undefined,
groupId: url.searchParams.get("groupId") || undefined,
isActive: url.searchParams.get("isActive") ? url.searchParams.get("isActive") === "true" : undefined,
keyword: url.searchParams.get("keyword") || undefined,
page: parseInt(url.searchParams.get("page") || "1", 10),
pageSize: parseInt(url.searchParams.get("pageSize") || "10", 10)
};
try {
// 模拟数据,实际项目中应从API获取
const rules: Rule[] = [
{
id: "1",
code: "CP001",
name: "合同主体信息完整性检查",
ruleGroupId: "1",
groupName: "合同基本要素检查",
ruleType: "essential",
priority: "high",
description: "检查合同中是否完整包含签约方的基本信息,包括名称、地址、法定代表人等",
checkMethod: "automatic",
prompt: "检查合同主体双方信息是否完整,包括企业名称、注册地址、法定代表人或授权代表、联系方式等",
isActive: true,
createdAt: "2023-06-15 10:30",
updatedAt: "2023-06-15 10:30"
},
{
id: "2",
code: "CP002",
name: "合同金额一致性校验",
ruleGroupId: "1",
groupName: "合同基本要素检查",
ruleType: "content",
priority: "high",
description: "检查合同大小写金额是否一致",
checkMethod: "automatic",
prompt: "检查合同中的金额大写和小写表示是否一致,如¥10,000.00(壹万元整)",
isActive: true,
createdAt: "2023-06-20 14:15",
updatedAt: "2023-06-20 14:15"
},
{
id: "3",
code: "CP003",
name: "保密条款合规性审核",
ruleGroupId: "1",
groupName: "合同基本要素检查",
ruleType: "legal",
priority: "medium",
description: "检查合同是否包含保密条款并符合行业要求",
checkMethod: "mixed",
prompt: "检查合同中的保密条款是否完整、清晰,包含保密范围、期限、违约责任等",
isActive: true,
createdAt: "2023-07-05 09:45",
updatedAt: "2023-07-05 09:45"
},
{
id: "4",
code: "CP004",
name: "合同签约日期格式检查",
ruleGroupId: "1",
groupName: "合同基本要素检查",
ruleType: "format",
priority: "low",
description: "检查合同签约日期格式是否规范",
checkMethod: "automatic",
prompt: "检查合同签约日期格式是否符合YYYY年MM月DD日的规范格式",
isActive: false,
createdAt: "2023-07-10 16:20",
updatedAt: "2023-07-10 16:20"
},
{
id: "5",
code: "CP005",
name: "违约责任条款完整性检查",
ruleGroupId: "2",
groupName: "销售合同专项检查",
ruleType: "legal",
priority: "high",
description: "检查合同违约责任条款是否明确、完整",
checkMethod: "mixed",
prompt: "检查合同中的违约责任条款是否包含违约情形、违约金计算方式、责任承担方式等内容",
isActive: true,
createdAt: "2023-07-15 11:30",
updatedAt: "2023-07-15 11:30"
},
{
id: "6",
code: "CP006",
name: "交货期限有效性检查",
ruleGroupId: "2",
groupName: "销售合同专项检查",
ruleType: "business",
priority: "medium",
description: "检查合同中交货期限是否明确、合理",
checkMethod: "automatic",
prompt: "检查合同中是否明确约定了交货期限,并且期限设置是否合理",
isActive: true,
createdAt: "2023-08-01 14:40",
updatedAt: "2023-08-01 14:40"
},
{
id: "7",
code: "CP007",
name: "合同条款矛盾性检查",
ruleGroupId: "3",
groupName: "采购合同专项检查",
ruleType: "legal",
priority: "high",
description: "检查合同条款之间是否存在矛盾或冲突",
checkMethod: "mixed",
prompt: "分析合同各条款,检查是否存在相互矛盾或冲突的内容",
isActive: true,
createdAt: "2023-08-10 09:15",
updatedAt: "2023-08-10 09:15"
}
];
// 使用模拟数据而不是API调用
// const response = await getRulesList(params);
const groups = [
{ id: "1", name: "合同基本要素检查" },
{ id: "2", name: "销售合同专项检查" },
{ id: "3", name: "采购合同专项检查" },
{ id: "4", name: "专卖许可证审核规则" },
{ id: "5", name: "行政处罚规范性检查" }
];
// 过滤模拟数据
let filteredRules = [...mockRules];
// 过滤数据
let filteredRules = [...rules];
if (ruleType) {
filteredRules = filteredRules.filter(rule => rule.ruleType === ruleType);
if (params.ruleType) {
filteredRules = filteredRules.filter(rule => rule.ruleType === params.ruleType);
}
if (groupId) {
filteredRules = filteredRules.filter(rule => rule.ruleGroupId === groupId);
if (params.groupId) {
filteredRules = filteredRules.filter(rule => rule.ruleGroupId === params.groupId);
}
if (isActive) {
const activeValue = isActive === 'true';
filteredRules = filteredRules.filter(rule => rule.isActive === activeValue);
if (params.isActive !== undefined) {
filteredRules = filteredRules.filter(rule => rule.isActive === params.isActive);
}
if (keyword) {
const lowerKeyword = keyword.toLowerCase();
filteredRules = filteredRules.filter(rule =>
rule.name.toLowerCase().includes(lowerKeyword) ||
rule.code.toLowerCase().includes(lowerKeyword)
if (params.keyword) {
const keyword = params.keyword.toLowerCase();
filteredRules = filteredRules.filter(
rule => rule.name.toLowerCase().includes(keyword) ||
rule.code.toLowerCase().includes(keyword)
);
}
// 计算分页信息
// 计算总记录数
const totalCount = filteredRules.length;
const totalPages = Math.ceil(totalCount / pageSize);
const totalPages = Math.ceil(totalCount / params.pageSize);
// 验证页码范围
if (currentPage < 1 || (totalCount > 0 && currentPage > totalPages)) {
// 如果页码超出范围,重定向到第一页
if (params.page < 1 || (totalCount > 0 && params.page > totalPages)) {
const newUrl = new URL(request.url);
newUrl.searchParams.set('page', '1');
return redirect(newUrl.pathname + newUrl.search);
}
// 分页截取
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
const paginatedRules = filteredRules.slice(startIndex, endIndex);
// 分页
const offset = (params.page - 1) * params.pageSize;
const paginatedRules = filteredRules.slice(offset, offset + params.pageSize);
return json<LoaderData>({
return json({
rules: paginatedRules,
groups,
totalCount,
currentPage,
pageSize,
currentPage: params.page,
pageSize: params.pageSize,
totalPages
}, {
headers: {
// 添加缓存控制,在生产环境中可以调整
"Cache-Control": "max-age=60, s-maxage=180"
}
});
} catch (error) {
console.error('加载评查点列表失败:', error);
throw new Response('加载评查点列表失败', { status: 500 });
@@ -303,7 +329,7 @@ const priorityLabels = {
};
export default function RulesIndex() {
const { rules, groups, totalCount, currentPage, pageSize } = useLoaderData<typeof loader>();
const { rules, totalCount, currentPage, pageSize } = useLoaderData<typeof loader>();
const [searchParams, setSearchParams] = useSearchParams();
const submit = useSubmit();
@@ -380,9 +406,9 @@ export default function RulesIndex() {
};
// 处理重置筛选
const handleReset = () => {
setSearchParams(new URLSearchParams());
};
// const handleReset = () => {
// setSearchParams(new URLSearchParams());
// };
// 定义表格列配置
const columns = [
@@ -489,16 +515,22 @@ export default function RulesIndex() {
{ value: "business", label: "业务专项类" }
]}
onChange={handleFilterChange}
className="mr-3 w-80 "
className="mr-3 w-60 "
/>
<FilterSelect
label="所属规则组"
name="groupId"
value={searchParams.get('groupId') || ''}
options={groups.map(group => ({ value: group.id, label: group.name }))}
options={[
{ value: "1", label: "合同基本要素类检查" },
{ value: "2", label: "销售合同专项检查" },
{ value: "3", label: "采购合同专项检查" },
{ value: "4", label: "专卖许可证审核规则" },
{ value: "5", label: "行政处罚规范性检查" }
]}
onChange={handleFilterChange}
className="mr-3 w-80"
className="mr-3 w-60"
/>
<FilterSelect
@@ -510,7 +542,7 @@ export default function RulesIndex() {
{ value: "false", label: "禁用" }
]}
onChange={handleFilterChange}
className="mr-3 w-80"
className="mr-3 w-60"
/>
<SearchFilter
@@ -0,0 +1,99 @@
.date-range-picker {
width: 100%;
}
.date-range-fields {
display: flex;
align-items: center;
gap: 8px;
}
.date-field {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.date-label {
font-size: 12px;
color: #666;
}
.date-input {
padding: 6px 8px;
border: 1px solid #e0e0e0;
border-radius: 4px;
font-size: 14px;
color: #333;
transition: all 0.2s ease;
background-color: #fff;
width: 100%;
}
.date-input:focus {
border-color: var(--primary-color);
outline: none;
box-shadow: 0 0 0 2px rgba(0,104,74, 0.2);
}
.date-separator {
margin: 0 4px;
color: #999;
font-size: 14px;
align-self: flex-end;
padding-bottom: 8px;
}
.simple-date-range-picker .date-range-fields {
display: flex;
align-items: center;
}
.simple-date-range-picker .date-input {
min-width: 130px;
max-width: 150px;
}
/* 响应式调整 */
@media (max-width: 640px) {
.date-range-fields {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.date-separator {
display: none;
}
.simple-date-range-picker .date-range-fields {
flex-direction: row;
}
.simple-date-range-picker .date-separator {
display: block;
}
}
/* 深色模式支持 */
@media (prefers-color-scheme: dark) {
.date-label {
color: #b0b0b0;
}
.date-input {
background-color: #1f1f1f;
border-color: #444;
color: #e0e0e0;
}
.date-input:focus {
border-color: #177ddc;
box-shadow: 0 0 0 2px rgba(23, 125, 220, 0.2);
}
.date-separator {
color: #888;
}
}
+148
View File
@@ -0,0 +1,148 @@
.file-tag {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
white-space: nowrap;
background-color: #f0f0f0;
color: #555;
}
.file-tag .file-tag-icon {
margin-right: 4px;
font-size: 16px;
}
/* 无背景模式 */
.file-tag-no-bg {
background-color: transparent !important;
padding: 0;
}
.file-tag-no-bg .file-tag-icon {
margin-right: 0;
font-size: 18px;
}
/* 没有文本时的图标调整 */
.file-tag:not(:has(.file-tag-text)) .file-tag-icon {
margin-right: 0;
}
/* 尺寸变体 */
.file-tag-sm {
padding: 1px 4px;
font-size: 10px;
}
.file-tag-sm .file-tag-icon {
font-size: 14px;
}
.file-tag-sm.file-tag-no-bg {
padding: 0;
}
.file-tag-sm.file-tag-no-bg .file-tag-icon {
font-size: 16px;
}
.file-tag-lg {
padding: 3px 8px;
font-size: 12px;
}
.file-tag-lg .file-tag-icon {
font-size: 20px;
}
.file-tag-lg.file-tag-no-bg {
padding: 0;
}
.file-tag-lg.file-tag-no-bg .file-tag-icon {
font-size: 22px;
}
/* 文件类型变体 */
.file-tag-pdf {
background-color: #fee2e2;
color: #b91c1c;
}
.file-tag-doc, .file-tag-docx {
background-color: #dbeafe;
color: #1d4ed8;
}
.file-tag-xls, .file-tag-xlsx {
background-color: #d1fae5;
color: #047857;
}
.file-tag-ppt, .file-tag-pptx {
background-color: #ffedd5;
color: #c2410c;
}
.file-tag-zip, .file-tag-rar {
background-color: #e9d5ff;
color: #7e22ce;
}
.file-tag-txt {
background-color: #e4e4e7;
color: #3f3f46;
}
.file-tag-jpg, .file-tag-jpeg, .file-tag-png, .file-tag-gif {
background-color: #d8b4fe;
color: #6b21a8;
}
/* 适配深色模式 */
@media (prefers-color-scheme: dark) {
.file-tag {
background-color: #2d2d2d;
color: #d4d4d4;
}
.file-tag-pdf {
background-color: rgba(185, 28, 28, 0.2);
color: #f87171;
}
.file-tag-doc, .file-tag-docx {
background-color: rgba(29, 78, 216, 0.2);
color: #93c5fd;
}
.file-tag-xls, .file-tag-xlsx {
background-color: rgba(4, 120, 87, 0.2);
color: #6ee7b7;
}
.file-tag-ppt, .file-tag-pptx {
background-color: rgba(194, 65, 12, 0.2);
color: #fdba74;
}
.file-tag-zip, .file-tag-rar {
background-color: rgba(126, 34, 206, 0.2);
color: #c4b5fd;
}
.file-tag-txt {
background-color: rgba(63, 63, 70, 0.2);
color: #a1a1aa;
}
.file-tag-jpg, .file-tag-jpeg, .file-tag-png, .file-tag-gif {
background-color: rgba(107, 33, 168, 0.2);
color: #e9d5ff;
}
}
+97 -4
View File
@@ -4,7 +4,26 @@
/* 文件类型标签基础样式 */
.file-type-tag {
@apply inline-flex items-center px-2 py-1 rounded text-xs font-medium;
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
line-height: 1.5;
white-space: nowrap;
background-color: #f0f0f0;
color: #333;
}
.file-type-tag .file-type-icon {
margin-right: 4px;
font-size: 14px;
}
/* 无图标模式 */
.file-type-tag-no-icon {
padding-left: 8px;
}
/* 文件类型颜色 */
@@ -61,13 +80,23 @@
@apply bg-purple-100 text-purple-800;
}
/* 文件类型尺寸 */
/* 尺寸变体 */
.file-type-tag-sm {
@apply px-1.5 py-0 text-xs;
padding: 2px 6px;
font-size: 11px;
}
.file-type-tag-sm .file-type-icon {
font-size: 12px;
}
.file-type-tag-lg {
@apply px-2.5 py-1 text-sm;
padding: 6px 12px;
font-size: 14px;
}
.file-type-tag-lg .file-type-icon {
font-size: 16px;
}
/* 带图标的文件类型标签 */
@@ -77,4 +106,68 @@
.file-type-tag i {
@apply mr-1 text-sm;
}
/* 文件类型变体 */
.file-type-sales-contract {
background-color: #e8f5fd;
color: #0077cc;
}
.file-type-purchase-contract {
background-color: #f5f0ff;
color: #6b46c1;
}
.file-type-license {
background-color: #fef5e7;
color: #dd6b20;
}
.file-type-punishment {
background-color: #fee7e7;
color: #e53e3e;
}
.file-type-agreement {
background-color: #e8fdf5;
color: #00a67e;
}
/* 动画效果 - 鼠标悬停 */
.file-type-tag:hover {
opacity: 0.9;
}
/* 适配深色模式 */
@media (prefers-color-scheme: dark) {
.file-type-tag {
background-color: #2d2d2d;
color: #e0e0e0;
}
.file-type-sales-contract {
background-color: rgba(0, 119, 204, 0.2);
color: #4db8ff;
}
.file-type-purchase-contract {
background-color: rgba(107, 70, 193, 0.2);
color: #b794f4;
}
.file-type-license {
background-color: rgba(221, 107, 32, 0.2);
color: #f6ad55;
}
.file-type-punishment {
background-color: rgba(229, 62, 62, 0.2);
color: #fc8181;
}
.file-type-agreement {
background-color: rgba(0, 166, 126, 0.2);
color: #68d5b1;
}
}
+3 -3
View File
@@ -12,12 +12,12 @@
@apply flex items-center;
}
.search-box-row .form-input {
/* .search-box-row .form-input {
@apply rounded-r-none;
}
} */
.search-box-row .search-button {
@apply rounded-l-none h-full flex items-center;
@apply h-full flex items-center;
}
/* 搜索输入框 */
+60 -41
View File
@@ -2,71 +2,90 @@
* 状态徽章组件样式
*/
/* 状态徽章基础样式 */
.status-badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
line-height: 1.5;
}
/* 状态颜色 - 从 documents_index.css 同步 */
.status-pending {
background-color: #E6F7FF;
color: #1890FF;
}
.status-processing {
background-color: #FFF7E6;
color: #FA8C16;
}
.status-pass {
background-color: #F6FFED;
color: #52C41A;
}
.status-warning {
background-color: #FFFBE6;
color: #FAAD14;
}
.status-fail {
background-color: #FFF1F0;
color: #F5222D;
}
/* 动画效果 - 用于processing状态 */
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.status-processing i {
animation: spin 1.2s linear infinite;
}
/* 状态徽章尺寸 */
.status-badge-sm {
@apply px-2 py-0.5 text-xs;
padding: 0px 4px;
font-size: 11px;
}
.status-badge-lg {
@apply px-3 py-1 text-sm;
}
/* 状态徽章类型 */
.status-badge-success {
@apply bg-green-100 text-green-800;
}
.status-badge-processing {
@apply bg-blue-100 text-blue-800;
}
.status-badge-warning {
@apply bg-yellow-100 text-yellow-800;
}
.status-badge-error {
@apply bg-red-100 text-red-800;
}
.status-badge-default {
@apply bg-gray-100 text-gray-800;
padding: 3px 10px;
font-size: 14px;
}
/* 带图标的状态徽章 */
.status-badge-with-icon {
@apply pl-1.5;
}
.status-badge i {
@apply mr-1;
margin-right: 4px;
}
/* 可点击的状态徽章 */
.status-badge-clickable {
@apply cursor-pointer transition-colors duration-200;
cursor: pointer;
transition: background-color 0.2s;
}
.status-badge-clickable.status-badge-success:hover {
@apply bg-green-200;
.status-badge-clickable.status-pending:hover {
background-color: #BAE7FF;
}
.status-badge-clickable.status-badge-processing:hover {
@apply bg-blue-200;
.status-badge-clickable.status-processing:hover {
background-color: #FFE7BA;
}
.status-badge-clickable.status-badge-warning:hover {
@apply bg-yellow-200;
.status-badge-clickable.status-pass:hover {
background-color: #D9F7BE;
}
.status-badge-clickable.status-badge-error:hover {
@apply bg-red-200;
.status-badge-clickable.status-warning:hover {
background-color: #FFF1B8;
}
.status-badge-clickable.status-badge-default:hover {
@apply bg-gray-200;
.status-badge-clickable.status-fail:hover {
background-color: #FFCCC7;
}
+4 -1
View File
@@ -4,12 +4,12 @@
*/
/* 导入组件样式 */
@import './components/badge.css';
@import './components/button.css';
@import './components/card.css';
@import './components/form.css';
@import './components/navigation.css';
@import './components/table.css';
@import './components/badge.css';
@import './components/pagination.css';
@import './components/search-box.css';
@import './components/filter-panel.css';
@@ -20,7 +20,10 @@
@import './components/tag.css';
@import './components/file-progress.css';
@import './components/processing-steps.css';
@import './components/date-range-picker.css';
@import './components/upload-area.css';
@import './components/file-tag.css';
/* @import './components/modal.css'; */
/* Tailwind 基础指令 */
+171
View File
@@ -0,0 +1,171 @@
/**
* 文档上传页面样式
*/
.document-upload-page {
--primary-color: var(--color-primary, #00684a);
--primary-hover: var(--color-primary-hover, #005a40);
--primary-light: rgba(0, 104, 74, 0.1);
--success-color: var(--color-success, #52c41a);
--warning-color: var(--color-warning, #faad14);
--error-color: var(--color-error, #ff4d4f);
--text-color: rgba(0, 0, 0, 0.85);
--text-secondary: rgba(0, 0, 0, 0.45);
--border-color: #f0f0f0;
--bg-gray: #f5f5f5;
}
/* 页面布局 */
.document-upload-page .page-header {
@apply flex justify-between items-center mb-4;
}
.document-upload-page .page-title {
@apply text-xl font-medium;
}
/* 表单样式 */
.document-upload-page .form-group {
@apply mb-4;
}
.document-upload-page .form-label {
@apply block font-medium text-gray-700 mb-2;
}
.document-upload-page .form-tip {
@apply text-xs text-gray-500 mt-1;
}
/* 文件列表区域 */
.document-upload-page .file-list {
@apply mt-6;
}
.document-upload-page .file-item {
@apply flex items-center p-3 border border-gray-200 rounded-md mb-3 bg-white;
}
.document-upload-page .file-item:hover {
@apply bg-gray-50;
}
.document-upload-page .file-info {
@apply flex-1 ml-3;
}
.document-upload-page .file-name {
@apply font-medium mb-1;
}
.document-upload-page .file-meta {
@apply text-xs text-gray-500 flex items-center;
}
.document-upload-page .file-size {
@apply mr-4;
}
.document-upload-page .progress-bar {
@apply h-1 bg-gray-100 rounded overflow-hidden mt-2 w-full;
}
.document-upload-page .progress-bar-inner {
@apply h-full bg-[var(--primary-color)] rounded transition-[width] duration-300 ease-out;
}
.document-upload-page .file-actions {
@apply flex items-center;
}
/* 批量操作区域 */
.document-upload-page .batch-actions {
@apply flex justify-between items-center p-2 bg-gray-50 border border-gray-200 rounded-md mb-4;
}
/* 高级选项区域 */
.document-upload-page .advanced-options {
@apply mt-4;
}
.document-upload-page .advanced-options-toggle {
@apply text-[var(--primary-color)] cursor-pointer inline-flex items-center text-sm;
}
.document-upload-page .advanced-options-toggle i {
@apply ml-1 transition-transform duration-200;
}
.document-upload-page .advanced-options-toggle.open i {
@apply rotate-180;
}
.document-upload-page .advanced-options-content {
@apply mt-3 p-3 bg-gray-50 border border-gray-200 rounded-md hidden;
}
/* 提醒横幅 */
.document-upload-page .alert {
@apply p-3 flex items-center rounded-md mb-4;
}
.document-upload-page .alert i {
@apply mr-2;
}
.document-upload-page .alert-success {
@apply bg-green-50 text-green-700 border border-green-200;
}
.document-upload-page .alert-info {
@apply bg-blue-50 text-blue-700 border border-blue-200;
}
.document-upload-page .alert-warning {
@apply bg-yellow-50 text-yellow-700 border border-yellow-200;
}
.document-upload-page .alert-error {
@apply bg-red-50 text-red-700 border border-red-200;
}
/* 完成操作区域 */
.document-upload-page .upload-complete-actions {
@apply text-center py-4 hidden;
}
/* 复选框样式 */
.document-upload-page .switch-container {
@apply flex items-center mt-2;
}
.document-upload-page .switch {
@apply relative inline-block w-10 h-5 mr-2;
}
.document-upload-page .switch input {
@apply opacity-0 w-0 h-0;
}
.document-upload-page .slider {
@apply absolute cursor-pointer inset-0 bg-gray-300 rounded-full transition-all duration-300;
}
.document-upload-page .slider:before {
@apply absolute content-[''] h-4 w-4 left-0.5 bottom-0.5 bg-white rounded-full transition-all duration-300;
}
.document-upload-page input:checked + .slider {
@apply bg-[var(--primary-color)];
}
.document-upload-page input:checked + .slider:before {
@apply transform translate-x-5;
}
/* 响应式调整 */
@screen md {
.document-upload-page .form-grid {
@apply grid grid-cols-2 gap-6;
}
}
+77
View File
@@ -0,0 +1,77 @@
/**
* 文档列表页面样式
*/
.documents-page {
/* 全局变量已定义在主样式表中,这里不需要重新定义 */
}
/* 文档列表特有样式 */
.form-select:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(0,104,74, 0.2);
outline: none;
}
/* 状态徽章样式已移动到 status-badge.css */
.file-icon {
width: 24px;
height: 24px;
margin-right: 12px;
}
.file-name {
max-width: 240px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.document-number {
font-family: monospace;
color: #666;
}
.filter-container {
display: flex;
flex-wrap: nowrap;
align-items: flex-end;
gap: 8px;
margin-bottom: 16px;
overflow-x: auto;
}
.filter-item {
display: flex;
flex-direction: column;
min-width: auto;
flex-shrink: 0;
}
.filter-label {
margin-bottom: 4px;
color: #666;
white-space: nowrap;
}
.operations-cell {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
/* 响应式调整 */
@media (max-width: 1200px) {
.filter-container {
flex-wrap: wrap;
}
.filter-item.ml-auto {
margin-left: 0;
width: 100%;
margin-top: 8px;
display: flex;
justify-content: flex-end;
}
}
-21
View File
@@ -96,27 +96,6 @@
@apply flex items-center;
}
/* 状态标签 */
.status-badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.status-badge.status-success {
@apply bg-green-100 text-green-900;
}
.status-badge.status-warning {
@apply bg-yellow-100 text-yellow-900;
}
.status-badge.status-error {
@apply bg-red-100 text-red-900;
}
.status-badge.status-processing {
@apply bg-blue-100 text-blue-900;
}
/* 卡片样式 */
.dashboard-card {
@apply bg-white rounded-lg shadow p-5 mb-5 transition-all duration-200;