diff --git a/app/components/layout/Sidebar.tsx b/app/components/layout/Sidebar.tsx index a6429c0..49c81b8 100644 --- a/app/components/layout/Sidebar.tsx +++ b/app/components/layout/Sidebar.tsx @@ -66,7 +66,7 @@ export function Sidebar({ onToggle, collapsed }: SidebarProps) { { id: 'rules-file', title: '评查文件列表', - path: '/rules/files', + path: '/rules-files', icon: 'ri-list-check-2' }, { @@ -97,6 +97,12 @@ export function Sidebar({ onToggle, collapsed }: SidebarProps) { path: '/settings', icon: 'ri-settings-4-line', children: [ + { + id: 'config-lists', + title: '配置列表', + path: '/config-lists', + icon: 'ri-list-check-3' + }, { id: 'basic-settings', title: '基础设置', diff --git a/app/components/ui/Card.tsx b/app/components/ui/Card.tsx index d5e925e..d28a5c5 100644 --- a/app/components/ui/Card.tsx +++ b/app/components/ui/Card.tsx @@ -20,11 +20,11 @@ export function Card({ noDivider = true, }: CardProps) { return ( -
+
{(title || extra) && ( -
+
{title && ( -
+
{icon && } {title}
@@ -36,7 +36,7 @@ export function Card({ )}
)} -
+
{children}
diff --git a/app/components/ui/FileIcon.tsx b/app/components/ui/FileIcon.tsx new file mode 100644 index 0000000..bd44c21 --- /dev/null +++ b/app/components/ui/FileIcon.tsx @@ -0,0 +1,45 @@ +/** + * 文件图标组件 + * 根据文件名后缀显示不同类型的图标 + */ +interface FileIconProps { + fileName: string; + className?: string; + size?: 'default' | 'sm' | 'lg'; +} + +export function FileIcon({ fileName, className = '', size }: FileIconProps) { + const sizeClass = size ? `file-icon-${size}` : ''; + + let fileType = 'unknown'; + let iconClass = 'ri-file-line'; + + if (fileName.endsWith('.pdf')) { + fileType = 'pdf'; + iconClass = 'ri-file-pdf-line'; + } else if (fileName.endsWith('.docx') || fileName.endsWith('.doc')) { + fileType = 'doc'; + iconClass = 'ri-file-word-2-line'; + } else if (fileName.endsWith('.xlsx') || fileName.endsWith('.xls')) { + fileType = 'xls'; + iconClass = 'ri-file-excel-2-line'; + } else if (fileName.endsWith('.pptx') || fileName.endsWith('.ppt')) { + fileType = 'ppt'; + iconClass = 'ri-file-ppt-2-line'; + } else if (fileName.endsWith('.zip') || fileName.endsWith('.rar')) { + fileType = 'zip'; + iconClass = 'ri-file-zip-line'; + } else if (fileName.endsWith('.png') || fileName.endsWith('.jpg') || fileName.endsWith('.jpeg') || fileName.endsWith('.gif')) { + fileType = 'img'; + iconClass = 'ri-image-line'; + } else if (fileName.endsWith('.txt')) { + fileType = 'txt'; + iconClass = 'ri-file-text-line'; + } + + return ( + + + + ); +} \ No newline at end of file diff --git a/app/components/ui/FileTypeTag.tsx b/app/components/ui/FileTypeTag.tsx new file mode 100644 index 0000000..68b7463 --- /dev/null +++ b/app/components/ui/FileTypeTag.tsx @@ -0,0 +1,39 @@ +import { FileType, FILE_TYPE_LABELS } from '~/routes/rules-files'; + +interface FileTypeTagProps { + fileType: FileType; + className?: string; + size?: 'default' | 'sm' | 'lg'; +} + +/** + * 文件类型标签组件 + * 根据文件类型显示不同样式的标签 + */ +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 ; + case FileType.LICENSE: + return ; + case FileType.PUNISHMENT: + return ; + case FileType.REPORT: + return ; + default: + return ; + } + }; + + return ( + + {getFileTypeIcon()} + {FILE_TYPE_LABELS[fileType]} + + ); +} \ No newline at end of file diff --git a/app/components/ui/FilterPanel.tsx b/app/components/ui/FilterPanel.tsx new file mode 100644 index 0000000..f47c0e1 --- /dev/null +++ b/app/components/ui/FilterPanel.tsx @@ -0,0 +1,135 @@ +import { SearchBox } from '~/components/ui/SearchBox'; + +interface FilterOption { + value: string; + label: string; +} + +interface FilterSelectProps { + label: string; + name: string; + value: string; + options: FilterOption[]; + onChange: (e: React.ChangeEvent) => void; + className?: string; +} + +/** + * 筛选下拉选择框组件 + */ +const FilterSelect = ({ label, name, value, options, onChange, className = '' }: FilterSelectProps) => ( +
+ + +
+); + +interface FilterPanelProps { + children: React.ReactNode; + className?: string; + actions?: React.ReactNode; // 按钮组,如搜索、重置按钮 + noActionDivider?: boolean; // 是否取消按钮组与内容之间的分割线 +} + +/** + * 通用筛选面板组件 + * 用于包裹筛选控件 + * + * 使用示例: + * ```tsx + * + * + * + * + * } + * > + * + * + * + */ +export function FilterPanel({ children, className = '', actions, noActionDivider = false }: FilterPanelProps) { + return ( +
+
+ {children} + {actions && ( +
+ {actions} +
+ )} +
+
+ ); +} + +interface SearchFilterProps { + label: string; + placeholder: string; + value: string; + onSearch: (value: string) => void; + buttonText?: string; + className?: string; + instantSearch?: boolean; // 是否启用即时搜索(输入内容时立即搜索) +} + +/** + * 搜索筛选组件 + * + * 使用示例: + * ```tsx + * // 带搜索按钮的搜索框 + * + * + * // 即时搜索的搜索框(无按钮) + * + */ +export function SearchFilter({ + label, + placeholder, + value, + onSearch, + buttonText = "搜索", + className = '', + instantSearch = false +}: SearchFilterProps) { + return ( +
+ + +
+ ); +} + +// 导出筛选下拉框组件 +export { FilterSelect }; \ No newline at end of file diff --git a/app/components/ui/Pagination.tsx b/app/components/ui/Pagination.tsx index 12ceab3..434acbc 100644 --- a/app/components/ui/Pagination.tsx +++ b/app/components/ui/Pagination.tsx @@ -1,5 +1,4 @@ // app/components/ui/Pagination.tsx -import React from 'react'; interface PaginationProps { currentPage: number; @@ -8,6 +7,8 @@ interface PaginationProps { onChange: (page: number) => void; onPageSizeChange?: (pageSize: number) => void; pageSizeOptions?: number[]; + showTotal?: boolean; + showPageSizeChanger?: boolean; } export function Pagination({ @@ -16,7 +17,9 @@ export function Pagination({ pageSize, onChange, onPageSizeChange, - pageSizeOptions = [10, 20, 50] + pageSizeOptions = [10, 20, 50], + showTotal = true, + showPageSizeChanger = true }: PaginationProps) { const totalPages = Math.ceil(total / pageSize); @@ -51,19 +54,24 @@ export function Pagination({ }; return ( -
- {onPageSizeChange && ( +
+ {showTotal && (
- 共 {total} 条 - + 共 {total} 条 + {onPageSizeChange && showPageSizeChanger && ( +
+ +
+ )}
)} diff --git a/app/components/ui/SearchBox.tsx b/app/components/ui/SearchBox.tsx index 0176d66..6e1aa08 100644 --- a/app/components/ui/SearchBox.tsx +++ b/app/components/ui/SearchBox.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { Button } from './Button'; interface SearchBoxProps { placeholder?: string; @@ -32,21 +31,29 @@ export function SearchBox({ } }; + const isIconOnly = buttonText === ''; + const isFilterControl = className.includes('filter-control'); + const searchBoxClass = `search-box ${className} ${isFilterControl ? 'search-box-row' : ''}`; + return ( -
- + - {buttonText && !className.includes('form-input-only') && ( - + {!className.includes('form-input-only') && ( + )}
); diff --git a/app/components/ui/StatusBadge.tsx b/app/components/ui/StatusBadge.tsx new file mode 100644 index 0000000..b499ae7 --- /dev/null +++ b/app/components/ui/StatusBadge.tsx @@ -0,0 +1,69 @@ +import { ReviewStatus, REVIEW_STATUS_LABELS } from '~/routes/rules-files'; + +interface StatusBadgeProps { + status: ReviewStatus; + issueCount?: number; + className?: string; + size?: 'default' | 'sm' | 'lg'; + clickable?: boolean; + onClick?: () => void; +} + +/** + * 文件评查状态标签组件 + * 根据评查状态显示不同样式的标签 + */ +export function StatusBadge({ + status, + issueCount = 0, + className = '', + size, + clickable = false, + onClick +}: StatusBadgeProps) { + const statusMap: Record = { + [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 ; + case ReviewStatus.WARNING: + return ; + case ReviewStatus.FAIL: + return ; + case ReviewStatus.PENDING: + return ; + default: + return null; + } + }; + + const handleClick = () => { + if (clickable && onClick) { + onClick(); + } + }; + + return ( + + {getStatusIcon()} + {REVIEW_STATUS_LABELS[status]} + {issueCount > 0 && ` (${issueCount})`} + + ); +} \ No newline at end of file diff --git a/app/components/ui/StatusDot.tsx b/app/components/ui/StatusDot.tsx index ea99793..658b69f 100644 --- a/app/components/ui/StatusDot.tsx +++ b/app/components/ui/StatusDot.tsx @@ -4,12 +4,16 @@ interface StatusDotProps { status: StatusType | boolean; text?: string; className?: string; + size?: 'default' | 'sm' | 'lg'; + pulse?: boolean; } export function StatusDot({ status, text, - className = '' + className = '', + size = 'default', + pulse = false }: StatusDotProps) { // 如果status是布尔值,则转换为对应的状态类型 const statusType = typeof status === 'boolean' @@ -23,11 +27,14 @@ export function StatusDot({ statusType === 'warning' ? '警告' : statusType === 'processing' ? '处理中' : '未知' ); + + const sizeClass = size !== 'default' ? `status-dot-${size}` : ''; + const pulseClass = pulse ? 'status-dot-pulse' : ''; return ( - - - {statusText} + + + {statusText} ); } \ No newline at end of file diff --git a/app/components/ui/Table.tsx b/app/components/ui/Table.tsx index 796c3ad..0bf37bd 100644 --- a/app/components/ui/Table.tsx +++ b/app/components/ui/Table.tsx @@ -39,8 +39,8 @@ export function Table>({ }; return ( -
- +
+
{columns.map((column, index) => ( @@ -86,7 +86,7 @@ export function Table>({ @@ -96,7 +96,7 @@ export function Table>({
{emptyText}
{loading && ( -
+
加载中... diff --git a/app/components/ui/Tag.tsx b/app/components/ui/Tag.tsx index fde0db7..91e1a69 100644 --- a/app/components/ui/Tag.tsx +++ b/app/components/ui/Tag.tsx @@ -1,17 +1,43 @@ import React from 'react'; -export type TagColor = 'blue' | 'green' | 'cyan' | 'purple' | 'orange' | 'red' | 'default'; +export type TagColor = 'blue' | 'green' | 'red' | 'yellow' | 'purple' | 'gray' | 'cyan' | 'orange'; interface TagProps { color?: TagColor; children: React.ReactNode; className?: string; + size?: 'default' | 'sm' | 'lg'; + closable?: boolean; + clickable?: boolean; + onClose?: () => void; } -export function Tag({ color = 'default', children, className = '' }: TagProps) { +export function Tag({ + color = 'blue', + children, + className = '', + size, + closable = false, + clickable = false, + onClose +}: TagProps) { + const sizeClass = size ? `tag-${size}` : ''; + const closableClass = closable ? 'tag-closable' : ''; + const clickableClass = clickable ? 'tag-clickable' : ''; + + const handleClose = (e: React.MouseEvent) => { + e.stopPropagation(); + onClose?.(); + }; + return ( - + {children} + {closable && ( + + + + )} ); } \ No newline at end of file diff --git a/app/root.tsx b/app/root.tsx index f4eddbf..ea3e333 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -14,7 +14,7 @@ import { Layout } from "~/components/layout/Layout"; import { ErrorBoundary as AppErrorBoundary } from "~/components/error/ErrorBoundary"; import "remixicon/fonts/remixicon.css"; // 导入样式 -import mainStyles from "~/styles/main.css?url"; +import styles from "~/styles/main.css?url"; // 添加客户端hydration错误处理 // if (typeof window !== "undefined") { @@ -42,10 +42,10 @@ export const meta: MetaFunction = () => { // 使用links函数为应用加载CSS和其他资源 export function links() { return [ - { rel: "stylesheet", href: mainStyles }, - { rel: "preconnect", href: "https://fonts.googleapis.com" }, - { rel: "preconnect", href: "https://fonts.gstatic.com", crossOrigin: "anonymous" }, - { rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap" } + { rel: "stylesheet", href: styles }, + // { rel: "preconnect", href: "https://fonts.googleapis.com" }, + // { rel: "preconnect", href: "https://fonts.gstatic.com", crossOrigin: "anonymous" }, + // { rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap" } ]; } diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx index 358d6ce..c3aedeb 100644 --- a/app/routes/_index.tsx +++ b/app/routes/_index.tsx @@ -3,9 +3,10 @@ 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 homeStyles from "~/styles/pages/home.css?url"; export const links = () => [ - { rel: "stylesheet", href: "app/styles/pages/home.css" } + { rel: "stylesheet", href: homeStyles } ]; export const meta: MetaFunction = () => { @@ -255,7 +256,7 @@ function StatusBadge({ status }: StatusBadgeProps) { warning: { label: '警告', className: 'status-badge status-warning', - icon: 'ri-error-warning-line' + icon: 'ri-alert-line' }, fail: { label: '不通过', @@ -264,7 +265,7 @@ function StatusBadge({ status }: StatusBadgeProps) { }, pending: { label: '待确认', - className: 'status-badge', + className: 'status-badge status-processing', icon: 'ri-time-line' } }; diff --git a/app/routes/config-lists._index.tsx b/app/routes/config-lists._index.tsx new file mode 100644 index 0000000..c541a52 --- /dev/null +++ b/app/routes/config-lists._index.tsx @@ -0,0 +1,605 @@ +import { json, type MetaFunction, type LoaderFunctionArgs, type ActionFunctionArgs } from "@remix-run/node"; +import { useLoaderData, useSearchParams, useSubmit, Link } from "@remix-run/react"; +import { useState } from "react"; +import { Button } from "~/components/ui/Button"; +import { Card } from "~/components/ui/Card"; +import { FilterPanel, FilterSelect, SearchFilter } from "~/components/ui/FilterPanel"; +import { Pagination } from "~/components/ui/Pagination"; +import { Table } from "~/components/ui/Table"; +import { Tag } from "~/components/ui/Tag"; +import configListsStyles from "~/styles/pages/config-lists_index.css?url"; + +export const links = () => [ + { rel: "stylesheet", href: configListsStyles } +]; + +// export const handle = { +// breadcrumb: "系统配置管理" +// }; + +export const meta: MetaFunction = () => { + return [ + { title: "系统配置管理 - 中国烟草AI合同及卷宗审核系统" }, + { name: "description", content: "管理系统配置,包括数据库、文件存储、AI引擎等配置项" }, + { name: "keywords", content: "系统配置,配置管理,中国烟草,参数设置" } + ]; +}; + +// 配置环境枚举 +export enum ConfigEnvironment { + DEV = 'dev', + TEST = 'test', + PROD = 'prod' +} + +// 配置模块枚举 +export enum ConfigModule { + SYSTEM = 'system', + AUTH = 'auth', + FILE = 'file', + AI = 'ai', + NOTIFICATION = 'notification' +} + +// 环境标签映射 +export const ENVIRONMENT_LABELS: Record = { + [ConfigEnvironment.DEV]: '开发环境', + [ConfigEnvironment.TEST]: '测试环境', + [ConfigEnvironment.PROD]: '生产环境' +}; + +// 模块标签映射 +export const MODULE_LABELS: Record = { + [ConfigModule.SYSTEM]: '系统', + [ConfigModule.AUTH]: '认证', + [ConfigModule.FILE]: '文件', + [ConfigModule.AI]: 'AI配置', + [ConfigModule.NOTIFICATION]: '通知' +}; + +// 配置数据类型 +interface ConfigDataType { + [key: string]: string | number | boolean | string[] | ConfigDataType | ConfigDataType[]; +} + +// 配置项模型 +interface ConfigItem { + id: string; + configName: string; + module: ConfigModule; + environment: ConfigEnvironment; + isActive: boolean; + configData: ConfigDataType; + createdAt: string; + updatedAt: string; +} + +interface LoaderData { + configs: ConfigItem[]; + totalCount: number; + currentPage: number; + pageSize: number; + totalPages: number; +} + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url); + const configName = url.searchParams.get("configName") || ""; + const module = url.searchParams.get("module") || ""; + const environment = url.searchParams.get("environment") || ""; + const isActive = url.searchParams.get("isActive") || ""; + const currentPage = parseInt(url.searchParams.get("page") || "1", 10); + const pageSize = parseInt(url.searchParams.get("pageSize") || "10", 10); + + try { + // 模拟数据,实际项目中应从API获取 + const mockConfigs: ConfigItem[] = [ + { + id: "1", + configName: "database_connection", + module: ConfigModule.SYSTEM, + environment: ConfigEnvironment.PROD, + isActive: true, + configData: { + database: { + host: "db.cluster.com", + port: 5432, + pool_size: 20, + ssl: true + }, + cache: { + ttl: 3600, + max_entries: 1000 + }, + feature_flags: ["new_ui", "analytics_v2"] + }, + createdAt: "2023-07-10 10:15:23", + updatedAt: "2023-07-15 14:30:26" + }, + { + id: "2", + configName: "text_extraction_ai", + module: ConfigModule.AI, + environment: ConfigEnvironment.TEST, + isActive: true, + configData: { + model: "gpt-4", + parameters: { + temperature: 0.7, + max_tokens: 2000 + }, + api_key: "sk-**********", + timeout: 30 + }, + createdAt: "2023-07-12 08:45:12", + updatedAt: "2023-07-14 09:15:33" + }, + { + id: "3", + configName: "notification_service", + module: ConfigModule.NOTIFICATION, + environment: ConfigEnvironment.DEV, + isActive: false, + configData: { + email: { + smtp_server: "smtp.example.com", + port: 587, + use_tls: true, + sender: "noreply@example.com" + }, + sms: { + provider: "aliyun", + region: "cn-hangzhou", + sign_name: "AI审核系统" + } + }, + createdAt: "2023-07-05 13:20:45", + updatedAt: "2023-07-10 16:45:19" + }, + { + id: "4", + configName: "file_storage", + module: ConfigModule.FILE, + environment: ConfigEnvironment.PROD, + isActive: true, + configData: { + type: "oss", + region: "cn-shanghai", + bucket: "contracts-ai-review", + access_control: "private", + lifecycle_rules: [ + { + prefix: "temp/", + ttl_days: 7 + } + ] + }, + createdAt: "2023-06-28 09:30:18", + updatedAt: "2023-07-08 11:22:07" + } + ]; + + // 过滤数据 + let filteredConfigs = [...mockConfigs]; + + if (configName) { + filteredConfigs = filteredConfigs.filter(config => + config.configName.toLowerCase().includes(configName.toLowerCase()) + ); + } + + if (module) { + filteredConfigs = filteredConfigs.filter(config => config.module === module); + } + + if (environment) { + filteredConfigs = filteredConfigs.filter(config => config.environment === environment); + } + + if (isActive) { + const activeValue = isActive === 'true'; + filteredConfigs = filteredConfigs.filter(config => config.isActive === activeValue); + } + + // 计算分页信息 + const totalCount = filteredConfigs.length; + const totalPages = Math.ceil(totalCount / pageSize); + + // 分页截取 + const startIndex = (currentPage - 1) * pageSize; + const endIndex = startIndex + pageSize; + const paginatedConfigs = filteredConfigs.slice(startIndex, endIndex); + + return json({ + configs: paginatedConfigs, + totalCount, + currentPage, + pageSize, + totalPages + }, { + headers: { + "Cache-Control": "max-age=60, s-maxage=180" + } + }); + } catch (error) { + console.error('加载配置列表失败:', error); + throw new Response('加载配置列表失败', { status: 500 }); + } +} + +export async function action({ request }: ActionFunctionArgs) { + const formData = await request.formData(); + const _action = formData.get('_action'); + const configId = formData.get('configId'); + + if (!configId) { + return json({ success: false, error: "缺少配置ID" }, { status: 400 }); + } + + try { + if (_action === 'toggleStatus') { + const isActive = formData.get('isActive') === 'true'; + const newStatus = !isActive; + + // 实际项目中应调用API更新状态 + console.log(`切换配置 ${configId} 状态为: ${newStatus}`); + + // 模拟API调用 + // const response = await fetch(`/api/configs/${configId}/status`, { + // method: 'PATCH', + // headers: { + // 'Content-Type': 'application/json', + // }, + // body: JSON.stringify({ isActive: newStatus }), + // }); + + // if (!response.ok) { + // throw new Error(`状态切换失败: ${response.status}`); + // } + + return json({ success: true, newStatus }); + } + + return json({ success: false, error: "未知操作" }, { status: 400 }); + } catch (error) { + console.error('操作配置失败:', error); + return json({ success: false, error: "操作失败" }, { status: 500 }); + } +} + +export function ErrorBoundary() { + return ( +
+

出错了

+

加载配置列表时发生错误。请稍后再试,或联系管理员。

+ +
+ ); +} + +export default function ConfigListsIndex() { + const { configs, totalCount, currentPage, pageSize } = useLoaderData(); + const [searchParams, setSearchParams] = useSearchParams(); + const submit = useSubmit(); + const [showDetailModal, setShowDetailModal] = useState(false); + const [selectedConfig, setSelectedConfig] = useState(null); + + const handleFilterChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + const newParams = new URLSearchParams(searchParams); + + if (value) { + newParams.set(name, value); + } else { + newParams.delete(name); + } + + // 切换筛选条件时,重置到第一页 + newParams.set('page', '1'); + + setSearchParams(newParams); + }; + + const handleConfigNameSearch = (value: string) => { + const newParams = new URLSearchParams(searchParams); + if (value) { + newParams.set('configName', value); + } else { + newParams.delete('configName'); + } + + // 搜索时,重置到第一页 + newParams.set('page', '1'); + + setSearchParams(newParams); + }; + + const handleToggleStatus = (config: ConfigItem) => { + if (window.confirm(`确定要${config.isActive ? '禁用' : '启用'}该配置吗?`)) { + const formData = new FormData(); + formData.append('_action', 'toggleStatus'); + formData.append('configId', config.id); + formData.append('isActive', String(config.isActive)); + + submit(formData, { method: 'post' }); + } + }; + + const handleViewDetail = (config: ConfigItem) => { + setSelectedConfig(config); + setShowDetailModal(true); + }; + + const handlePageChange = (page: number) => { + const newParams = new URLSearchParams(searchParams); + newParams.set('page', page.toString()); + setSearchParams(newParams); + }; + + const handlePageSizeChange = (size: number) => { + const newParams = new URLSearchParams(searchParams); + newParams.set('pageSize', size.toString()); + newParams.set('page', '1'); // 更改每页条数时,重置到第一页 + setSearchParams(newParams); + }; + + // 处理重置筛选 + const handleReset = () => { + setSearchParams(new URLSearchParams()); + }; + + + // 关闭详情模态框 + const closeDetailModal = () => { + setShowDetailModal(false); + setSelectedConfig(null); + }; + + // 定义表格列配置 + const columns = [ + { + title: "配置名称", + dataIndex: "configName" as keyof ConfigItem, + key: "configName", + width: "20%" + }, + { + title: "所属模块", + key: "module", + width: "10%", + render: (_: unknown, record: ConfigItem) => MODULE_LABELS[record.module] + }, + { + title: "环境", + key: "environment", + width: "15%", + render: (_: unknown, record: ConfigItem) => { + const envClass = `env-tag env-tag-${record.environment}`; + return ( + + {ENVIRONMENT_LABELS[record.environment]} + + ); + } + }, + { + title: "状态", + key: "isActive", + width: "15%", + render: (_: unknown, record: ConfigItem) => ( + + {record.isActive ? '已启用' : '已禁用'} + + ) + }, + { + title: "最后更新时间", + dataIndex: "updatedAt" as keyof ConfigItem, + key: "updatedAt", + width: "15%" + }, + { + title: "操作", + key: "operation", + width: "25%", + render: (_: unknown, record: ConfigItem) => ( +
+ + + 编辑 + + +
+ ) + } + ]; + + // 生成环境选项 + const environmentOptions = Object.entries(ENVIRONMENT_LABELS).map(([value, label]) => ({ + value, + label + })); + + // 生成模块选项 + const moduleOptions = Object.entries(MODULE_LABELS).map(([value, label]) => ({ + value, + label + })); + + return ( +
+ {/* 页面头部 */} +
+

系统配置管理

+ +
+ + {/* 搜索区域 */} + + + + + } + noActionDivider={true} + > + + + + + + + + + + {/* 表格区域 */} + + + + {/* 分页区域 */} + {totalCount > 0 && ( + + )} + + + {/* 配置详情模态框 */} + {showDetailModal && selectedConfig && ( +
+
+
+

查看配置详情

+ +
+ +
+
+
配置名称
+
{selectedConfig.configName}
+
+ +
+
所属模块
+
{MODULE_LABELS[selectedConfig.module]}
+
+ +
+
环境
+
+ + {ENVIRONMENT_LABELS[selectedConfig.environment]} + +
+
+ +
+
状态
+
+ + {selectedConfig.isActive ? '已启用' : '已禁用'} + +
+
+ +
+
配置数据
+
+                  {JSON.stringify(selectedConfig.configData, null, 2)}
+                
+
+ +
+
+
创建时间
+
{selectedConfig.createdAt}
+
+ +
+
更新时间
+
{selectedConfig.updatedAt}
+
+
+
+ +
+ +
+
+
+ )} + + ); +} diff --git a/app/routes/config-lists.new.tsx b/app/routes/config-lists.new.tsx new file mode 100644 index 0000000..ce5061e --- /dev/null +++ b/app/routes/config-lists.new.tsx @@ -0,0 +1,682 @@ +import { json, redirect, type ActionFunctionArgs, type LoaderFunctionArgs, type MetaFunction } from "@remix-run/node"; +import { Form, useActionData, useLoaderData, useNavigation } from "@remix-run/react"; +import { useEffect, useState } from "react"; +import { Button } from "~/components/ui/Button"; +import { Card } from "~/components/ui/Card"; +import { ConfigModule, MODULE_LABELS, ENVIRONMENT_LABELS } from "./config-lists._index"; +import configNewStyles from "~/styles/pages/config-lists_new.css?url"; + +export const links = () => [ + { rel: "stylesheet", href: configNewStyles } +]; + +export const handle = { + breadcrumb: ({ location }: { location: Location }) => { + const hasId = new URLSearchParams(location.search).has("id"); + return hasId ? "编辑配置" : "新增配置"; + } +}; + +export const meta: MetaFunction = () => { + return [ + { title: "新增配置 - 中国烟草AI合同及卷宗审核系统" }, + { name: "description", content: "新增或编辑系统配置项" }, + { name: "keywords", content: "配置管理,系统配置,新增配置,编辑配置" } + ]; +}; + +// 扩展环境枚举,添加"通用"选项 +export enum ExtendedConfigEnvironment { + DEV = 'dev', + TEST = 'test', + PROD = 'prod', + COMMON = 'common' +} + +// 扩展环境标签映射 +export const EXTENDED_ENVIRONMENT_LABELS: Record = { + ...ENVIRONMENT_LABELS, + [ExtendedConfigEnvironment.COMMON]: '通用' +}; + +interface ConfigData { + id: string; + configName: string; + module: ConfigModule; + environment: string; // 使用扩展的环境类型 + isActive: boolean; + configData: string; // JSON字符串 + remarks?: string; // 添加备注字段 +} + +interface LoaderData { + config?: ConfigData; + isEdit: boolean; +} + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url); + const id = url.searchParams.get("id"); + let config: ConfigData | undefined = undefined; + + if (id) { + try { + // 实际应用中,应从API获取配置详情 + // const response = await fetch(`${process.env.API_BASE_URL}/api/configs/${id}`); + // if (!response.ok) throw new Error(`获取配置详情失败: ${response.status}`); + // config = await response.json(); + // config.configData = JSON.stringify(config.configData, null, 2); + + // 使用模拟数据 + if (id === "1") { + config = { + id: "1", + configName: "database_connection", + module: ConfigModule.SYSTEM, + environment: ExtendedConfigEnvironment.PROD, + isActive: true, + remarks: "数据库连接配置,包含主库和从库配置", + configData: JSON.stringify({ + database: { + host: "db.cluster.com", + port: 5432, + pool_size: 20, + ssl: true + }, + cache: { + ttl: 3600, + max_entries: 1000 + }, + feature_flags: ["new_ui", "analytics_v2"] + }, null, 2) + }; + } else if (id === "2") { + config = { + id: "2", + configName: "text_extraction_ai", + module: ConfigModule.AI, + environment: ExtendedConfigEnvironment.TEST, + isActive: true, + remarks: "AI文本抽取服务配置", + configData: JSON.stringify({ + model: "gpt-4", + parameters: { + temperature: 0.7, + max_tokens: 2000 + }, + api_key: "sk-**********", + timeout: 30 + }, null, 2) + }; + } else if (id === "3") { + config = { + id: "3", + configName: "notification_service", + module: ConfigModule.NOTIFICATION, + environment: ExtendedConfigEnvironment.DEV, + isActive: false, + remarks: "通知服务配置,目前处于开发测试阶段", + configData: JSON.stringify({ + email: { + smtp_server: "smtp.example.com", + port: 587, + use_tls: true, + sender: "noreply@example.com" + }, + sms: { + provider: "aliyun", + region: "cn-hangzhou", + sign_name: "AI审核系统" + } + }, null, 2) + }; + } else if (id === "4") { + config = { + id: "4", + configName: "file_storage", + module: ConfigModule.FILE, + environment: ExtendedConfigEnvironment.COMMON, + isActive: true, + remarks: "文件存储通用配置,适用于所有环境", + configData: JSON.stringify({ + type: "oss", + region: "cn-shanghai", + bucket: "contracts-ai-review", + access_control: "private", + lifecycle_rules: [ + { + prefix: "temp/", + ttl_days: 7 + } + ] + }, null, 2) + }; + } + } catch (error) { + console.error("获取配置详情失败:", error); + // 在实际应用中,应该将错误信息返回给客户端 + // 这里简单处理,返回空config + } + } + + return json({ + config, + isEdit: !!config + }); +} + + +interface ActionData { + success?: boolean; + errors?: { + configName?: string; + module?: string; + environment?: string; + configData?: string; + general?: string; + }; +} + +export async function action({ request }: ActionFunctionArgs) { + const formData = await request.formData(); + const configId = formData.get("id") as string; + const configName = formData.get("configName") as string; + const module = formData.get("module") as string; + const environment = formData.get("environment") as string; + const configData = formData.get("configData") as string; + const isActive = formData.get("isActive") === "true"; + const remarks = formData.get("remarks") as string; + + const errors: ActionData["errors"] = {}; + + // 表单验证 + if (!configName || configName.trim() === "") { + errors.configName = "配置名称不能为空"; + } + + if (!module) { + errors.module = "请选择所属模块"; + } + + if (!environment) { + errors.environment = "请选择环境"; + } + + if (!configData || configData.trim() === "") { + errors.configData = "配置数据不能为空"; + } else { + try { + JSON.parse(configData); + } catch (e) { + errors.configData = "配置数据必须是有效的JSON格式"; + } + } + + if (Object.keys(errors).length > 0) { + return json({ errors }); + } + + try { + // 实际应用中,应调用API保存数据 + console.log("保存配置:", { configId, configName, module, environment, configData, isActive, remarks }); + + // 模拟API调用 + // const response = await fetch(`${process.env.API_BASE_URL}/api/configs${configId ? `/${configId}` : ''}`, { + // method: configId ? "PUT" : "POST", + // headers: { + // "Content-Type": "application/json", + // }, + // body: JSON.stringify({ + // id: configId, + // configName, + // module, + // environment, + // configData: JSON.parse(configData), + // isActive, + // remarks, + // }), + // }); + // + // if (!response.ok) { + // throw new Error(`保存失败: ${response.status}`); + // } + + // 保存成功后重定向到列表页 + return redirect("/config-lists"); + } catch (error) { + console.error("保存配置失败:", error); + return json({ + success: false, + errors: { + general: "保存配置失败,请稍后重试" + } + }); + } +} + +// JSON模板数据 +const JSON_TEMPLATES = { + database: { + database: { + host: "localhost", + port: 5432, + username: "db_user", + password: "******", + name: "app_database", + pool_size: 10, + timeout: 5000, + ssl: false + } + }, + file: { + storage: { + type: "local", // or "s3", "oss" + path: "/data/uploads", + allowed_types: ["pdf", "docx", "jpg", "png"], + max_size: 10485760, // 10MB + backup_enabled: true + } + }, + ai: { + ai_service: { + provider: "openai", + api_key: "sk-******", + model: "gpt-4", + max_tokens: 2000, + temperature: 0.7, + timeout: 30000, + rate_limit: 10 // requests per minute + } + } +}; + +export default function ConfigNew() { + const { config, isEdit } = useLoaderData(); + + const actionData = useActionData(); + const navigation = useNavigation(); + const isSubmitting = navigation.state === "submitting"; + + const [jsonError, setJsonError] = useState(null); + const [configDataValue, setConfigDataValue] = useState(""); + const [exampleJsonValue, setExampleJsonValue] = useState(""); + + // 标签选择状态 + const [selectedModule, setSelectedModule] = useState(""); + const [selectedEnvironment, setSelectedEnvironment] = useState(""); + + useEffect(() => { + // 初始化配置数据 + if (config?.configData) { + setConfigDataValue(config.configData); + } + + // 初始化模块和环境的选中状态 + if (config?.module) { + setSelectedModule(config.module); + } + + if (config?.environment) { + setSelectedEnvironment(config.environment); + } + + // 初始化示例JSON + setExampleJsonValue(JSON.stringify({ + database: { + host: "db.cluster.com", + port: 5432, + pool_size: 20, + ssl: true + }, + cache: { + ttl: 3600, + max_entries: 1000 + }, + feature_flags: ["new_ui", "analytics_v2"] + }, null, 2)); + + }, [config]); + + // 处理JSON数据变更 + const handleConfigDataChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setConfigDataValue(value); + + if (value.trim() === "") { + setJsonError(null); + return; + } + + try { + JSON.parse(value); + setJsonError(null); + } catch (error) { + if (error instanceof Error) { + setJsonError(`配置数据必须是有效的JSON格式: ${error.message}`); + } else { + setJsonError("配置数据必须是有效的JSON格式"); + } + } + }; + + // 格式化JSON + const handleFormatJson = () => { + if (configDataValue.trim() === "") return; + + try { + const parsed = JSON.parse(configDataValue); + setConfigDataValue(JSON.stringify(parsed, null, 2)); + setJsonError(null); + } catch (error) { + if (error instanceof Error) { + setJsonError(`当前不是有效的JSON,无法格式化: ${error.message}`); + } else { + setJsonError("当前不是有效的JSON,无法格式化"); + } + } + }; + + // 加载JSON模板 + const handleLoadTemplate = (type: keyof typeof JSON_TEMPLATES) => { + const template = JSON_TEMPLATES[type]; + setConfigDataValue(JSON.stringify(template, null, 2)); + setJsonError(null); + }; + + // 模块标签点击 + const handleModuleTagClick = (module: string) => { + setSelectedModule(module); + }; + + // 环境标签点击 + const handleEnvironmentTagClick = (env: string) => { + setSelectedEnvironment(env); + }; + + // 显示JSON语法高亮 + const renderJsonWithSyntaxHighlight = (json: string) => { + try { + // 如果是空字符串,直接返回 + if (!json.trim()) return ""; + + // 解析并格式化JSON + const parsed = JSON.parse(json); + const formatted = JSON.stringify(parsed, null, 2); + + // 添加语法高亮 + return formatted + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g, (match) => { + let cls = 'number'; + if (/^"/.test(match)) { + if (/:$/.test(match)) { + cls = 'key'; + match = match.replace(':', ''); + } else { + cls = 'string'; + } + } else if (/true|false/.test(match)) { + cls = 'boolean'; + } else if (/null/.test(match)) { + cls = 'null'; + } + return `${match}`; + }); + } catch (e) { + // 如果解析失败,返回原始JSON + return json; + } + }; + + return ( +
+
+ {isEdit ? "编辑系统配置" : "新增系统配置"} +
+ +
+ {config?.id && } + + + + + + + + +
+
+ + +
+ {config?.id && } + + {/* 配置名称和状态 */} +
+
+ + + {actionData?.errors?.configName && ( +
{actionData.errors.configName}
+ )} +
+ 唯一标识符,配置名称应使用英文,推荐使用下划线命名方式 +
+
+ +
+ +
+
+ + +
+
+ 禁用配置后,系统将不会读取此配置 +
+
+
+
+ + {/* 所属模块 */} +
+ + + + {actionData?.errors?.module && ( +
{actionData.errors.module}
+ )} +
+ {Object.entries(MODULE_LABELS).map(([value, label]) => ( + + ))} +
+
+ 将配置按功能模块进行分类,便于管理和查找 +
+
+ + {/* 环境 */} +
+ + + + {actionData?.errors?.environment && ( +
{actionData.errors.environment}
+ )} +
+ {Object.entries(EXTENDED_ENVIRONMENT_LABELS).map(([value, label]) => ( + + ))} +
+
+ 不同环境可以使用相同的配置名称,系统会自动识别当前环境并使用对应配置 +
+
+ + {/* 配置数据 */} +
+ +
+ {/* 左侧JSON编辑区 */} +
+