重新构建路由和配置样式文件

This commit is contained in:
2025-03-26 10:04:27 +08:00
parent a42a9990bf
commit 97ccf5a077
141 changed files with 88034 additions and 179 deletions
+267 -125
View File
@@ -1,138 +1,280 @@
import type { MetaFunction } from "@remix-run/node";
// import React from 'react';
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";
export const links = () => [
{ rel: "stylesheet", href: "/app/styles/index.css" }
];
export const meta: MetaFunction = () => {
return [
{ title: "New Remix App" },
{ name: "description", content: "Welcome to Remix!" },
{ title: "中国烟草AI合同及卷宗审核系统 - 首页" },
{ name: "description", content: "AI审核系统首页" }
];
};
// API 响应的类型定义
interface StatsData {
totalFiles: number;
reviewedFiles: number;
pendingFiles: number;
passRate: number;
}
interface RecentFile {
id: string;
name: string;
type: string;
reviewStatus: string;
updatedAt: string;
}
// interface LoaderData {
// stats: StatsData;
// recentFiles: RecentFile[];
// }
// 模拟数据,实际项目中应该从API获取
export async function loader() {
try {
// 实际项目中这里应该是 API 调用
// const response = await fetch('/api/dashboard/stats');
// const stats: StatsData = await response.json();
// const filesResponse = await fetch('/api/files/recent');
// const recentFiles: RecentFile[] = await filesResponse.json();
// 模拟数据
const stats = {
totalFiles: 156,
reviewedFiles: 124,
pendingFiles: 32,
passRate: 92.5
} as StatsData;
const recentFiles = [
{
id: "1",
name: "2023年度烟草专卖零售许可证.pdf",
type: "专卖许可证",
reviewStatus: "pass",
updatedAt: "2023-12-24 14:30"
},
{
id: "2",
name: "烟草制品购销合同(2023-12).docx",
type: "合同文档",
reviewStatus: "warning",
updatedAt: "2023-12-23 09:15"
},
{
id: "3",
name: "专卖管理处罚决定书(2023-145).pdf",
type: "行政处罚决定书",
reviewStatus: "fail",
updatedAt: "2023-12-22 16:45"
},
{
id: "4",
name: "2023年第四季度采购合同.docx",
type: "合同文档",
reviewStatus: "pass",
updatedAt: "2023-12-20 11:20"
},
{
id: "5",
name: "广告宣传协议书.pdf",
type: "合同文档",
reviewStatus: "pass",
updatedAt: "2023-12-18 15:30"
}
] as RecentFile[];
return Response.json({ stats, recentFiles });
} catch (error) {
// 错误处理
console.error('Failed to fetch dashboard data:', error);
return Response.json(
{ error: '获取数据失败,请稍后重试' },
{ status: 500 }
);
}
}
export default function Index() {
const { stats, recentFiles } = useLoaderData<typeof loader>();
return (
<div className="flex h-screen items-center justify-center">
<div className="flex flex-col items-center gap-16">
<header className="flex flex-col items-center gap-9">
<h1 className="leading text-2xl font-bold text-gray-800 dark:text-gray-100">
Welcome to <span className="sr-only">Remix</span>
</h1>
<div className="h-[144px] w-[434px]">
<img
src="/logo-light.png"
alt="Remix"
className="block w-full dark:hidden"
/>
<img
src="/logo-dark.png"
alt="Remix"
className="hidden w-full dark:block"
/>
</div>
</header>
<nav className="flex flex-col items-center justify-center gap-4 rounded-3xl border border-gray-200 p-6 dark:border-gray-700">
<p className="leading-6 text-gray-700 dark:text-gray-200">
What&apos;s next?
</p>
<ul>
{resources.map(({ href, text, icon }) => (
<li key={href}>
<a
className="group flex items-center gap-3 self-stretch p-3 leading-normal text-blue-700 hover:underline dark:text-blue-500"
href={href}
target="_blank"
rel="noreferrer"
>
{icon}
{text}
</a>
</li>
))}
</ul>
</nav>
<div className="dashboard-container">
{/* 页面标识 */}
<div className="mb-4 p-3 bg-yellow-100 border border-yellow-300 rounded text-yellow-800">
<h3 className="font-bold text-lg">当前页面: 首页 (_index.tsx)</h3>
<p></p>
<div className="mt-2">
<a href="/debug" className="text-blue-600 hover:underline"></a> |
<a href="/rules" className="ml-2 text-blue-600 hover:underline">访</a>
</div>
</div>
{/* 统计卡片区域 */}
<Card title="统计信息" icon="ri-bar-chart-line" className="mt-6 transition-all duration-200 hover:shadow-[0_4px_15px_rgba(0,0,0,0.1)]">
<div className="stat-grid ">
<StatCard
title="总文件数"
value={stats.totalFiles}
icon="ri-file-list-3-line"
/>
<StatCard
title="已审核"
value={stats.reviewedFiles}
icon="ri-check-double-line"
trend={{ value: 5.2, isUp: true }}
/>
<StatCard
title="待审核"
value={stats.pendingFiles}
icon="ri-time-line"
trend={{ value: 2.1, isUp: false }}
/>
<StatCard
title="通过率"
value={`${stats.passRate}%`}
icon="ri-pie-chart-line"
trend={{ value: 1.5, isUp: true }}
/>
</div>
</Card>
{/* 快捷访问区域 */}
<Card title="快捷访问" icon="ri-speed-line" className="mt-6 transition-all duration-200 hover:shadow-[0_4px_15px_rgba(0,0,0,0.1)]">
<div className="shortcut-grid">
<ShortcutItem icon="ri-upload-cloud-line" label="上传文件" to="/files/new" />
<ShortcutItem icon="ri-file-list-3-line" label="文件列表" to="/files" />
<ShortcutItem icon="ri-list-check-2" label="评查点管理" to="/rules" />
<ShortcutItem icon="ri-folder-open-line" label="评查点分组" to="/rule-groups" />
<ShortcutItem icon="ri-file-chart-line" label="评查详情" to="/reviews" />
<ShortcutItem icon="ri-file-list-line" label="文档类型" to="/doc-types" />
<ShortcutItem icon="ri-settings-3-line" label="系统设置" to="/settings" />
<ShortcutItem icon="ri-chat-1-line" label="提示词管理" to="/prompts" />
</div>
</Card>
{/* 最近文档区域 */}
<Card
title="最近文档"
icon="ri-file-list-3-line"
extra={<Button to="/files" size="small"></Button>}
className="mt-6"
>
<div className="doc-list">
{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>
<div>
<div className="doc-name">{file.name}</div>
<div className="doc-meta">
{file.type} · {file.updatedAt}
</div>
</div>
</div>
<div className="doc-status">
<StatusBadge status={file.reviewStatus} />
</div>
</div>
))}
</div>
</Card>
</div>
);
}
const resources = [
{
href: "https://remix.run/start/quickstart",
text: "Quick Start (5 min)",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="20"
viewBox="0 0 20 20"
fill="none"
className="stroke-gray-600 group-hover:stroke-current dark:stroke-gray-300"
>
<path
d="M8.51851 12.0741L7.92592 18L15.6296 9.7037L11.4815 7.33333L12.0741 2L4.37036 10.2963L8.51851 12.0741Z"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
),
},
{
href: "https://remix.run/start/tutorial",
text: "Tutorial (30 min)",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="20"
viewBox="0 0 20 20"
fill="none"
className="stroke-gray-600 group-hover:stroke-current dark:stroke-gray-300"
>
<path
d="M4.561 12.749L3.15503 14.1549M3.00811 8.99944H1.01978M3.15503 3.84489L4.561 5.2508M8.3107 1.70923L8.3107 3.69749M13.4655 3.84489L12.0595 5.2508M18.1868 17.0974L16.635 18.6491C16.4636 18.8205 16.1858 18.8205 16.0144 18.6491L13.568 16.2028C13.383 16.0178 13.0784 16.0347 12.915 16.239L11.2697 18.2956C11.047 18.5739 10.6029 18.4847 10.505 18.142L7.85215 8.85711C7.75756 8.52603 8.06365 8.21994 8.39472 8.31453L17.6796 10.9673C18.0223 11.0653 18.1115 11.5094 17.8332 11.7321L15.7766 13.3773C15.5723 13.5408 15.5554 13.8454 15.7404 14.0304L18.1868 16.4767C18.3582 16.6481 18.3582 16.926 18.1868 17.0974Z"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
),
},
{
href: "https://remix.run/docs",
text: "Remix Docs",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="20"
viewBox="0 0 20 20"
fill="none"
className="stroke-gray-600 group-hover:stroke-current dark:stroke-gray-300"
>
<path
d="M9.99981 10.0751V9.99992M17.4688 17.4688C15.889 19.0485 11.2645 16.9853 7.13958 12.8604C3.01467 8.73546 0.951405 4.11091 2.53116 2.53116C4.11091 0.951405 8.73546 3.01467 12.8604 7.13958C16.9853 11.2645 19.0485 15.889 17.4688 17.4688ZM2.53132 17.4688C0.951566 15.8891 3.01483 11.2645 7.13974 7.13963C11.2647 3.01471 15.8892 0.951453 17.469 2.53121C19.0487 4.11096 16.9854 8.73551 12.8605 12.8604C8.73562 16.9853 4.11107 19.0486 2.53132 17.4688Z"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
),
},
{
href: "https://rmx.as/discord",
text: "Join Discord",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="20"
viewBox="0 0 24 20"
fill="none"
className="stroke-gray-600 group-hover:stroke-current dark:stroke-gray-300"
>
<path
d="M15.0686 1.25995L14.5477 1.17423L14.2913 1.63578C14.1754 1.84439 14.0545 2.08275 13.9422 2.31963C12.6461 2.16488 11.3406 2.16505 10.0445 2.32014C9.92822 2.08178 9.80478 1.84975 9.67412 1.62413L9.41449 1.17584L8.90333 1.25995C7.33547 1.51794 5.80717 1.99419 4.37748 2.66939L4.19 2.75793L4.07461 2.93019C1.23864 7.16437 0.46302 11.3053 0.838165 15.3924L0.868838 15.7266L1.13844 15.9264C2.81818 17.1714 4.68053 18.1233 6.68582 18.719L7.18892 18.8684L7.50166 18.4469C7.96179 17.8268 8.36504 17.1824 8.709 16.4944L8.71099 16.4904C10.8645 17.0471 13.128 17.0485 15.2821 16.4947C15.6261 17.1826 16.0293 17.8269 16.4892 18.4469L16.805 18.8725L17.3116 18.717C19.3056 18.105 21.1876 17.1751 22.8559 15.9238L23.1224 15.724L23.1528 15.3923C23.5873 10.6524 22.3579 6.53306 19.8947 2.90714L19.7759 2.73227L19.5833 2.64518C18.1437 1.99439 16.6386 1.51826 15.0686 1.25995ZM16.6074 10.7755L16.6074 10.7756C16.5934 11.6409 16.0212 12.1444 15.4783 12.1444C14.9297 12.1444 14.3493 11.6173 14.3493 10.7877C14.3493 9.94885 14.9378 9.41192 15.4783 9.41192C16.0471 9.41192 16.6209 9.93851 16.6074 10.7755ZM8.49373 12.1444C7.94513 12.1444 7.36471 11.6173 7.36471 10.7877C7.36471 9.94885 7.95323 9.41192 8.49373 9.41192C9.06038 9.41192 9.63892 9.93712 9.6417 10.7815C9.62517 11.6239 9.05462 12.1444 8.49373 12.1444Z"
strokeWidth="1.5"
/>
</svg>
),
},
];
// 统计卡片组件
interface StatCardProps {
title: string;
value: number | string;
icon: string;
trend?: {
value: number;
isUp: boolean;
};
}
function StatCard({ title, value, icon, trend }: StatCardProps) {
return (
<div className="stat-card">
<div className="stat-title">{title}</div>
<div className="stat-value">{value}</div>
{trend && (
<div className={`stat-trend ${trend.isUp ? 'trend-up' : 'trend-down'}`}>
<i className={`mr-1 ${trend.isUp ? 'ri-arrow-up-s-line' : 'ri-arrow-down-s-line'}`}></i>
<span>{trend.value}%</span>
<span className="ml-1 text-gray-500"></span>
</div>
)}
<i className={`${icon} stat-icon`}></i>
</div>
);
}
// 快捷方式组件
interface ShortcutItemProps {
icon: string;
label: string;
to: string;
}
function ShortcutItem({ icon, label, to }: ShortcutItemProps) {
return (
<Button
to={to}
type="default"
className="shortcut-item"
>
<i className={`${icon} shortcut-icon text-2xl`}></i>
<span className="shortcut-label">{label}</span>
</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-error-warning-line'
},
fail: {
label: '不通过',
className: 'status-badge status-error',
icon: 'ri-close-circle-line'
},
pending: {
label: '待确认',
className: 'status-badge',
icon: 'ri-time-line'
}
};
const { label, className, icon } = statusMap[status] || statusMap.pending;
return (
<span className={className}>
<i className={`${icon} mr-1`}></i>
{label}
</span>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { useLocation, Link } from "@remix-run/react";
export default function DebugPage() {
const location = useLocation();
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-4"></h1>
<div className="bg-gray-100 p-4 rounded mb-6">
<h2 className="text-xl font-semibold mb-2"></h2>
<pre className="bg-white p-3 rounded border">
{JSON.stringify({
pathname: location.pathname,
search: location.search,
hash: location.hash,
key: location.key,
state: location.state
}, null, 2)}
</pre>
</div>
<div className="mb-6">
<h2 className="text-xl font-semibold mb-2"></h2>
<div className="flex flex-col space-y-2">
<Link to="/" className="text-blue-500 hover:underline"> - /</Link>
<Link to="/rules" className="text-blue-500 hover:underline"> - /rules</Link>
<Link to="/rules/1" className="text-blue-500 hover:underline"> - /rules/1</Link>
<a href="/rules" className="text-green-500 hover:underline"> - /rules</a>
</div>
</div>
<div>
<h2 className="text-xl font-semibold mb-2"></h2>
<button
onClick={() => {
window.location.href = '/rules';
}}
className="bg-blue-500 text-white px-4 py-2 rounded mr-2 hover:bg-blue-600"
>
/rules
</button>
<button
onClick={() => {
window.history.pushState({}, '', '/rules');
window.dispatchEvent(new PopStateEvent('popstate'));
}}
className="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600"
>
使History API跳转到 /rules
</button>
</div>
</div>
);
}
+306
View File
@@ -0,0 +1,306 @@
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>
);
}
View File
+414
View File
@@ -0,0 +1,414 @@
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
@@ -0,0 +1,328 @@
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>
);
}
+384
View File
@@ -0,0 +1,384 @@
import React from 'react';
import { Link } from "@remix-run/react";
import { json, type MetaFunction } from "@remix-run/node";
import { useLoaderData, useNavigate } from "@remix-run/react";
import { Button } from "~/components/ui/Button";
// import stylesUrl from "~/styles/pages/rule-groups.css";
// 引入CSS
// export function links() {
// return [
// { rel: "stylesheet", href: stylesUrl }
// ];
// }
export const meta: MetaFunction = () => {
return [
{ title: "中国烟草AI合同及卷宗审核系统 - 评查点分组列表" },
{ name: "description", content: "评查点分组管理" }
];
};
// 分组接口定义
interface RuleGroup {
id: string;
name: string;
code: string;
ruleCount: number;
childGroupCount: number;
isActive: boolean;
parentId: string | null;
createdAt: string;
level: 1 | 2; // 1-一级分组,2-二级分组
}
interface LoaderData {
groups: RuleGroup[];
}
export async function loader() {
// 模拟数据,实际项目中应该从API获取
const groups: RuleGroup[] = [
{
id: "1",
name: "合同基本要素检查",
code: "contract-base",
ruleCount: 18,
childGroupCount: 2,
isActive: true,
parentId: null,
createdAt: "2023-10-01 14:30",
level: 1
},
{
id: "2",
name: "必备要素检查",
code: "essential-elements",
ruleCount: 7,
childGroupCount: 0,
isActive: true,
parentId: "1",
createdAt: "2023-10-02 10:15",
level: 2
},
{
id: "3",
name: "合同主体检查",
code: "contract-parties",
ruleCount: 5,
childGroupCount: 0,
isActive: true,
parentId: "1",
createdAt: "2023-10-02 11:40",
level: 2
},
{
id: "4",
name: "销售合同专项检查",
code: "sales-contract",
ruleCount: 10,
childGroupCount: 2,
isActive: true,
parentId: null,
createdAt: "2023-10-03 09:20",
level: 1
},
{
id: "5",
name: "交付条款检查",
code: "delivery-terms",
ruleCount: 4,
childGroupCount: 0,
isActive: true,
parentId: "4",
createdAt: "2023-10-03 14:30",
level: 2
},
{
id: "6",
name: "付款条款检查",
code: "payment-terms",
ruleCount: 6,
childGroupCount: 0,
isActive: true,
parentId: "4",
createdAt: "2023-10-03 15:45",
level: 2
},
{
id: "7",
name: "采购合同专项检查",
code: "purchase-contract",
ruleCount: 8,
childGroupCount: 0,
isActive: true,
parentId: null,
createdAt: "2023-10-04 10:15",
level: 1
},
{
id: "8",
name: "行政处罚规范性检查",
code: "admin-punishment",
ruleCount: 12,
childGroupCount: 0,
isActive: false,
parentId: null,
createdAt: "2023-10-05 16:30",
level: 1
}
];
return json<LoaderData>({ groups });
}
export default function RuleGroupsPage() {
const { groups } = useLoaderData<typeof loader>();
const navigate = useNavigate();
// 过滤出父级分组和子级分组
const parentGroups = groups.filter(group => group.parentId === null);
// 根据父级ID获取子分组
const getChildGroups = (parentId: string) => {
return groups.filter(group => group.parentId === parentId);
};
// 模拟删除操作
const handleDelete = (id: string) => {
if (window.confirm("确定要删除该分组吗?删除后无法恢复,且会删除该分组下的所有评查点。")) {
alert(`删除分组: ${id}`);
}
};
// 创建新分组
const handleCreate = () => {
navigate("/rule-groups/new");
};
// 展开/收起状态(实际项目中可以使用useState管理)
const toggleExpand = (groupId: string) => {
const childRows = document.querySelectorAll(`.child-of-${groupId}`);
childRows.forEach(row => {
(row as HTMLElement).style.display =
(row as HTMLElement).style.display === "none" ? "table-row" : "none";
});
// 切换图标
const icon = document.querySelector(`span.expand-icon[data-group-id="${groupId}"] i`);
if (icon) {
icon.classList.toggle("ri-arrow-down-s-line");
icon.classList.toggle("ri-arrow-right-s-line");
}
};
// 键盘处理器
const handleKeyDown = (e: React.KeyboardEvent, groupId: string) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleExpand(groupId);
}
};
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">当前页面: 评查点分组列表 (rule-groups._index.tsx)</h3>
<p></p>
<div className="mt-2">
<a href="/debug" className="text-blue-600 hover:underline"></a> |
<a href="/" className="ml-2 text-blue-600 hover:underline"></a>
</div>
</div>
{/* 页面头部 */}
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-medium"></h2>
<div className="flex">
<Button type="default" className="mr-2" icon="ri-arrow-down-s-line"
onClick={() => document.querySelectorAll(".child-row").forEach(row => (row as HTMLElement).style.display = "table-row")}>
</Button>
<Button type="default" className="mr-2" icon="ri-arrow-up-s-line"
onClick={() => document.querySelectorAll(".child-row").forEach(row => (row as HTMLElement).style.display = "none")}>
</Button>
<Button type="primary" icon="ri-add-line" onClick={handleCreate}>
</Button>
</div>
</div>
{/* 搜索栏 */}
<div className="card mb-4">
<div className="card-body">
<div className="flex flex-wrap items-end gap-4">
<div className="flex-1 min-w-[200px]">
<label htmlFor="groupName" className="form-label"></label>
<input type="text" id="groupName" className="form-input" placeholder="请输入分组名称" />
</div>
<div className="flex-1 min-w-[200px]">
<label htmlFor="groupCode" className="form-label"></label>
<input type="text" id="groupCode" className="form-input" placeholder="请输入分组编码" />
</div>
<div className="flex-1 min-w-[200px]">
<label htmlFor="groupStatus" className="form-label"></label>
<select id="groupStatus" className="form-select">
<option value=""></option>
<option value="true"></option>
<option value="false"></option>
</select>
</div>
<div className="flex items-center">
<Button type="default" className="mr-2" icon="ri-refresh-line">
</Button>
<Button type="primary" icon="ri-search-line">
</Button>
</div>
</div>
</div>
</div>
{/* 数据表格 */}
<div className="card">
<div className="card-body">
<div className="overflow-x-auto">
<table className="table tree-table">
<thead>
<tr>
<th style={{ width: "400px" }}></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th style={{ width: "180px" }}></th>
</tr>
</thead>
<tbody>
{parentGroups.map(parent => (
<React.Fragment key={parent.id}>
{/* 一级分组 */}
<tr className="group-row parent-row" data-group-id={parent.id}>
<td>
<div className="flex items-center">
<span
className="expand-icon"
data-group-id={parent.id}
onClick={() => toggleExpand(parent.id)}
onKeyDown={(e) => handleKeyDown(e, parent.id)}
role="button"
tabIndex={0}
aria-label="展开/收起"
>
<i className="ri-arrow-down-s-line text-primary"></i>
</span>
<Link
to={`/rules?groupId=${parent.id}`}
className="text-primary hover:underline flex items-center ml-1"
>
<i className="ri-folder-line mr-1"></i> {parent.name}
</Link>
<span className="group-badge parent-badge"></span>
</div>
</td>
<td>{parent.code}</td>
<td>
<Link to={`/rules?groupId=${parent.id}`} className="badge bg-primary text-white">
{parent.ruleCount}
</Link>
{parent.childGroupCount > 0 && (
<span className="text-secondary text-sm ml-1">
| : {parent.childGroupCount}
</span>
)}
</td>
<td>
<span className={`status-dot ${parent.isActive ? 'status-success' : 'status-error'}`}></span>
{parent.isActive ? '启用' : '禁用'}
</td>
<td>{parent.createdAt}</td>
<td className="py-3 px-2 text-center">
<Button
type="default"
size="small"
className="text-primary mr-2"
icon="ri-edit-line"
onClick={() => navigate(`/rule-groups/${parent.id}/edit`)}
>
</Button>
<Button
type="danger"
size="small"
icon="ri-delete-bin-line"
onClick={() => handleDelete(parent.id)}
>
</Button>
</td>
</tr>
{/* 二级分组 */}
{getChildGroups(parent.id).map(child => (
<tr
key={child.id}
className={`group-row child-row child-of-${parent.id}`}
data-parent-id={parent.id}
data-group-id={child.id}
>
<td>
<div className="flex items-center ml-8">
<Link
to={`/rules?groupId=${child.id}`}
className="text-primary hover:underline flex items-center"
>
<i className="ri-file-list-line mr-1"></i> {child.name}
</Link>
<span className="group-badge child-badge"></span>
</div>
</td>
<td>{child.code}</td>
<td>
<Link to={`/rules?groupId=${child.id}`} className="badge bg-primary text-white">
{child.ruleCount}
</Link>
</td>
<td>
<span className={`status-dot ${child.isActive ? 'status-success' : 'status-error'}`}></span>
{child.isActive ? '启用' : '禁用'}
</td>
<td>{child.createdAt}</td>
<td className="py-3 px-2 text-center">
<Button
type="default"
size="small"
className="text-primary mr-2"
icon="ri-edit-line"
onClick={() => navigate(`/rule-groups/${child.id}/edit`)}
>
</Button>
<Button
type="danger"
size="small"
icon="ri-delete-bin-line"
onClick={() => handleDelete(child.id)}
>
</Button>
</td>
</tr>
))}
</React.Fragment>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { Outlet } from "@remix-run/react";
import { type MetaFunction } from "@remix-run/node";
export const links = () => [
{ rel: "stylesheet", href: "/rule-groups.css" }
];
export const meta: MetaFunction = () => {
return [
{ title: "中国烟草AI合同及卷宗审核系统 - 评查点分组管理" },
{ name: "description", content: "评查点分组管理页面" }
];
};
/**
* 评查点分组管理路由布局
*/
export default function RuleGroupsLayout() {
return <Outlet />;
}
+351
View File
@@ -0,0 +1,351 @@
import React from 'react';
import { json, type MetaFunction, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData, useSearchParams } from "@remix-run/react";
import { Button } from '~/components/ui/Button';
import { Card } from '~/components/ui/Card';
import { Tag, type TagColor } from '~/components/ui/Tag';
import type { Rule } from '~/models/rule';
import { RULE_TYPE_LABELS, RULE_TYPE_COLORS, RULE_PRIORITY_LABELS, RULE_PRIORITY_COLORS } from '~/models/rule';
export const links = () => [
{ rel: "stylesheet", href: "/rules_index.css" }
];
export const meta: MetaFunction = () => {
return [
{ title: "中国烟草AI合同及卷宗审核系统 - 评查点列表" },
{ name: "description", content: "评查点管理列表" }
];
};
interface LoaderData {
rules: Rule[];
groups: {
id: string;
name: string;
}[];
totalCount: number;
}
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") || "";
// 模拟数据,实际项目中应从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"
}
];
const groups = [
{ id: "1", name: "合同基本要素检查" },
{ id: "2", name: "销售合同专项检查" },
{ id: "3", name: "采购合同专项检查" },
{ id: "4", name: "专卖许可证审核规则" },
{ id: "5", name: "行政处罚规范性检查" }
];
// 过滤数据
let filteredRules = [...rules];
if (ruleType) {
filteredRules = filteredRules.filter(rule => rule.ruleType === ruleType);
}
if (groupId) {
filteredRules = filteredRules.filter(rule => rule.ruleGroupId === groupId);
}
if (isActive) {
const activeValue = isActive === 'true';
filteredRules = filteredRules.filter(rule => rule.isActive === activeValue);
}
if (keyword) {
const lowerKeyword = keyword.toLowerCase();
filteredRules = filteredRules.filter(rule =>
rule.name.toLowerCase().includes(lowerKeyword) ||
rule.code.toLowerCase().includes(lowerKeyword)
);
}
return json<LoaderData>({
rules: filteredRules,
groups,
totalCount: rules.length
});
}
export default function RulesList() {
const { rules, groups } = useLoaderData<typeof loader>();
const [searchParams, setSearchParams] = useSearchParams();
const handleFilterChange = (e: React.ChangeEvent<HTMLSelectElement | HTMLInputElement>) => {
const { name, value } = e.target;
const newParams = new URLSearchParams(searchParams);
if (value) {
newParams.set(name, value);
} else {
newParams.delete(name);
}
setSearchParams(newParams);
};
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
const keyword = formData.get('keyword') as string;
const newParams = new URLSearchParams(searchParams);
if (keyword) {
newParams.set('keyword', keyword);
} else {
newParams.delete('keyword');
}
setSearchParams(newParams);
};
const handleCopy = (rule: Rule) => {
// 实际项目中应调用API复制规则
alert(`复制规则: ${rule.name}`);
};
const handleDelete = (rule: Rule) => {
// 实际项目中应调用API删除规则
if (window.confirm(`确定要删除评查点"${rule.name}"吗?`)) {
alert(`删除规则: ${rule.name}`);
}
};
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">当前页面: 评查点列表 (rules._index.tsx)</h3>
<p></p>
<div className="mt-2">
<a href="/debug" className="text-blue-600 hover:underline"></a> |
<a href="/" className="ml-2 text-blue-600 hover:underline"></a>
</div>
</div>
{/* 页面头部 */}
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-medium"></h2>
<Button type="primary" icon="ri-add-line" to="/rules/new">
</Button>
</div>
{/* 筛选区域 */}
<Card className="mb-4" noDivider>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 p-4">
<div>
<label htmlFor="ruleType" className="block text-sm mb-1"></label>
<select
id="ruleType"
className="form-select w-full rounded border-gray-300 shadow-sm"
name="ruleType"
value={searchParams.get('ruleType') || ''}
onChange={handleFilterChange}
>
<option value=""></option>
{Object.entries(RULE_TYPE_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>
<div>
<label htmlFor="groupId" className="block text-sm mb-1"></label>
<select
id="groupId"
className="form-select w-full rounded border-gray-300 shadow-sm"
name="groupId"
value={searchParams.get('groupId') || ''}
onChange={handleFilterChange}
>
<option value=""></option>
{groups.map((group) => (
<option key={group.id} value={group.id}>{group.name}</option>
))}
</select>
</div>
<div>
<label htmlFor="isActive" className="block text-sm mb-1"></label>
<select
id="isActive"
className="form-select w-full rounded border-gray-300 shadow-sm"
name="isActive"
value={searchParams.get('isActive') || ''}
onChange={handleFilterChange}
>
<option value=""></option>
<option value="true"></option>
<option value="false"></option>
</select>
</div>
<div>
<label htmlFor="keyword" className="block text-sm mb-1"></label>
<form onSubmit={handleSearch} className="flex items-center">
<input
type="text"
id="keyword"
name="keyword"
className="form-input rounded-l flex-1 border-gray-300 shadow-sm"
placeholder="输入评查点名称或编码"
defaultValue={searchParams.get('keyword') || ''}
/>
<Button type="primary" icon="ri-search-line" className="rounded-l-none">
</Button>
</form>
</div>
</div>
</Card>
{/* 评查点列表 */}
<Card noDivider>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead className="bg-gray-50">
<tr className="text-xs text-gray-500 border-b">
<th className="py-3 px-4"></th>
<th className="py-3 px-4"></th>
<th className="py-3 px-4"></th>
<th className="py-3 px-4"></th>
<th className="py-3 px-4"></th>
<th className="py-3 px-4"></th>
<th className="py-3 px-4"></th>
<th className="py-3 px-4"></th>
</tr>
</thead>
<tbody>
{rules.map((rule) => {
const typeColor = RULE_TYPE_COLORS[rule.ruleType] as TagColor;
const priorityColor = RULE_PRIORITY_COLORS[rule.priority] as TagColor;
return (
<tr key={rule.id} className="border-b hover:bg-gray-50">
<td className="py-3 px-4">{rule.code}</td>
<td className="py-3 px-4">{rule.name}</td>
<td className="py-3 px-4">
<Tag color={typeColor}>{RULE_TYPE_LABELS[rule.ruleType]}</Tag>
</td>
<td className="py-3 px-4">{rule.groupName}</td>
<td className="py-3 px-4">
<Tag color={priorityColor}>{RULE_PRIORITY_LABELS[rule.priority]}</Tag>
</td>
<td className="py-3 px-4">
{rule.isActive ? (
<span className="flex items-center">
<i className="inline-block w-2 h-2 rounded-full bg-green-500 mr-2"></i>
</span>
) : (
<span className="flex items-center">
<i className="inline-block w-2 h-2 rounded-full bg-gray-400 mr-2"></i>
</span>
)}
</td>
<td className="py-3 px-4">{rule.createdAt}</td>
<td className="py-3 px-4">
<Button type="default" size="small" icon="ri-edit-line" to={`/rules/${rule.id}`} className="mr-1">
</Button>
<Button type="default" size="small" icon="ri-file-copy-line" className="mr-1" onClick={() => handleCopy(rule)}>
</Button>
<Button type="danger" size="small" icon="ri-delete-bin-line" onClick={() => handleDelete(rule)}>
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { Outlet } from "@remix-run/react";
import { type MetaFunction } from "@remix-run/node";
export const meta: MetaFunction = () => {
return [
{ title: "中国烟草AI合同及卷宗审核系统 - 规则管理" },
{ name: "description", content: "规则管理页面" }
];
};
/**
* 规则管理路由布局
*/
export default function RulesLayout() {
return (
<>
<Outlet />
</>
);
}
+418
View File
@@ -0,0 +1,418 @@
import React, { useState } from 'react';
import { redirect, type MetaFunction, type ActionFunctionArgs, type LoaderFunctionArgs } from '@remix-run/node';
import { useLoaderData, useActionData, Form, useSubmit, useNavigate } from '@remix-run/react';
import { Button } from '~/components/ui/Button';
import { Card } from '~/components/ui/Card';
import { Breadcrumb } from '~/components/layout/Breadcrumb';
import type { Rule, RuleType, RulePriority } from '~/models/rule';
export const meta: MetaFunction = () => {
return [
{ title: "中国烟草AI合同及卷宗审核系统 - 评查规则详情" },
{ name: "description", content: "评查规则详情编辑页面" }
];
};
export const handle = {
breadcrumb: '规则详情'
};
interface LoaderData {
rule: Rule;
ruleTypes: { label: string; value: RuleType }[];
rulePriorities: { label: string; value: RulePriority }[];
groupOptions: { label: string; value: string }[];
}
export async function loader({ params }: LoaderFunctionArgs) {
const { ruleId } = params;
// 判断是否为新建规则
const isNewRule = ruleId === 'new';
// 模拟数据,实际项目中应从API获取
const rule: Rule = isNewRule ? {
id: '',
name: '',
description: '',
content: '',
type: 'text',
priority: 'medium',
groupId: '',
groupName: '',
isActive: true,
createdAt: '',
updatedAt: ''
} : {
id: ruleId,
name: '许可证编号格式检查',
description: '检查烟草专卖零售许可证编号是否符合"烟零许(年份)序号号"的标准格式',
content: '许可证编号应当符合"烟零许(年份)序号号"的标准格式,如"烟零许(2023)12345号"',
type: 'regex',
priority: 'high',
groupId: '1',
groupName: '专卖许可证规则组',
isActive: true,
createdAt: '2023-10-15 09:30',
updatedAt: '2023-12-10 14:20'
};
// 规则类型选项
const ruleTypes = [
{ label: '文本匹配', value: 'text' },
{ label: '正则表达式', value: 'regex' },
{ label: '数值范围', value: 'range' },
{ label: '日期检查', value: 'date' },
{ label: 'AI智能检查', value: 'ai' }
];
// 规则优先级选项
const rulePriorities = [
{ label: '低', value: 'low' },
{ label: '中', value: 'medium' },
{ label: '高', value: 'high' },
{ label: '关键', value: 'critical' }
];
// 规则组选项
const groupOptions = [
{ label: '专卖许可证规则组', value: '1' },
{ label: '合同协议规则组', value: '2' },
{ label: '财务票据规则组', value: '3' },
{ label: '采购订单规则组', value: '4' },
{ label: '销售报表规则组', value: '5' }
];
return Response.json({
rule,
ruleTypes,
rulePriorities,
groupOptions
});
}
interface ActionData {
success?: boolean;
errors?: {
name?: string;
description?: string;
content?: string;
type?: string;
priority?: string;
groupId?: string;
general?: string;
};
}
export async function action({ request, params }: ActionFunctionArgs) {
const { ruleId } = params;
const formData = await request.formData();
const isNewRule = ruleId === 'new';
// 获取表单数据
const name = formData.get('name')?.toString() || '';
const description = formData.get('description')?.toString() || '';
const content = formData.get('content')?.toString() || '';
const type = formData.get('type')?.toString() || '';
const priority = formData.get('priority')?.toString() || '';
const groupId = formData.get('groupId')?.toString() || '';
const isActive = formData.get('isActive') === 'true';
// 表单验证
const errors: ActionData['errors'] = {};
if (!name.trim()) {
errors.name = '规则名称不能为空';
}
if (!content.trim()) {
errors.content = '规则内容不能为空';
}
if (!type) {
errors.type = '必须选择规则类型';
}
if (!priority) {
errors.priority = '必须选择规则优先级';
}
if (!groupId) {
errors.groupId = '必须选择规则所属组';
}
if (Object.keys(errors).length > 0) {
return Response.json({ errors });
}
// 模拟API保存操作,实际项目中应调用API
try {
// 在这里调用API进行保存
console.log('保存规则:', {
id: isNewRule ? 'new-id' : ruleId,
name,
description,
content,
type,
priority,
groupId,
isActive
});
// 成功后重定向到规则列表页
return redirect('/rules');
} catch (error) {
return Response.json({
errors: {
general: '保存规则失败,请重试'
}
});
}
}
export default function RuleDetail() {
const { rule, ruleTypes, rulePriorities, groupOptions } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigate = useNavigate();
const submit = useSubmit();
const [formData, setFormData] = useState({
name: rule.name,
description: rule.description,
content: rule.content,
type: rule.type,
priority: rule.priority,
groupId: rule.groupId,
isActive: rule.isActive
});
const isNewRule = !rule.id;
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleSwitchChange = (name: string, checked: boolean) => {
setFormData(prev => ({ ...prev, [name]: checked }));
};
const handleCancel = () => {
navigate('/rules');
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
// 使用useSubmit提交表单
const formElement = e.currentTarget;
submit(formElement, { method: 'post' });
};
return (
<div>
<Breadcrumb
items={[
{ title: '评查规则', to: '/rules' },
{ title: isNewRule ? '新增规则' : '编辑规则', to: `/rules/${rule.id}` }
]}
/>
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-medium">{isNewRule ? '新增评查规则' : '编辑评查规则'}</h2>
</div>
<Card>
<Form method="post" onSubmit={handleSubmit}>
{actionData?.errors?.general && (
<div className="error-message mb-4">
<i className="ri-error-warning-line mr-1"></i>
{actionData.errors.general}
</div>
)}
<div className="form-section mb-6">
<h3 className="form-section-title"></h3>
<div className="form-row">
<div className="form-group col-span-6">
<label htmlFor="name" className="form-label required"></label>
<input
type="text"
id="name"
name="name"
className={`form-input ${actionData?.errors?.name ? 'error' : ''}`}
value={formData.name}
onChange={handleChange}
required
/>
{actionData?.errors?.name && (
<div className="form-error">{actionData.errors.name}</div>
)}
</div>
<div className="form-group col-span-6">
<label htmlFor="groupId" className="form-label required"></label>
<select
id="groupId"
name="groupId"
className={`form-select ${actionData?.errors?.groupId ? 'error' : ''}`}
value={formData.groupId}
onChange={handleChange}
required
>
<option value=""></option>
{groupOptions.map((option: { value: string; label: string }) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
{actionData?.errors?.groupId && (
<div className="form-error">{actionData.errors.groupId}</div>
)}
</div>
</div>
<div className="form-row">
<div className="form-group col-span-12">
<label htmlFor="description" className="form-label"></label>
<textarea
id="description"
name="description"
className="form-textarea"
rows={3}
value={formData.description}
onChange={handleChange}
></textarea>
</div>
</div>
</div>
<div className="form-section mb-6">
<h3 className="form-section-title"></h3>
<div className="form-row">
<div className="form-group col-span-4">
<label htmlFor="type" className="form-label required"></label>
<select
id="type"
name="type"
className={`form-select ${actionData?.errors?.type ? 'error' : ''}`}
value={formData.type}
onChange={handleChange}
required
>
<option value=""></option>
{ruleTypes.map((option: { value: string; label: string }) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
{actionData?.errors?.type && (
<div className="form-error">{actionData.errors.type}</div>
)}
</div>
<div className="form-group col-span-4">
<label htmlFor="priority" className="form-label required"></label>
<select
id="priority"
name="priority"
className={`form-select ${actionData?.errors?.priority ? 'error' : ''}`}
value={formData.priority}
onChange={handleChange}
required
>
<option value=""></option>
{rulePriorities.map((option: { value: string; label: string }) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
{actionData?.errors?.priority && (
<div className="form-error">{actionData.errors.priority}</div>
)}
</div>
<div className="form-group col-span-4">
<label htmlFor="isActive" className="form-label"></label>
<div className="flex items-center h-10 mt-1">
<label className="switch" aria-label="切换规则状态">
<input
type="checkbox"
name="isActive"
checked={formData.isActive}
onChange={(e) => handleSwitchChange('isActive', e.target.checked)}
/>
<span className="slider round"></span>
</label>
<input type="hidden" name="isActive" value={formData.isActive ? 'true' : 'false'} />
<span className="ml-2">{formData.isActive ? '启用' : '禁用'}</span>
</div>
</div>
</div>
<div className="form-row">
<div className="form-group col-span-12">
<label htmlFor="content" className="form-label required"></label>
<textarea
id="content"
name="content"
className={`form-textarea code-editor ${actionData?.errors?.content ? 'error' : ''}`}
rows={8}
value={formData.content}
onChange={handleChange}
required
></textarea>
{actionData?.errors?.content && (
<div className="form-error">{actionData.errors.content}</div>
)}
{formData.type === 'regex' && (
<div className="text-xs text-gray-500 mt-1">
<i className="ri-information-line mr-1"></i>
</div>
)}
{formData.type === 'ai' && (
<div className="text-xs text-gray-500 mt-1">
<i className="ri-information-line mr-1"></i>
使AI将自动理解并执行检查
</div>
)}
</div>
</div>
</div>
<div className="form-section mb-6">
<h3 className="form-section-title"></h3>
<div className="p-4 bg-gray-50 rounded-md">
<div className="mb-4">
<label htmlFor="testContent" className="form-label"></label>
<textarea
id="testContent"
className="form-textarea"
rows={4}
placeholder="粘贴待测试的文本内容..."
></textarea>
</div>
<div className="flex justify-end">
<Button type="default">
<i className="ri-test-tube-line mr-1"></i>
</Button>
</div>
</div>
</div>
<div className="flex justify-end space-x-2">
<Button type="default" onClick={handleCancel}>
</Button>
<Button type="primary">
{isNewRule ? '创建规则' : '保存修改'}
</Button>
</div>
</Form>
</Card>
</div>
);
}